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:
1 parent
0aa066fd01
commit
114c6fc530
10 files changed
+58
-43
No files matched your search
+43
-28
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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}};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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}};
|
||||
|
||||
Reference in new issue
Block a user