internal/httpd/badge.go
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}