internal/httpd/badge.go

e3632a550366fe23b2619edba30fc58fab19b598
gitbay/internal/httpd/badge.go history · blame · raw

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