diff --git a/README.fr.md b/README.fr.md index 0fe2657..1a219d5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -721,6 +721,7 @@ Pour mettre à jour l'une d'elles : | `tags.go` | stockage des tags de couleur | | `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée | | `api.go` | routes HTTP `/api/*` | +| `static.go` | fichiers de l'interface embarqués : ETag par fichier, revérifiés à chaque chargement (`no-cache`, 304 s'ils n'ont pas changé) pour toujours prendre une mise à jour ; polices en cache 30 jours | | `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion | ## Remarque diff --git a/README.md b/README.md index 81b7c7c..bc85bfd 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,7 @@ To update one of them: | `tags.go` | color tag storage | | `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list | | `api.go` | `/api/*` HTTP routes | +| `static.go` | embedded UI files: ETag per file, revalidated on each load (`no-cache`, 304 when unchanged) so an update is always picked up; fonts cached 30 days | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page | ## Note diff --git a/main.go b/main.go index 22c8bc6..3e404f7 100644 --- a/main.go +++ b/main.go @@ -230,7 +230,7 @@ func main() { go api.files.Run(ctx) api.selfLog = selfLog api.Routes(mux) - mux.Handle("GET /", http.FileServer(http.FS(static))) + mux.Handle("GET /", staticFiles(static)) app := readOnly(mux) handler, err := newAuth(cfg.auth, app) diff --git a/static.go b/static.go new file mode 100644 index 0000000..28290ac --- /dev/null +++ b/static.go @@ -0,0 +1,44 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "io/fs" + "net/http" + "strings" +) + +// staticFiles serves the embedded UI with an ETag per file (a hash of its +// content, computed at startup). Pages, scripts and styles are revalidated on +// every load ("no-cache"): an unchanged file costs a 304 without body, and the +// files of a new version are always taken after an update. Fonts are kept for +// 30 days. +func staticFiles(static fs.FS) http.Handler { + etags := map[string]string{} + _ = fs.WalkDir(static, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := fs.ReadFile(static, path) + if err != nil { + return err + } + sum := sha256.Sum256(b) + etags["/"+path] = `"` + hex.EncodeToString(sum[:8]) + `"` + return nil + }) + etags["/"] = etags["/index.html"] + files := http.FileServer(http.FS(static)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tag, ok := etags[r.URL.Path]; ok { + // http.FileServer answers If-None-Match with 304 from this header. + w.Header().Set("ETag", tag) + if strings.HasPrefix(r.URL.Path, "/fonts/") { + w.Header().Set("Cache-Control", "public, max-age=2592000") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + } + files.ServeHTTP(w, r) + }) +} diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..841b563 --- /dev/null +++ b/static_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func TestStaticFilesCache(t *testing.T) { + h := staticFiles(fstest.MapFS{ + "index.html": {Data: []byte("

v1

")}, + "app.js": {Data: []byte("v1")}, + "fonts/mono.woff2": {Data: []byte("font")}, + }) + get := func(path, etag string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", path, nil) + if etag != "" { + req.Header.Set("If-None-Match", etag) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + rec := get("/app.js", "") + tag := rec.Header().Get("ETag") + if rec.Code != http.StatusOK || tag == "" || rec.Header().Get("Cache-Control") != "no-cache" { + t.Fatalf("app.js: %d, ETag %q, Cache-Control %q", rec.Code, tag, rec.Header().Get("Cache-Control")) + } + if rec := get("/app.js", tag); rec.Code != http.StatusNotModified || rec.Body.Len() != 0 { + t.Fatalf("revalidation: %d with %d bytes, want 304 without body", rec.Code, rec.Body.Len()) + } + if rec := get("/app.js", `"old"`); rec.Code != http.StatusOK || rec.Body.String() != "v1" { + t.Fatalf("stale ETag: %d %q", rec.Code, rec.Body) + } + if rec := get("/", ""); rec.Code != http.StatusOK || rec.Header().Get("ETag") == "" || rec.Header().Get("Cache-Control") != "no-cache" { + t.Fatalf("index: %d %v", rec.Code, rec.Header()) + } + if rec := get("/fonts/mono.woff2", ""); rec.Header().Get("Cache-Control") != "public, max-age=2592000" { + t.Fatalf("font Cache-Control %q", rec.Header().Get("Cache-Control")) + } +}