web: give the embedded static assets a content ETag
test / test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 23:35:29 +03:00
parent 01c754f857
commit 10c41677bd
3 changed files with 136 additions and 2 deletions
+59
View File
@@ -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)
})
}