Fingerprint app.js, setup.js and app.css

This commit is contained in:
Daniel Redetzke
2026-10-04 20:56:35 +03:00
parent 990aa55a3d
commit 8f13a37d92
2 changed files with 58 additions and 5 deletions
+10 -3
View File
@@ -921,8 +921,15 @@ func TestDecoy(t *testing.T) {
} }
} }
if b, _ := get("/", 200); !strings.Contains(b, "/app.js") { b, _ := get("/", 200)
t.Fatal("web interface not served with the decoy off") if !strings.Contains(b, `"/app.js?v=`+assetHash["app.js"]+`"`) || !strings.Contains(b, `"/app.css?v=`+assetHash["app.css"]+`"`) {
t.Fatalf("web interface not served with fingerprinted files: %q", b)
}
if _, h := get("/app.js?v="+assetHash["app.js"], 200); !strings.Contains(h.Get("Cache-Control"), "immutable") {
t.Fatalf("fingerprinted app.js: %v", h)
}
if _, h := get("/app.js?v=old", 200); h.Get("Cache-Control") != "no-cache" {
t.Fatalf("stale app.js cached: %v", h)
} }
set(func(c *Config) { set(func(c *Config) {
v4 := netip.MustParsePrefix(c.Server.IPv4) v4 := netip.MustParsePrefix(c.Server.IPv4)
@@ -939,7 +946,7 @@ func TestDecoy(t *testing.T) {
t.Fatalf("%s leaks: %q", p, b) t.Fatalf("%s leaks: %q", p, b)
} }
} }
if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js"`) { if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js?v=`+assetHash["setup.js"]+`"`) {
t.Fatalf("setup page files not under the link: %q", b) t.Fatalf("setup page files not under the link: %q", b)
} }
get("/setup/live-token/app.css", 200) get("/setup/live-token/app.css", 200)
+48 -2
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -14,6 +16,39 @@ import (
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2 //go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
var webFiles embed.FS var webFiles embed.FS
// The pages load app.js, setup.js and app.css with ?v=<hash of the file>, so
// a new binary makes browsers fetch the new files, and a fingerprinted file
// can be cached for good.
var (
assetHash = map[string]string{}
indexPage []byte
)
func init() {
for _, name := range []string{"app.js", "setup.js", "app.css"} {
b, err := webFiles.ReadFile(name)
if err != nil {
panic(err)
}
sum := sha256.Sum256(b)
assetHash[name] = hex.EncodeToString(sum[:5])
}
b, err := webFiles.ReadFile("index.html")
if err != nil {
panic(err)
}
indexPage = fingerprint(b, "/")
}
// fingerprint adds ?v=<hash> to the page's references to base + file.
func fingerprint(page []byte, base string) []byte {
s := string(page)
for name, h := range assetHash {
s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`)
}
return []byte(s)
}
func (a *App) webHandler() http.Handler { func (a *App) webHandler() http.Handler {
files := http.FileServerFS(webFiles) files := http.FileServerFS(webFiles)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -22,7 +57,18 @@ func (a *App) webHandler() http.Handler {
return return
} }
switch r.URL.Path { switch r.URL.Path {
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": case "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(indexPage)
case "/app.js", "/setup.js", "/app.css":
if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
case "/favicon.svg", "/apple-touch-icon.png":
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
case "/ShipporiMinchoB1-ExtraBold.woff2": case "/ShipporiMinchoB1-ExtraBold.woff2":
@@ -66,7 +112,7 @@ func (a *App) setupPage(w http.ResponseWriter, r *http.Request) {
page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b)) page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(page)) _, _ = w.Write(fingerprint([]byte(page), base))
} }
func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) { func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {