fix: static files linked with their content hash, so a release is never shadowed by a cached copy (Cloudflare kept the old CSS)

This commit is contained in:
redtea committed 2026-10-14 19:40:31 +03:00
1 parent 0aa066fd01
commit 114c6fc530
10 files changed
+58 -43

No files matched your search

+43 -28
View File
@@ -55,10 +55,11 @@ type Server struct {
tunnelMu sync.Mutex // serializes port changes
tunnelPort atomic.Int64
stats statCache // last load sample per agent, for the status page
relays speedRelays // speed tests that run through the browser
meters speedMeters // byte counts of the speed tests under way
xfers xfers // files on their way between a browser and an agent
stats statCache // last load sample per agent, for the status page
relays speedRelays // speed tests that run through the browser
meters speedMeters // byte counts of the speed tests under way
xfers xfers // files on their way between a browser and an agent
assets map[string]string // static file -> hash of its content
}
// New builds the server, parsing embedded templates.
@@ -66,7 +67,14 @@ func New(opts Options) (*Server, error) {
if err := opts.DB.ensureDefaults(); err != nil {
return nil, err
}
tmpl, err := template.ParseFS(webFS, "web/templates/*.html")
staticFS, _ := fs.Sub(webFS, "web/static")
assets := assetHashes(staticFS)
funcs := template.FuncMap{
// asset is a static file's URL with its content hash, so a new
// release is a new URL that no browser or proxy has cached.
"asset": func(name string) string { return "/static/" + name + "?v=" + assets[name] },
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(webFS, "web/templates/*.html")
if err != nil {
return nil, err
}
@@ -92,6 +100,7 @@ func New(opts Options) (*Server, error) {
distDir: opts.DistDir,
secureCookies: opts.SecureCookies,
installTmpl: installTmpl,
assets: assets,
}
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
@@ -186,7 +195,7 @@ func (s *Server) Handler() http.Handler {
// Static assets (xterm.js etc.) from the embedded FS.
staticFS, _ := fs.Sub(webFS, "web/static")
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS)))
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS, s.assets)))
// Public: install script + agent binary downloads.
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
@@ -336,32 +345,38 @@ func clientIP(r *http.Request) string {
return host
}
// staticHandler serves the embedded assets with a content hash as ETag. The
// browser asks each time whether a file changed (no-cache) and gets a 304
// when it did not, so a new release reaches it at once without reloading
// what stayed the same.
func staticHandler(fsys fs.FS) http.Handler {
// assetHashes hashes every static file, for asset URLs and ETags.
func assetHashes(fsys fs.FS) map[string]string {
out := map[string]string{}
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if data, err := fs.ReadFile(fsys, p); err == nil {
sum := sha256.Sum256(data)
out[p] = hex.EncodeToString(sum[:8])
}
return nil
})
return out
}
// staticHandler serves the embedded assets. Pages ask for them by URLs that
// carry the content hash (?v=): such a file never changes and is kept for a
// year, by the browser and by a proxy in front such as Cloudflare. Asked for
// without it, a file is checked every time against its ETag.
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
files := http.FileServer(http.FS(fsys))
var mu sync.Mutex
etags := map[string]string{}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/")
mu.Lock()
tag, ok := etags[name]
mu.Unlock()
if !ok {
if data, err := fs.ReadFile(fsys, name); err == nil {
sum := sha256.Sum256(data)
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
}
mu.Lock()
etags[name] = tag
mu.Unlock()
h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
if h != "" {
w.Header().Set("ETag", `"`+h+`"`)
}
if tag != "" {
w.Header().Set("ETag", tag)
if v := r.URL.Query().Get("v"); h != "" && v == h {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r)
})
}