httpd: stamp the stylesheet hash on its URL !445

merged merged by cmc on 2026-09-20 07:07 UTC · krz/gitbay:style-version into main

4 files changed, +68 −7

Layout: unified · split

internal/httpd/anchors_test.go +42
@@ -7,6 +7,8 @@ import (
7 "testing" 7 "testing"
8 8
9 "gitbay.org/gitbay/internal/config" 9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11 "gitbay.org/gitbay/internal/web"
10) 12)
11 13
12// Markdown headings carry ids, so a README section can be deep-linked 14// Markdown headings carry ids, so a README section can be deep-linked
@@ -38,3 +40,43 @@ func TestStylesheetRevalidates(t *testing.T) {
38 t.Fatalf("revalidation: %d with %d bytes", second.Code, second.Body.Len()) 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).
49func 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 return strings.TrimSuffix(h, "/") 64 return strings.TrimSuffix(h, "/")
65} 65}
66 66
67// stylesheetETag is the hash of what stylesheet serves, computed once: 67// stylesheetHash is the hash of what stylesheet serves, computed once. It
68// a browser revalidates with If-None-Match and gets a 304 until a deploy 68// is the ETag, so a browser revalidating with If-None-Match gets a 304
69// changes the bytes (#132). 69// until a deploy changes the bytes (#132), and it is the ?v= the layout
70var stylesheetETag = func() string { 70// stamps on the URL, so a deploy the browser has not fetched yet cannot be
71// answered from its cache (#239).
72var stylesheetHash = func() string {
71 h := sha256.New() 73 h := sha256.New()
72 h.Write(styleCSS) 74 h.Write(styleCSS)
73 h.Write(chromaCSS) 75 h.Write(chromaCSS)
74 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` 76 return hex.EncodeToString(h.Sum(nil))[:16]
75}() 77}()
76 78
79var stylesheetETag = `"` + stylesheetHash + `"`
80
81func init() { web.StyleVersion = stylesheetHash }
82
77func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { 83func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
78 w.Header().Set("ETag", stylesheetETag) 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 if r.Header.Get("If-None-Match") == stylesheetETag { 93 if r.Header.Get("If-None-Match") == stylesheetETag {
81 w.WriteHeader(http.StatusNotModified) 94 w.WriteHeader(http.StatusNotModified)
82 return 95 return
internal/web/templates/layout.html +1 −1
@@ -4,7 +4,7 @@
4<meta charset="utf-8"> 4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1"> 5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>{{template "title" .}}</title> 6<title>{{template "title" .}}</title>
7<link rel="stylesheet" href="/static/style.css"> 7<link rel="stylesheet" href="/static/style.css?v={{styleVersion}}">
8<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8<link rel="icon" href="/favicon.svg" type="image/svg+xml">
9{{with field . "Feed"}}<link rel="alternate" type="application/atom+xml" href="{{.}}"> 9{{with field . "Feed"}}<link rel="alternate" type="application/atom+xml" href="{{.}}">
10{{end}}</head> 10{{end}}</head>
internal/web/web.go +6
@@ -62,9 +62,15 @@ var fullVersion = sync.OnceValue(func() string {
62 return "" 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.
68var StyleVersion string
69
65var funcs = template.FuncMap{ 70var funcs = template.FuncMap{
66 "gitbayVersion": func() string { return version() }, 71 "gitbayVersion": func() string { return version() },
67 "gitbayCommit": func() string { return fullVersion() }, 72 "gitbayCommit": func() string { return fullVersion() },
73 "styleVersion": func() string { return StyleVersion },
68 "join": strings.Join, 74 "join": strings.Join,
69 // paragraphs splits plain text on blank lines for safe rich display. 75 // paragraphs splits plain text on blank lines for safe rich display.
70 "paragraphs": func(s string) []string { 76 "paragraphs": func(s string) []string {