Commit 2538f3fdc6
2538f3fdc63b4c6dd660ebcd120381b954b1d310
parent: 11c4aee7c5
Verified · cmc ci/build: success ci/test: success
cmc <hello@cleberg.net> · 2026-09-20 06:50 UTC
httpd: stamp the stylesheet hash on its URL
/static/style.css was served max-age=86400, must-revalidate at an
unversioned URL. must-revalidate only forces a check once freshness
expires, so for a day after a deploy a browser rendered the new markup
against its old stylesheet, and the ETag was never consulted.
Seen on a phone minutes after v1.31.0: the cached sheet predated !440,
so .railopt, .railmore and .raildrop had no rules. Every rail icon
showed instead of collapsing to five, and the More menu's drop rendered
as a line of inline links across the rail.
The hash that was already the ETag is now also the ?v= the layout stamps
on the link, so a deploy changes the URL. That URL names bytes that
cannot change and is served immutable; the bare URL keeps its lifetime.
Closes #239
internal/httpd/anchors_test.go
+42
| @@ -7,6 +7,8 @@ import ( |
| 7 | 7 | "testing" |
| 8 | 8 | |
| 9 | 9 | "gitbay.org/gitbay/internal/config" |
| 10 | "gitbay.org/gitbay/internal/store" |
| 11 | "gitbay.org/gitbay/internal/web" |
| 10 | 12 | ) |
| 11 | 13 | |
| 12 | 14 | // Markdown headings carry ids, so a README section can be deep-linked |
| @@ -38,3 +40,43 @@ func TestStylesheetRevalidates(t *testing.T) { |
| 38 | 40 | t.Fatalf("revalidation: %d with %d bytes", second.Code, second.Body.Len()) |
| 39 | 41 | } |
| 40 | 42 | } |
| 43 | |
| 44 | // The layout stamps the served bytes' hash on the stylesheet URL, so a |
| 45 | // deploy changes that URL and a browser cannot answer it from a copy of |
| 46 | // the old sheet. A URL carrying the hash names bytes that cannot change |
| 47 | // and is served without revalidation; the bare URL keeps its lifetime |
| 48 | // (#239). |
| 49 | func TestStylesheetURLCarriesTheBuildHash(t *testing.T) { |
| 50 | var sb strings.Builder |
| 51 | var base basePage |
| 52 | base.Viewer = "alice" |
| 53 | err := web.Render(&sb, "dashboard.html", struct { |
| 54 | basePage |
| 55 | Tab string |
| 56 | Pins []pinnedRow |
| 57 | Reviews []store.DashboardItem |
| 58 | Assigned []store.DashboardItem |
| 59 | MRs []store.DashboardItem |
| 60 | Issues []store.DashboardItem |
| 61 | Feed []feedLine |
| 62 | }{base, "dashboard", nil, nil, nil, nil, nil, nil}) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | want := `href="/static/style.css?v=` + stylesheetHash + `"` |
| 67 | if !strings.Contains(sb.String(), want) { |
| 68 | t.Errorf("the page does not link %s", want) |
| 69 | } |
| 70 | |
| 71 | s := New(config.Default(), nil) |
| 72 | versioned := httptest.NewRecorder() |
| 73 | s.stylesheet(versioned, httptest.NewRequest("GET", "/static/style.css?v="+stylesheetHash, nil)) |
| 74 | if cc := versioned.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { |
| 75 | t.Errorf("versioned URL: Cache-Control = %q, want immutable", cc) |
| 76 | } |
| 77 | bare := httptest.NewRecorder() |
| 78 | s.stylesheet(bare, httptest.NewRequest("GET", "/static/style.css", nil)) |
| 79 | if cc := bare.Header().Get("Cache-Control"); !strings.Contains(cc, "must-revalidate") { |
| 80 | t.Errorf("bare URL: Cache-Control = %q, want must-revalidate", cc) |
| 81 | } |
| 82 | } |
internal/httpd/web.go
+19 −6
| @@ -64,19 +64,32 @@ func (s *Server) siteName() string { |
| 64 | 64 | return strings.TrimSuffix(h, "/") |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | | // stylesheetETag is the hash of what stylesheet serves, computed once: |
| 68 | | // a browser revalidates with If-None-Match and gets a 304 until a deploy |
| 69 | | // changes the bytes (#132). |
| 70 | | var stylesheetETag = func() string { |
| 67 | // stylesheetHash is the hash of what stylesheet serves, computed once. It |
| 68 | // is the ETag, so a browser revalidating with If-None-Match gets a 304 |
| 69 | // until a deploy changes the bytes (#132), and it is the ?v= the layout |
| 70 | // stamps on the URL, so a deploy the browser has not fetched yet cannot be |
| 71 | // answered from its cache (#239). |
| 72 | var stylesheetHash = func() string { |
| 71 | 73 | h := sha256.New() |
| 72 | 74 | h.Write(styleCSS) |
| 73 | 75 | h.Write(chromaCSS) |
| 74 | | return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` |
| 76 | return hex.EncodeToString(h.Sum(nil))[:16] |
| 75 | 77 | }() |
| 76 | 78 | |
| 79 | var stylesheetETag = `"` + stylesheetHash + `"` |
| 80 | |
| 81 | func init() { web.StyleVersion = stylesheetHash } |
| 82 | |
| 77 | 83 | func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { |
| 78 | 84 | w.Header().Set("ETag", stylesheetETag) |
| 79 | | w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate") |
| 85 | // A URL carrying this build's hash names bytes that cannot change, so |
| 86 | // it never needs revalidating. The bare URL still can, and keeps the |
| 87 | // policy it had. |
| 88 | if r.URL.Query().Get("v") == stylesheetHash { |
| 89 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 90 | } else { |
| 91 | w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate") |
| 92 | } |
| 80 | 93 | if r.Header.Get("If-None-Match") == stylesheetETag { |
| 81 | 94 | w.WriteHeader(http.StatusNotModified) |
| 82 | 95 | return |
internal/web/templates/layout.html
+1 −1
| @@ -4,7 +4,7 @@ |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | 6 | <title>{{template "title" .}}</title> |
| 7 | | <link rel="stylesheet" href="/static/style.css"> |
| 7 | <link rel="stylesheet" href="/static/style.css?v={{styleVersion}}"> |
| 8 | 8 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
| 9 | 9 | {{with field . "Feed"}}<link rel="alternate" type="application/atom+xml" href="{{.}}"> |
| 10 | 10 | {{end}}</head> |
internal/web/web.go
+6
| @@ -62,9 +62,15 @@ var fullVersion = sync.OnceValue(func() string { |
| 62 | 62 | return "" |
| 63 | 63 | }) |
| 64 | 64 | |
| 65 | // StyleVersion identifies the bytes /static/style.css serves. The httpd |
| 66 | // package sets it; the layout stamps it on the stylesheet URL so a deploy |
| 67 | // changes that URL and a browser holding a cached copy cannot miss it. |
| 68 | var StyleVersion string |
| 69 | |
| 65 | 70 | var funcs = template.FuncMap{ |
| 66 | 71 | "gitbayVersion": func() string { return version() }, |
| 67 | 72 | "gitbayCommit": func() string { return fullVersion() }, |
| 73 | "styleVersion": func() string { return StyleVersion }, |
| 68 | 74 | "join": strings.Join, |
| 69 | 75 | // paragraphs splits plain text on blank lines for safe rich display. |
| 70 | 76 | "paragraphs": func(s string) []string { |