oficina / home Public

Cache Home's stylesheets and pictures by content version

Paul, 2026-09-29: images took a second and flashed in on every visit.
Home served its stylesheets and icons no-cache with no validator, so each
visit downloaded all of them again. Now every stylesheet and picture is
linked as path?v=<content version> and that URL is cached for a year,
immutably; the bare path revalidates by ETag (a 304). A deploy still
takes effect at once: new bytes, new version, new URL. Scripts keep their
bare paths (the scriptless allowlist names them) and revalidate by ETag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 3af575d0b6fa00586ea183647227f6ee721da0ec parent 5e91b7b
14 files changed, +189 −23
  • internal/home/app.go +2 −2
  • internal/home/assetversion.go +79 −0
  • internal/home/assetversion_test.go +56 −0
  • internal/home/brand.go +1 −0
  • internal/home/brand_test.go +1 −1
  • internal/home/calma.go +4 −2
  • internal/home/credits.go +1 −1
  • internal/home/directory.go +2 −2
  • internal/home/directory_catalog.go +3 −0
  • internal/home/page.go +13 −3
  • internal/home/passkey.go +16 −1
  • internal/home/templates/credits.html +1 −1
  • internal/home/templates/directory.html +1 −1
  • internal/home/templates/layout.html +9 −9

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
cd handoff && CGO_ENABLED=0 go vet ./...
cd roster && CGO_ENABLED=0 go vet ./...
cd connect && CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch
cd handoff && CGO_ENABLED=0 go build ./...
cd roster && CGO_ENABLED=0 go build ./...
cd connect && CGO_ENABLED=0 go build ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/home/cmd/home	[no test files]
ok  	amadan.net/oficina/home/cmd/home-placeholder	0.517s
?   	amadan.net/oficina/home/cmd/oficina	[no test files]
ok  	amadan.net/oficina/home/internal/guardstest	0.033s
ok  	amadan.net/oficina/home/internal/home	8.323s
ok  	amadan.net/oficina/home/internal/home/directorydata	0.917s
ok  	amadan.net/oficina/home/internal/home/logtest	0.003s
ok  	amadan.net/oficina/home/internal/home/seal	0.002s
ok  	amadan.net/oficina/home/internal/home/sealtest	0.002s
ok  	amadan.net/oficina/home/internal/operator	5.054s
ok  	amadan.net/oficina/home/internal/operatorlaunch	4.113s
ok  	amadan.net/oficina/home/internal/provisioner	38.552s
cd handoff && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/handoff	0.320s
ok  	amadan.net/oficina/home/handoff/seal	0.002s
cd roster && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/roster	5.078s
cd connect && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/connect	0.065s
ok  	amadan.net/oficina/home/connect/linkstore	0.183s
=== race ===
go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/
ok  	amadan.net/oficina/home/internal/home	101.401s
ok  	amadan.net/oficina/home/internal/operator	64.968s
ok  	amadan.net/oficina/home/internal/operatorlaunch	22.364s
ok  	amadan.net/oficina/home/internal/provisioner	288.222s
ok  	amadan.net/oficina/home/internal/home/directorydata	14.971s
cd handoff && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/handoff	8.933s
ok  	amadan.net/oficina/home/handoff/seal	1.008s
cd roster && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/roster	6.553s
cd connect && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/connect	1.595s
ok  	amadan.net/oficina/home/connect/linkstore	3.679s
diff --git a/internal/home/app.go b/internal/home/app.go
index ecb12f8..bd36888 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -242,8 +242,8 @@ func (a *App) Router() http.Handler {
// Vendored from rastrillo's ui package, not linked from it: an app
// owns its stylesheet from the moment it lands, and
// `rastrillo doctor --fix` is what reconciles it on an upgrade.
- r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
- r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
+ mountCSS(r, "/static/tokens.css", staticAsset("tokens.css"))
+ mountCSS(r, "/static/theme.css", theme.CSS())
mountBrand(r)
mountCalma(r)
mountSite(r)
diff --git a/internal/home/assetversion.go b/internal/home/assetversion.go
new file mode 100644
index 0000000..06378c4
--- /dev/null
+++ b/internal/home/assetversion.go
@@ -0,0 +1,79 @@
+package home
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync"
+)
+
+// Asset versioning. Home's stylesheets and pictures are served at stable
+// paths (a closed list, mountBrand and friends), and every page links
+// them as path?v=<content version>. serveAsset caches a request carrying
+// the current version for a year, immutably, and revalidates anything
+// else, so a returning visitor fetches only the page, and a deploy still
+// takes effect at once: new bytes, new version, new URL.
+//
+// Scripts are left at their bare paths: the scriptless sweep's allowlist
+// names them there (passkey.go), and they are revalidated by ETag.
+
+// contentVersion is the first 16 hex digits of the body's SHA-256.
+func contentVersion(body []byte) string {
+ sum := sha256.Sum256(body)
+ return hex.EncodeToString(sum[:8])
+}
+
+var (
+ assetVersionsMu sync.RWMutex
+ assetVersions = map[string]string{}
+)
+
+// versionAsset records path's current version. Called where each asset is
+// mounted; every router mounts the same bytes, so the value never changes.
+func versionAsset(path string, body []byte) {
+ assetVersionsMu.Lock()
+ assetVersions[path] = contentVersion(body)
+ assetVersionsMu.Unlock()
+}
+
+// versioned is path with its version, or path unchanged if it is not a
+// versioned asset (a script, an estilo URL already versioned in its path).
+func versioned(path string) string {
+ assetVersionsMu.RLock()
+ v, ok := assetVersions[path]
+ assetVersionsMu.RUnlock()
+ if !ok {
+ return path
+ }
+ return path + "?v=" + v
+}
+
+var staticRef = regexp.MustCompile(`((?:href|src)=")(/static/[^"?#]+)"`)
+
+// versionStaticRefs rewrites every href/src naming a versioned asset in a
+// static page (the front page, which is bytes, not a template).
+func versionStaticRefs(page []byte) []byte {
+ return staticRef.ReplaceAllFunc(page, func(m []byte) []byte {
+ parts := staticRef.FindSubmatch(m)
+ return []byte(string(parts[1]) + versioned(string(parts[2])) + `"`)
+ })
+}
+
+// isVersionedAsset reports whether a path is one Home versions: used by
+// tests that check every page links assets by version.
+func isVersionedAsset(path string) bool {
+ assetVersionsMu.RLock()
+ defer assetVersionsMu.RUnlock()
+ _, ok := assetVersions[strings.SplitN(path, "?", 2)[0]]
+ return ok
+}
+
+// mountCSS mounts a versioned stylesheet.
+func mountCSS(r interface {
+ Get(string, http.HandlerFunc)
+}, path string, body []byte) {
+ versionAsset(path, body)
+ r.Get(path, serveAsset(body, "text/css; charset=utf-8"))
+}
diff --git a/internal/home/assetversion_test.go b/internal/home/assetversion_test.go
new file mode 100644
index 0000000..c768988
--- /dev/null
+++ b/internal/home/assetversion_test.go
@@ -0,0 +1,56 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// Every stylesheet and picture a page links is linked by its content
+// version, and fetching that URL is cached immutably; the bare path and a
+// stale version revalidate, and a matching ETag is a 304.
+func TestAssetsAreLinkedByVersionAndCachedForAYear(t *testing.T) {
+ app := newTestApp(t)
+ get := func(host, path string, header ...string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil)
+ for i := 0; i+1 < len(header); i += 2 {
+ req.Header.Set(header[i], header[i+1])
+ }
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ return rec
+ }
+ ref := regexp.MustCompile(`(?:href|src)="(/static/[^"]+)"`)
+ for _, page := range []struct{ host, path string }{{directoryHost, "/"}, {credentialHost, "/signin"}} {
+ body := get(page.host, page.path).Body.String()
+ var checked int
+ for _, m := range ref.FindAllStringSubmatch(body, -1) {
+ url := m[1]
+ bare := strings.SplitN(url, "?", 2)[0]
+ if strings.HasSuffix(bare, ".js") || strings.HasSuffix(bare, ".mjs") || strings.HasPrefix(bare, "/static/estilo/sky/") || strings.HasPrefix(bare, "/static/estilo/fonts/") {
+ continue // scripts stay bare on purpose; estilo's URLs carry their version in the path
+ }
+ checked++
+ if !strings.Contains(url, "?v=") {
+ t.Errorf("%s%s links %s without its version", page.host, page.path, url)
+ continue
+ }
+ rec := get(page.host, url)
+ if rec.Code != 200 || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
+ t.Errorf("GET %s = %d %q", url, rec.Code, rec.Header().Get("Cache-Control"))
+ }
+ etag := rec.Header().Get("ETag")
+ if b := get(page.host, bare); b.Header().Get("Cache-Control") != "no-cache" || b.Header().Get("ETag") != etag {
+ t.Errorf("GET %s (bare) = %q %q", bare, b.Header().Get("Cache-Control"), b.Header().Get("ETag"))
+ }
+ if n := get(page.host, bare, "If-None-Match", etag); n.Code != http.StatusNotModified || n.Body.Len() != 0 {
+ t.Errorf("GET %s with its ETag = %d, want 304", bare, n.Code)
+ }
+ }
+ if checked < 4 {
+ t.Errorf("%s%s: only %d versioned assets checked", page.host, page.path, checked)
+ }
+ }
+}
diff --git a/internal/home/brand.go b/internal/home/brand.go
index 7afaeb5..c4ba74c 100644
--- a/internal/home/brand.go
+++ b/internal/home/brand.go
@@ -32,6 +32,7 @@ func mountBrand(r chi.Router) {
if err != nil {
panic(err) // A missing embedded release asset is a build error.
}
+ versionAsset("/static/brand/"+name, body)
r.Get("/static/brand/"+name, serveAsset(body, contentType))
}
}
diff --git a/internal/home/brand_test.go b/internal/home/brand_test.go
index 56d605e..0570ca1 100644
--- a/internal/home/brand_test.go
+++ b/internal/home/brand_test.go
@@ -21,7 +21,7 @@ func TestDirectoryBrandAssetsArePublicAndListingKeepsNames(t *testing.T) {
body := page.Body.String()
for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Calls", "Memoria"} {
path := "/static/brand/" + strings.ToLower(name) + ".svg"
- if !strings.Contains(body, `src="`+path+`" alt=""`) || !strings.Contains(body, ">"+name+"<") {
+ if !strings.Contains(body, `src="`+versioned(path)+`" alt=""`) || !strings.Contains(body, ">"+name+"<") {
t.Fatal("listing lost icon or name", name)
}
// No session on the asset request: every sign-in surface needs its
diff --git a/internal/home/calma.go b/internal/home/calma.go
index 863c937..916fac6 100644
--- a/internal/home/calma.go
+++ b/internal/home/calma.go
@@ -19,6 +19,8 @@ var calmaCSS []byte
// sky for pages that belong to no one team: all of Home's, today, since
// the directory lists apps rather than one team's.
var homeFuncs = map[string]any{
+ // v links a stylesheet or picture by its content version (assetversion.go).
+ "v": versioned,
"suiteSky": func() sky.View { return sky.For(sky.SuiteSeed) },
// Onest's stylesheet, under estilo's versioned prefix.
"fontsCSS": func() string { return theme.FontPrefix + "fonts.css" },
@@ -59,6 +61,6 @@ var hopPages = map[string]bool{
func mountCalma(r chi.Router) {
r.Handle(theme.FontPrefix+"*", theme.FontHandler())
r.Handle(sky.Prefix+"*", sky.Handler())
- r.Get("/static/estilo/sky.css", serveAsset(sky.CSS(), "text/css; charset=utf-8"))
- r.Get("/static/calma.css", serveAsset(calmaCSS, "text/css; charset=utf-8"))
+ mountCSS(r, "/static/estilo/sky.css", sky.CSS())
+ mountCSS(r, "/static/calma.css", calmaCSS)
}
diff --git a/internal/home/credits.go b/internal/home/credits.go
index 1c469ff..07dded1 100644
--- a/internal/home/credits.go
+++ b/internal/home/credits.go
@@ -30,5 +30,5 @@ func (a *App) mountCredits(r chi.Router) {
a.render(w, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components})
})
- r.Get("/static/credits.css", serveAsset(credits.CSS(), "text/css; charset=utf-8"))
+ mountCSS(r, "/static/credits.css", credits.CSS())
}
diff --git a/internal/home/directory.go b/internal/home/directory.go
index 44d052b..c2c5a0d 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -117,8 +117,8 @@ func (a *App) directoryRouter() http.Handler {
a.render(w, 200, "directory_signed_out.html", map[string]any{})
})
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
- r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
- r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
+ mountCSS(r, "/static/tokens.css", staticAsset("tokens.css"))
+ mountCSS(r, "/static/theme.css", theme.CSS())
mountBrand(r)
mountCalma(r)
mountSite(r)
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go
index d94bec8..b417778 100644
--- a/internal/home/directory_catalog.go
+++ b/internal/home/directory_catalog.go
@@ -36,6 +36,9 @@ func directoryCatalog(cfg Runtime) []catalogApp {
apps[i].Status = "Open preview"
}
}
+ for i := range apps {
+ apps[i].Icon = versioned(apps[i].Icon)
+ }
return apps
}
diff --git a/internal/home/page.go b/internal/home/page.go
index 984d5bd..6bdedfa 100644
--- a/internal/home/page.go
+++ b/internal/home/page.go
@@ -4,6 +4,7 @@ import (
"bytes"
"embed"
"net/http"
+ "sync"
"amadan.net/oficina/estilo.git/theme"
"github.com/go-chi/chi/v5"
@@ -57,12 +58,21 @@ var appsCSS = func() []byte {
// mountBrand keeps one: a request path must never be treated as a file
// name.
func mountSite(r chi.Router) {
- r.Get("/static/site/home.css", serveAsset(siteAsset("home.css"), "text/css; charset=utf-8"))
- r.Get("/static/site/apps.css", serveAsset(appsCSS, "text/css; charset=utf-8"))
+ mountCSS(r, "/static/site/home.css", siteAsset("home.css"))
+ mountCSS(r, "/static/site/apps.css", appsCSS)
}
+// servedIndex is the front page with every stylesheet and picture linked
+// by its content version. Built on first serve, once the routers have
+// mounted (and so versioned) everything the page links.
+var (
+ servedIndexOnce sync.Once
+ servedIndex []byte
+)
+
// handleIndex serves the front page.
func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) {
+ servedIndexOnce.Do(func() { servedIndex = versionStaticRefs(indexPage) })
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write(indexPage)
+ w.Write(servedIndex)
}
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index 4eb2e64..d19fa1d 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -199,9 +199,24 @@ func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Reque
// maps .mjs. A wrong MIME type makes a browser refuse a module silently,
// which is the worst way for this to fail.
func serveAsset(body []byte, contentType string) http.HandlerFunc {
+ version := contentVersion(body)
+ etag := `"` + version + `"`
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
- w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("ETag", etag)
+ // A URL carrying this body's version can never serve anything
+ // else, so the browser keeps it for a year without asking. Any
+ // other request โ€” the bare path, a stale version โ€” revalidates,
+ // and the ETag makes that a 304 rather than the bytes again.
+ if r.URL.Query().Get("v") == version {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ if r.Header.Get("If-None-Match") == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
w.WriteHeader(http.StatusOK)
w.Write(body)
}
diff --git a/internal/home/templates/credits.html b/internal/home/templates/credits.html
index b587fe5..596f7dd 100644
--- a/internal/home/templates/credits.html
+++ b/internal/home/templates/credits.html
@@ -1,5 +1,5 @@
{{define "title"}}Open source ยท Oficina{{end}}
-{{define "head"}}<link rel="stylesheet" href="/static/credits.css">{{end}}
+{{define "head"}}<link rel="stylesheet" href="{{v "/static/credits.css"}}">{{end}}
{{define "content"}}{{template "estilo/credits" .}}{{end}}
{{define "help"}}{{template "home/help" .}}{{end}}
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html
index c5854b2..a085be3 100644
--- a/internal/home/templates/directory.html
+++ b/internal/home/templates/directory.html
@@ -1,7 +1,7 @@
{{define "title"}}Oficina{{end}}
{{define "content"}}
<header home-hub-head>
-<h1 estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="40" height="40">Oficina</h1>
+<h1 estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="40" height="40">Oficina</h1>
<form method="post" action="/signout"><button type="submit" rst-btn>Sign out</button></form>
</header>
<p home-hub-lede>You are signed in. Every app below opens with this one account.</p>
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html
index 70a7b07..d536c5d 100644
--- a/internal/home/templates/layout.html
+++ b/internal/home/templates/layout.html
@@ -1,18 +1,18 @@
{{define "layout"}}<!doctype html>
<html lang="{{block "lang" .}}en{{end}}">
<head>
-<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
-<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
-<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
-<link rel="stylesheet" href="/static/brand/brand.css">
+<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}">
+<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}">
+<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}">
+<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Oficina{{end}}</title>
<link rel="stylesheet" href="{{fontsCSS}}">
-<link rel="stylesheet" href="/static/tokens.css">
-<link rel="stylesheet" href="/static/theme.css">
-<link rel="stylesheet" href="/static/estilo/sky.css">
-<link rel="stylesheet" href="/static/calma.css">
+<link rel="stylesheet" href="{{v "/static/tokens.css"}}">
+<link rel="stylesheet" href="{{v "/static/theme.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/sky.css"}}">
+<link rel="stylesheet" href="{{v "/static/calma.css"}}">
{{block "head" .}}{{end}}
</head>
<body{{block "bodyattrs" .}}{{end}}>
@@ -20,7 +20,7 @@
{{if not hop}}{{template "estilo-sky" suiteSky}}{{end}}
{{block "help" .}}{{end}}
<main rst-page{{if door}} home-door{{end}}>
-{{if door}}<p estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="36" height="36">Oficina</p>{{end}}
+{{if door}}<p estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="36" height="36">Oficina</p>{{end}}
{{template "content" .}}
</main>
</div>