Compare commits

...
Author SHA1 Message Date
cedricandClaude Opus 5.5 3e703c8792 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>
2026-11-01 16:11:41 +01:00
claude Bot 367420961e Merge feat/api-tokens-save: token changes wait for Save, curl example 2026-11-01 16:03:24 +01:00
5 changed files with 89 additions and 1 deletions

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -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)
+44
View File
@@ -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)
})
}
+42
View File
@@ -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"))
}
}