Commit 57ae9420a9
Verified · cmc ci/build: success ci/test: success
go.mod +1
| @@ -32,6 +32,7 @@ require ( | ||
| 32 | 32 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 33 | 33 | github.com/russross/blackfriday/v2 v2.1.0 // indirect |
| 34 | 34 | github.com/spf13/pflag v1.0.9 // indirect |
| 35 | golang.org/x/image v0.45.0 // indirect | |
| 35 | 36 | golang.org/x/sys v0.47.0 // indirect |
| 36 | 37 | golang.org/x/text v0.41.0 // indirect |
| 37 | 38 | modernc.org/libc v1.74.4 // indirect |
go.sum +2
| @@ -65,6 +65,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||
| 65 | 65 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 66 | 66 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 67 | 67 | golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| 68 | golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= | |
| 69 | golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= | |
| 68 | 70 | golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| 69 | 71 | golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| 70 | 72 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= |
internal/httpd/badge.go +34 −7
| @@ -62,28 +62,55 @@ func badgeSVG(label, state string) string { | ||
| 62 | 62 | |
| 63 | 63 | // buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name]. |
| 64 | 64 | func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) { |
| 65 | repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git")) | |
| 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. | |
| 76 | func (s *Server) buildBadgePNG(w http.ResponseWriter, r *http.Request) { | |
| 77 | label, state, ok := s.badgeState(r) | |
| 66 | 78 | if !ok { |
| 67 | 79 | http.NotFound(w, r) |
| 68 | 80 | return |
| 69 | 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. | |
| 93 | func (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 | } | |
| 70 | 98 | job := r.URL.Query().Get("job") |
| 71 | label := "build" | |
| 99 | label = "build" | |
| 72 | 100 | if job != "" { |
| 73 | 101 | label = job |
| 74 | 102 | } |
| 75 | state := "unknown" | |
| 103 | state = "unknown" | |
| 76 | 104 | if b, err := s.st.LatestBuild(repo.ID, job); err == nil { |
| 77 | 105 | state = b.Status |
| 78 | 106 | } |
| 79 | writeBadge(w, label, state) | |
| 107 | return label, state, true | |
| 80 | 108 | } |
| 81 | 109 | |
| 82 | func writeBadge(w http.ResponseWriter, label, state string) { | |
| 83 | w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") | |
| 110 | func badgeHeaders(w http.ResponseWriter, contentType string) { | |
| 111 | w.Header().Set("Content-Type", contentType) | |
| 84 | 112 | // Badges are read by other people's caches; keep them briefly fresh |
| 85 | 113 | // rather than pinned to a stale result. |
| 86 | 114 | w.Header().Set("Cache-Control", "max-age=60, must-revalidate") |
| 87 | 115 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 88 | fmt.Fprint(w, badgeSVG(label, state)) | |
| 89 | 116 | } |
internal/httpd/badgepng.go added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "image" | |
| 6 | "image/color" | |
| 7 | "image/draw" | |
| 8 | "image/png" | |
| 9 | "sync" | |
| 10 | ||
| 11 | "golang.org/x/image/font" | |
| 12 | "golang.org/x/image/font/gofont/goregular" | |
| 13 | "golang.org/x/image/font/opentype" | |
| 14 | "golang.org/x/image/math/fixed" | |
| 15 | ) | |
| 16 | ||
| 17 | // The PNG badge, for readers that cannot decode SVG. iOS is the case that | |
| 18 | // forced it: UIImage decodes SVG only from an asset catalog, so a native | |
| 19 | // README view has no way to render the SVG badge. It is drawn at 2x because a | |
| 20 | // raster badge has no other way to stay sharp, and a badge carries no channel | |
| 21 | // to declare its own scale. | |
| 22 | ||
| 23 | const ( | |
| 24 | badgeScale = 2 | |
| 25 | badgeHeight = 20 | |
| 26 | badgeRadius = 3 | |
| 27 | badgeFontPt = 11 | |
| 28 | ) | |
| 29 | ||
| 30 | // badgeFace is parsed once. Go Regular, not the Verdana the SVG names: the SVG | |
| 31 | // asks a browser for a font it already has, while a raster badge has to carry | |
| 32 | // one, and the Go fonts are the only TTF in the module graph. | |
| 33 | var badgeFace = sync.OnceValues(func() (font.Face, error) { | |
| 34 | parsed, err := opentype.Parse(goregular.TTF) | |
| 35 | if err != nil { | |
| 36 | return nil, err | |
| 37 | } | |
| 38 | return opentype.NewFace(parsed, &opentype.FaceOptions{ | |
| 39 | Size: badgeFontPt * badgeScale, | |
| 40 | DPI: 72, | |
| 41 | // Hinting rounds stems to the pixel grid, which is what keeps 11px text | |
| 42 | // legible rather than smeared. | |
| 43 | Hinting: font.HintingFull, | |
| 44 | }) | |
| 45 | }) | |
| 46 | ||
| 47 | // badgePNG draws the same two-part pill badgeSVG describes. | |
| 48 | // | |
| 49 | // Widths are measured against the real face rather than reusing badgeWidth's | |
| 50 | // Verdana approximation, which exists only so the SVG need not ship metrics. | |
| 51 | // Here the metrics are already loaded, so the pill fits its text exactly. | |
| 52 | func badgePNG(label, state string) ([]byte, error) { | |
| 53 | face, err := badgeFace() | |
| 54 | if err != nil { | |
| 55 | return nil, err | |
| 56 | } | |
| 57 | fill := badgeColors[state] | |
| 58 | if fill == "" { | |
| 59 | fill = badgeColors["unknown"] | |
| 60 | } | |
| 61 | ||
| 62 | pad := 10 * badgeScale | |
| 63 | lw := font.MeasureString(face, label).Ceil() + pad | |
| 64 | sw := font.MeasureString(face, state).Ceil() + pad | |
| 65 | h := badgeHeight * badgeScale | |
| 66 | r := badgeRadius * badgeScale | |
| 67 | ||
| 68 | img := image.NewRGBA(image.Rect(0, 0, lw+sw, h)) | |
| 69 | // Three fills, as in the SVG: the grey pill, the coloured pill over its | |
| 70 | // right half, then a square patch squaring off that pill's left edge. | |
| 71 | fillRoundRect(img, image.Rect(0, 0, lw+sw, h), r, mustHex("#444d56")) | |
| 72 | fillRoundRect(img, image.Rect(lw, 0, lw+sw, h), r, mustHex(fill)) | |
| 73 | draw.Draw(img, image.Rect(lw, 0, lw+2*r, h), image.NewUniform(mustHex(fill)), image.Point{}, draw.Src) | |
| 74 | ||
| 75 | // y=14 of 20 in the SVG is the baseline; it is the same fraction here. | |
| 76 | baseline := 14 * badgeScale | |
| 77 | drawCentered(img, face, label, lw/2, baseline) | |
| 78 | drawCentered(img, face, state, lw+sw/2, baseline) | |
| 79 | ||
| 80 | var out bytes.Buffer | |
| 81 | if err := png.Encode(&out, img); err != nil { | |
| 82 | return nil, err | |
| 83 | } | |
| 84 | return out.Bytes(), nil | |
| 85 | } | |
| 86 | ||
| 87 | func drawCentered(dst *image.RGBA, face font.Face, s string, centerX, baseline int) { | |
| 88 | width := font.MeasureString(face, s) | |
| 89 | drawer := font.Drawer{ | |
| 90 | Dst: dst, | |
| 91 | Src: image.NewUniform(color.White), | |
| 92 | Face: face, | |
| 93 | Dot: fixed.P(centerX, baseline).Sub(fixed.Point26_6{X: width / 2}), | |
| 94 | } | |
| 95 | drawer.DrawString(s) | |
| 96 | } | |
| 97 | ||
| 98 | // fillRoundRect fills r with c, rounding its four corners by radius. | |
| 99 | func fillRoundRect(dst *image.RGBA, r image.Rectangle, radius int, c color.Color) { | |
| 100 | for y := r.Min.Y; y < r.Max.Y; y++ { | |
| 101 | for x := r.Min.X; x < r.Max.X; x++ { | |
| 102 | if insideRoundRect(x, y, r, radius) { | |
| 103 | dst.Set(x, y, c) | |
| 104 | } | |
| 105 | } | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | func insideRoundRect(x, y int, r image.Rectangle, radius int) bool { | |
| 110 | // Corner centres, inset by the radius. A pixel outside the rectangle the | |
| 111 | // corners inscribe is in a corner, and belongs only if it is within radius | |
| 112 | // of the nearest centre. | |
| 113 | cx, cy := x, y | |
| 114 | switch { | |
| 115 | case x < r.Min.X+radius: | |
| 116 | cx = r.Min.X + radius | |
| 117 | case x >= r.Max.X-radius: | |
| 118 | cx = r.Max.X - radius - 1 | |
| 119 | } | |
| 120 | switch { | |
| 121 | case y < r.Min.Y+radius: | |
| 122 | cy = r.Min.Y + radius | |
| 123 | case y >= r.Max.Y-radius: | |
| 124 | cy = r.Max.Y - radius - 1 | |
| 125 | } | |
| 126 | if cx == x && cy == y { | |
| 127 | return true | |
| 128 | } | |
| 129 | dx, dy := x-cx, y-cy | |
| 130 | return dx*dx+dy*dy <= radius*radius | |
| 131 | } | |
| 132 | ||
| 133 | // mustHex parses the "#rrggbb" literals badgeColors holds. They are constants | |
| 134 | // in this package, so a bad one is a programming error, not input. | |
| 135 | func mustHex(s string) color.RGBA { | |
| 136 | var r, g, b uint8 | |
| 137 | for i, shift := range []uint{0, 1, 2} { | |
| 138 | hi, lo := hexNibble(s[1+i*2]), hexNibble(s[2+i*2]) | |
| 139 | v := hi<<4 | lo | |
| 140 | switch shift { | |
| 141 | case 0: | |
| 142 | r = v | |
| 143 | case 1: | |
| 144 | g = v | |
| 145 | case 2: | |
| 146 | b = v | |
| 147 | } | |
| 148 | } | |
| 149 | return color.RGBA{R: r, G: g, B: b, A: 255} | |
| 150 | } | |
| 151 | ||
| 152 | func hexNibble(c byte) uint8 { | |
| 153 | switch { | |
| 154 | case c >= '0' && c <= '9': | |
| 155 | return c - '0' | |
| 156 | case c >= 'a' && c <= 'f': | |
| 157 | return c - 'a' + 10 | |
| 158 | default: | |
| 159 | return 0 | |
| 160 | } | |
| 161 | } | |
internal/httpd/badgepng_test.go added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "image" | |
| 6 | "image/color" | |
| 7 | "image/png" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func decodeBadge(t *testing.T, label, state string) image.Image { | |
| 12 | t.Helper() | |
| 13 | out, err := badgePNG(label, state) | |
| 14 | if err != nil { | |
| 15 | t.Fatalf("badgePNG(%q, %q): %v", label, state, err) | |
| 16 | } | |
| 17 | img, err := png.Decode(bytes.NewReader(out)) | |
| 18 | if err != nil { | |
| 19 | t.Fatalf("decode %q: %v", state, err) | |
| 20 | } | |
| 21 | return img | |
| 22 | } | |
| 23 | ||
| 24 | func TestBadgePNGGeometry(t *testing.T) { | |
| 25 | img := decodeBadge(t, "build", "success") | |
| 26 | if got, want := img.Bounds().Dy(), badgeHeight*badgeScale; got != want { | |
| 27 | t.Errorf("height = %d, want %d", got, want) | |
| 28 | } | |
| 29 | // A longer label makes a wider badge; the pill is sized to its text. | |
| 30 | if decodeBadge(t, "integration", "success").Bounds().Dx() <= img.Bounds().Dx() { | |
| 31 | t.Error("a longer label did not widen the badge") | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | func TestBadgePNGUsesTheStateColour(t *testing.T) { | |
| 36 | // A point inside the right-hand pill, clear of the rounded corners. | |
| 37 | for state, hex := range badgeColors { | |
| 38 | img := decodeBadge(t, "build", state) | |
| 39 | b := img.Bounds() | |
| 40 | got := color.RGBAModel.Convert(img.At(b.Max.X-4, b.Dy()/2)).(color.RGBA) | |
| 41 | if want := mustHex(hex); got != want { | |
| 42 | t.Errorf("%s: pill colour = %v, want %v", state, got, want) | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // An unknown state takes the grey the SVG gives it rather than a transparent pill. | |
| 48 | func TestBadgePNGUnknownStateFallsBack(t *testing.T) { | |
| 49 | img := decodeBadge(t, "build", "no-such-state") | |
| 50 | b := img.Bounds() | |
| 51 | got := color.RGBAModel.Convert(img.At(b.Max.X-4, b.Dy()/2)).(color.RGBA) | |
| 52 | if want := mustHex(badgeColors["unknown"]); got != want { | |
| 53 | t.Errorf("pill colour = %v, want %v", got, want) | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | // The corners are rounded, so the very corner pixel is left transparent. | |
| 58 | func TestBadgePNGCornersAreRounded(t *testing.T) { | |
| 59 | img := decodeBadge(t, "build", "success") | |
| 60 | if _, _, _, a := img.At(img.Bounds().Min.X, img.Bounds().Min.Y).RGBA(); a != 0 { | |
| 61 | t.Errorf("top-left alpha = %d, want 0", a) | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | func TestMustHex(t *testing.T) { | |
| 66 | if got, want := mustHex("#2da44e"), (color.RGBA{R: 0x2d, G: 0xa4, B: 0x4e, A: 255}); got != want { | |
| 67 | t.Errorf("mustHex = %v, want %v", got, want) | |
| 68 | } | |
| 69 | } | |
internal/httpd/routes.go +1
| @@ -59,6 +59,7 @@ func (s *Server) Routes() []Route { | ||
| 59 | 59 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, |
| 60 | 60 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, |
| 61 | 61 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, |
| 62 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG}, | |
| 62 | 63 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build}, |
| 63 | 64 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset}, |
| 64 | 65 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |