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
+53
-38
No files matched your search
+38
-23
@@ -59,6 +59,7 @@ type Server struct {
|
|||||||
relays speedRelays // speed tests that run through the browser
|
relays speedRelays // speed tests that run through the browser
|
||||||
meters speedMeters // byte counts of the speed tests under way
|
meters speedMeters // byte counts of the speed tests under way
|
||||||
xfers xfers // files on their way between a browser and an agent
|
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.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -66,7 +67,14 @@ func New(opts Options) (*Server, error) {
|
|||||||
if err := opts.DB.ensureDefaults(); err != nil {
|
if err := opts.DB.ensureDefaults(); err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -92,6 +100,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
distDir: opts.DistDir,
|
distDir: opts.DistDir,
|
||||||
secureCookies: opts.SecureCookies,
|
secureCookies: opts.SecureCookies,
|
||||||
installTmpl: installTmpl,
|
installTmpl: installTmpl,
|
||||||
|
assets: assets,
|
||||||
}
|
}
|
||||||
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
||||||
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
|
// 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.
|
// Static assets (xterm.js etc.) from the embedded FS.
|
||||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
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.
|
// Public: install script + agent binary downloads.
|
||||||
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
||||||
@@ -336,32 +345,38 @@ func clientIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// staticHandler serves the embedded assets with a content hash as ETag. The
|
// assetHashes hashes every static file, for asset URLs and ETags.
|
||||||
// browser asks each time whether a file changed (no-cache) and gets a 304
|
func assetHashes(fsys fs.FS) map[string]string {
|
||||||
// when it did not, so a new release reaches it at once without reloading
|
out := map[string]string{}
|
||||||
// what stayed the same.
|
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||||
func staticHandler(fsys fs.FS) http.Handler {
|
if err != nil || d.IsDir() {
|
||||||
files := http.FileServer(http.FS(fsys))
|
return nil
|
||||||
var mu sync.Mutex
|
}
|
||||||
etags := map[string]string{}
|
if data, err := fs.ReadFile(fsys, p); err == nil {
|
||||||
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)
|
sum := sha256.Sum256(data)
|
||||||
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
|
out[p] = hex.EncodeToString(sum[:8])
|
||||||
}
|
}
|
||||||
mu.Lock()
|
return nil
|
||||||
etags[name] = tag
|
})
|
||||||
mu.Unlock()
|
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")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
files.ServeHTTP(w, r)
|
files.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Машины - LPRA</title>
|
<title>Машины - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Изменить машину - LPRA</title>
|
<title>Изменить машину - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Файлы · {{.Agent.Name}} - LPRA</title>
|
<title>Файлы · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<link rel="stylesheet" href="/static/codemirror/codemirror.css">
|
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
|
||||||
<style>
|
<style>
|
||||||
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
.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. */
|
/* Normalize buttons and the file <label> button so they line up on one row. */
|
||||||
@@ -333,8 +333,8 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script src="/static/codemirror/codemirror.js"></script>
|
<script src="{{asset "codemirror/codemirror.js"}}"></script>
|
||||||
<script src="/static/docview.js"></script>
|
<script src="{{asset "docview.js"}}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const agentID = {{.Agent.ID}};
|
const agentID = {{.Agent.ID}};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<style>
|
<style>
|
||||||
/* One line: fixed narrow fields, the label takes what is left. */
|
/* One line: fixed narrow fields, the label takes what is left. */
|
||||||
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Вход - LPRA</title>
|
<title>Вход - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Настройки - LPRA</title>
|
<title>Настройки - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Настройка - LPRA</title>
|
<title>Настройка - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Статус · {{.Agent.Name}} - LPRA</title>
|
<title>Статус · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<style>
|
<style>
|
||||||
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
||||||
.meters .card { margin:0; }
|
.meters .card { margin:0; }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<link rel="stylesheet" href="/static/xterm.css">
|
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||||
<style>
|
<style>
|
||||||
#status.st-ok { color:#37c871; }
|
#status.st-ok { color:#37c871; }
|
||||||
#status.st-err { color:#ff5c5c; }
|
#status.st-err { color:#ff5c5c; }
|
||||||
@@ -82,8 +82,8 @@
|
|||||||
<button type="button" data-k="pgdn">PGDN</button>
|
<button type="button" data-k="pgdn">PGDN</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/xterm.js"></script>
|
<script src="{{asset "xterm.js"}}"></script>
|
||||||
<script src="/static/addon-fit.js"></script>
|
<script src="{{asset "addon-fit.js"}}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const agentID = {{.Agent.ID}};
|
const agentID = {{.Agent.ID}};
|
||||||
|
|||||||
Reference in new issue
Block a user