diff --git a/internal/server/server.go b/internal/server/server.go index d40d981..b59592d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) }) } diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index 3bd9b9c..fa496ef 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -4,7 +4,7 @@ Машины - LPRA - +
diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html index 4160acb..b4ac97b 100644 --- a/internal/server/web/templates/edit.html +++ b/internal/server/web/templates/edit.html @@ -4,7 +4,7 @@ Изменить машину - LPRA - +
diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html index cdc4a36..578efd3 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -4,8 +4,8 @@ Файлы · {{.Agent.Name}} - LPRA - - + +