From 10c41677bd4123039847124754152982b3b48898 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sun, 9 Aug 2026 23:35:29 +0300 Subject: [PATCH] web: give the embedded static assets a content ETag The assets are baked into the binary, so their FS modification times are the zero value and http.FileServer sent no Last-Modified. With no ETag either, a static response carried no validator at all and the browser was free to guess how long to keep it -- which is how the panel kept showing the previous favicon in the tab well after the new mark was deployed. Hash each asset once at startup and serve that as a strong ETag, with Cache-Control: no-cache so the browser keeps its copy but revalidates. An unchanged asset then costs a bodyless 304 and a changed one is picked up on the next load. Co-Authored-By: Claude Sonnet 5 --- internal/web/static.go | 59 +++++++++++++++++++++++++++++ internal/web/static_test.go | 74 +++++++++++++++++++++++++++++++++++++ internal/web/web.go | 5 ++- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 internal/web/static.go create mode 100644 internal/web/static_test.go diff --git a/internal/web/static.go b/internal/web/static.go new file mode 100644 index 0000000..d4b70d5 --- /dev/null +++ b/internal/web/static.go @@ -0,0 +1,59 @@ +package web + +import ( + "crypto/sha256" + "encoding/hex" + "io/fs" + "net/http" + "path" +) + +// staticETags maps each /static/ URL path to a strong ETag over the asset's +// bytes, computed once at startup from the embedded FS. +// +// The assets are baked into the binary, so their FS modification times are the +// zero value and http.FileServer sends no Last-Modified. Without an ETag either, +// a response carries no validator at all and the browser is free to guess how +// long to keep it — which is how a replaced favicon keeps showing the old mark +// long after a deploy. Hashing the content gives every asset a validator that +// changes exactly when the asset does. +var staticETags = buildStaticETags() + +func buildStaticETags() map[string]string { + etags := make(map[string]string) + // An error here would mean the embed directive and this walk disagree, which + // is a build-time defect rather than a runtime condition; the assets still + // serve correctly without a validator, so skip what can't be read. + _ = fs.WalkDir(assetsFS, "static", func(name string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + b, err := fs.ReadFile(assetsFS, name) + if err != nil { + return nil + } + sum := sha256.Sum256(b) + etags["/"+name] = `"` + hex.EncodeToString(sum[:16]) + `"` + return nil + }) + return etags +} + +// staticHandler serves the embedded assets under /static/ with a content ETag. +// +// Cache-Control is no-cache rather than a max-age: it lets the browser keep the +// copy but requires it to revalidate, so an asset that changed is picked up on +// the next page load while an unchanged one costs a 304 with no body. For a +// handful of small files on a single-operator panel that trade is the right way +// round — correctness after a deploy matters more than saving the round trip. +// http.ServeContent answers the conditional request from the ETag we set here. +func staticHandler() http.Handler { + files := http.FileServer(http.FS(assetsFS)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok { + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "no-cache") + } + files.ServeHTTP(w, r) + }) +} diff --git a/internal/web/static_test.go b/internal/web/static_test.go new file mode 100644 index 0000000..17b71f2 --- /dev/null +++ b/internal/web/static_test.go @@ -0,0 +1,74 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// serveStatic runs one GET against the static handler. +func serveStatic(path string, headers map[string]string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, path, nil) + for k, v := range headers { + r.Header.Set(k, v) + } + rec := httptest.NewRecorder() + staticHandler().ServeHTTP(rec, r) + return rec +} + +// Every embedded asset must carry a validator. The favicon is the one that +// prompted this: a browser given no ETag and no Last-Modified caches it on a +// guess, and a rebranded panel keeps serving the old mark from the tab. +func TestStaticAssetsCarryETag(t *testing.T) { + for _, name := range []string{"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js"} { + rec := serveStatic("/static/"+name, nil) + if rec.Code != http.StatusOK { + t.Fatalf("%s: got status %d, want 200", name, rec.Code) + } + if rec.Header().Get("ETag") == "" { + t.Errorf("%s: no ETag", name) + } + if got := rec.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("%s: Cache-Control = %q, want %q", name, got, "no-cache") + } + } +} + +// The point of the ETag is the cheap second request: the browser sends back +// what it has and gets a bodyless 304 when nothing changed. +func TestStaticETagRevalidates(t *testing.T) { + first := serveStatic("/static/favicon.png", nil) + etag := first.Header().Get("ETag") + if etag == "" { + t.Fatal("no ETag on the first response") + } + + same := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": etag}) + if same.Code != http.StatusNotModified { + t.Errorf("matching If-None-Match: got status %d, want 304", same.Code) + } + if same.Body.Len() != 0 { + t.Errorf("matching If-None-Match: got %d bytes of body, want none", same.Body.Len()) + } + + // A stale validator — what a browser holds after the asset is replaced — + // has to produce the new bytes rather than another 304. + stale := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": `"0000000000000000"`}) + if stale.Code != http.StatusOK { + t.Errorf("stale If-None-Match: got status %d, want 200", stale.Code) + } + if stale.Body.Len() == 0 { + t.Error("stale If-None-Match: empty body, want the asset") + } +} + +// Two different assets must not share a validator, or replacing one would be +// masked by the other's cached copy. +func TestStaticETagsAreContentDerived(t *testing.T) { + png := serveStatic("/static/favicon.png", nil).Header().Get("ETag") + svg := serveStatic("/static/favicon.svg", nil).Header().Get("ETag") + if png == svg { + t.Errorf("favicon.png and favicon.svg share the ETag %s", png) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 2b5f83a..6d0e9c5 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -147,8 +147,9 @@ func (s *Server) Handler() http.Handler { // Health check stays unauthenticated for the container/orchestrator. mux.HandleFunc("/healthz", handleHealth) - // Vendored static assets (HTMX). Served from the embedded FS. - mux.Handle("/static/", http.FileServer(http.FS(assetsFS))) + // Vendored static assets (HTMX). Served from the embedded FS, with a + // content ETag so a replaced asset survives the browser cache (static.go). + mux.Handle("/static/", staticHandler()) // One-time administrator setup (security.md). mux.HandleFunc("/setup/", s.handleSetup)