UI files: ETag and revalidation on each load
The embedded UI was served without any cache header, so a browser or a proxy could keep an old app.js after an update (seen with the API token display). Each file now has an ETag (hash of its content, computed at startup); the page, app.js and style.css are sent with "no-cache", so each load costs a 304 without body until the next version. Fonts are cached for 30 days. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
367420961e
commit
3e703c8792
5 files changed
+89
-1
No files matched your search
@@ -721,6 +721,7 @@ Pour mettre à jour l'une d'elles :
|
|||||||
| `tags.go` | stockage des tags de couleur |
|
| `tags.go` | stockage des tags de couleur |
|
||||||
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
|
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
|
||||||
| `api.go` | routes HTTP `/api/*` |
|
| `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 |
|
| `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
|
## Remarque
|
||||||
|
|||||||
@@ -663,6 +663,7 @@ To update one of them:
|
|||||||
| `tags.go` | color tag storage |
|
| `tags.go` | color tag storage |
|
||||||
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
||||||
| `api.go` | `/api/*` HTTP routes |
|
| `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 |
|
| `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
|
## Note
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ func main() {
|
|||||||
go api.files.Run(ctx)
|
go api.files.Run(ctx)
|
||||||
api.selfLog = selfLog
|
api.selfLog = selfLog
|
||||||
api.Routes(mux)
|
api.Routes(mux)
|
||||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
mux.Handle("GET /", staticFiles(static))
|
||||||
|
|
||||||
app := readOnly(mux)
|
app := readOnly(mux)
|
||||||
handler, err := newAuth(cfg.auth, app)
|
handler, err := newAuth(cfg.auth, app)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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("<p>v1</p>")},
|
||||||
|
"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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user