httpd: stamp the stylesheet hash on its URL !445
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). | ||
| 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 | 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 |
| 70 | var 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). | ||
| 72 | var 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 | ||
| 79 | var stylesheetETag = `"` + stylesheetHash + `"` | ||
| 80 | |||
| 81 | func init() { web.StyleVersion = stylesheetHash } | ||
| 82 | |||
| 77 | func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { | 83 | func (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. | ||
| 68 | var StyleVersion string | ||
| 69 | |||
| 65 | var funcs = template.FuncMap{ | 70 | var 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 { |