internal/httpd/badge.go

81bb0d14080e45ffcd6ca844cbe506ce1f10e22a
gitbay/internal/httpd/badge.go history · blame · raw

116 lines · 3401 bytes

  1package httpd
  2
  3import (
  4	"fmt"
  5	"net/http"
  6	"strings"
  7)
  8
  9// Status badges. A badge is a small SVG served for public repositories,
 10// so a README on any host can show whether the latest build passed.
 11// Private repositories 404 like everywhere else — a badge must not leak
 12// that a repo exists, let alone its state.
 13
 14// badgeColors are the shield fills per build state.
 15var badgeColors = map[string]string{
 16	"success": "#2da44e",
 17	"failure": "#cf222e",
 18	"running": "#bf8700",
 19	"pending": "#bf8700",
 20	"unknown": "#6b7280",
 21}
 22
 23// badgeWidth approximates Verdana 11px advance so the pill fits its text
 24// without shipping a font metric table.
 25func badgeWidth(s string) int {
 26	w := 0
 27	for _, r := range s {
 28		switch {
 29		case strings.ContainsRune("iljtIJ1.,:;'", r):
 30			w += 4
 31		case strings.ContainsRune("mwMW", r):
 32			w += 10
 33		case r >= 'A' && r <= 'Z':
 34			w += 8
 35		default:
 36			w += 7
 37		}
 38	}
 39	return w + 10
 40}
 41
 42// badgeSVG renders a two-part pill: a grey label and a coloured state.
 43func badgeSVG(label, state string) string {
 44	color := badgeColors[state]
 45	if color == "" {
 46		color = badgeColors["unknown"]
 47	}
 48	lw, sw := badgeWidth(label), badgeWidth(state)
 49	total := lw + sw
 50	return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s">
 51<title>%s: %s</title>
 52<rect width="%d" height="20" rx="3" fill="#444d56"/>
 53<rect x="%d" width="%d" height="20" rx="3" fill="%s"/>
 54<rect x="%d" width="4" height="20" fill="%s"/>
 55<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
 56<text x="%d" y="14">%s</text>
 57<text x="%d" y="14">%s</text>
 58</g>
 59</svg>`, total, label, state, label, state,
 60		total, lw, sw, color, lw, color, lw/2, label, lw+sw/2, state)
 61}
 62
 63// buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name].
 64func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) {
 65	label, state, ok := s.badgeState(r)
 66	if !ok {
 67		http.NotFound(w, r)
 68		return
 69	}
 70	badgeHeaders(w, "image/svg+xml; charset=utf-8")
 71	fmt.Fprint(w, badgeSVG(label, state))
 72}
 73
 74// buildBadgePNG answers GET /{owner}/{repo}/badge/build.png[?job=name], for
 75// readers that cannot decode SVG.
 76func (s *Server) buildBadgePNG(w http.ResponseWriter, r *http.Request) {
 77	label, state, ok := s.badgeState(r)
 78	if !ok {
 79		http.NotFound(w, r)
 80		return
 81	}
 82	out, err := badgePNG(label, state)
 83	if err != nil {
 84		http.Error(w, "badge unavailable", http.StatusInternalServerError)
 85		return
 86	}
 87	badgeHeaders(w, "image/png")
 88	w.Write(out)
 89}
 90
 91// badgeState resolves the repo and its latest build. Private repositories
 92// report not ok — a badge must not leak that a repo exists.
 93func (s *Server) badgeState(r *http.Request) (label, state string, ok bool) {
 94	repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git"))
 95	if !ok {
 96		return "", "", false
 97	}
 98	job := r.URL.Query().Get("job")
 99	label = "build"
100	if job != "" {
101		label = job
102	}
103	state = "unknown"
104	if b, err := s.st.LatestBuild(repo.ID, job); err == nil {
105		state = b.Status
106	}
107	return label, state, true
108}
109
110func badgeHeaders(w http.ResponseWriter, contentType string) {
111	w.Header().Set("Content-Type", contentType)
112	// Badges are read by other people's caches; keep them briefly fresh
113	// rather than pinned to a stale result.
114	w.Header().Set("Cache-Control", "max-age=60, must-revalidate")
115	w.Header().Set("X-Content-Type-Options", "nosniff")
116}