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
+53 -38

No files matched your search

+38 -23
View File
@@ -59,6 +59,7 @@ type Server struct {
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 {
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 {
// 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)
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
out[p] = hex.EncodeToString(sum[:8])
}
mu.Lock()
etags[name] = tag
mu.Unlock()
return nil
})
return out
}
if tag != "" {
w.Header().Set("ETag", tag)
// 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))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
if h != "" {
w.Header().Set("ETag", `"`+h+`"`)
}
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")
}
files.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
</head>
<body>
<header class="topbar">
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
<link rel="stylesheet" href="{{asset "app.css"}}">
</head>
<body>
<header class="topbar">
+4 -4
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/codemirror/codemirror.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
<style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
/* Normalize buttons and the file <label> button so they line up on one row. */
@@ -333,8 +333,8 @@
<div class="toast" id="toast"></div>
<script src="/static/codemirror/codemirror.js"></script>
<script src="/static/docview.js"></script>
<script src="{{asset "codemirror/codemirror.js"}}"></script>
<script src="{{asset "docview.js"}}"></script>
<script>
(function () {
const agentID = {{.Agent.ID}};
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
<style>
/* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
</head>
<body>
<div class="center">
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
</head>
<body>
<header class="topbar">
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройка - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
</head>
<body>
<div class="center">
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
<style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; }
+4 -4
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/xterm.css">
<link rel="stylesheet" href="{{asset "app.css"}}">
<link rel="stylesheet" href="{{asset "xterm.css"}}">
<style>
#status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; }
@@ -82,8 +82,8 @@
<button type="button" data-k="pgdn">PGDN</button>
</div>
<script src="/static/xterm.js"></script>
<script src="/static/addon-fit.js"></script>
<script src="{{asset "xterm.js"}}"></script>
<script src="{{asset "addon-fit.js"}}"></script>
<script>
(function () {
const agentID = {{.Agent.ID}};