feat: per-file Cache-Control, navigation-scoped SPA fallback, nosniff, non-root image; v1.7.0

- Cache-Control per file: no-cache for index.html + service workers, immutable 1y for
  content-hashed assets, 1h for the rest (Vite/CRA/Angular). Closes the stale-release
  trap at the HTTP layer that the v1.6.0 image fix closed at the container layer.
- SPA fallback scoped to navigation: a missing asset (path w/ extension) or a non-HTML
  Accept now returns 404 instead of index.html (no more HTML-as-JS 'Unexpected token <').
- X-Content-Type-Options: nosniff on every response.
- Image runs as a non-root 'spa' user.
- README: caching table, fallback contract, and the Angular dist/<project>/browser/ note.
This commit is contained in:
2026-08-18 17:55:56 -06:00
parent bbc588d933
commit b73b9ee022
9 changed files with 343 additions and 18 deletions
+103 -5
View File
@@ -3,7 +3,9 @@ package spa
import (
"net/http"
"path"
"path/filepath"
"strings"
"code.nochebuena.dev/einherjar/contracts/logging"
)
@@ -15,8 +17,14 @@ type handler struct {
}
// NewHandler returns an http.Handler that serves static files from staticDir.
// Requests for paths that exist on disk are served directly.
// Any other path receives index.html, delegating routing to the SPA.
//
// A path that resolves to a file on disk is served directly, with a Cache-Control
// header chosen from its name (see [cacheControl]). A path that does not resolve to
// a file follows the SPA routing contract: a browser navigation receives index.html
// so the client-side router can handle the route, while a request for a missing
// asset (a path with a file extension, or a non-HTML client) receives 404 — so a
// misconfigured deploy fails loudly at the first request instead of returning
// index.html as JavaScript ("Unexpected token '<'").
func NewHandler(logger logging.Logger, staticDir string) http.Handler {
return &handler{
logger: logger,
@@ -29,8 +37,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// http.Dir.Open sanitises the path, preventing directory traversal.
f, err := http.Dir(h.staticDir).Open(r.URL.Path)
if err != nil {
// Path does not exist — hand off to the SPA router.
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
h.serveFallback(w, r)
return
}
defer f.Close()
@@ -38,9 +45,100 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
stat, err := f.Stat()
if err != nil || stat.IsDir() {
// Directory listing is disabled; treat directories as SPA routes.
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
h.serveFallback(w, r)
return
}
// Existing file: set Cache-Control from its name, then delegate to FileServer,
// which fills Content-Type, Last-Modified, range support and 304s — but never
// Cache-Control, so ours stands.
w.Header().Set("Cache-Control", cacheControl(path.Base(r.URL.Path)))
h.fs.ServeHTTP(w, r)
}
// serveFallback applies the SPA routing contract for a path that is not a file.
func (h *handler) serveFallback(w http.ResponseWriter, r *http.Request) {
if looksLikeFile(r.URL.Path) || !acceptsHTML(r) {
http.NotFound(w, r)
return
}
// index.html must always be revalidated so a new deploy is picked up.
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, filepath.Join(h.staticDir, "index.html"))
}
// looksLikeFile reports whether the last path segment carries a file extension —
// e.g. /main.4f8a2b1c.js is an asset request, /dashboard is a route.
func looksLikeFile(urlPath string) bool {
return path.Ext(path.Base(path.Clean(urlPath))) != ""
}
// acceptsHTML reports whether the client will take an HTML document. A browser
// navigation sends "text/html"; "*/*" and an absent header are treated as willing
// (curl, health probes). A typed non-HTML Accept (e.g. application/json) is not —
// such a request for a missing route gets 404 rather than an HTML body.
func acceptsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
return accept == "" ||
strings.Contains(accept, "text/html") ||
strings.Contains(accept, "*/*")
}
// cacheControl chooses a Cache-Control value for a static file by name, correct for
// Vite, CRA and Angular output alike:
//
// - index.html and service-worker files → no-cache (revalidate every load, so a
// new release is never masked by a cached shell or a stale ngsw.json).
// - content-hashed filenames → one year, immutable (the hash changes when the
// content does, so the URL is safe to cache forever).
// - everything else → one hour (favicon.ico, icons/ and other verbatim assets are
// not hashed; a year would be a footgun when they change under the same name).
func cacheControl(name string) string {
if name == "index.html" || isServiceWorker(name) {
return "no-cache"
}
if isHashed(name) {
return "public, max-age=31536000, immutable"
}
return "public, max-age=3600"
}
// isServiceWorker reports whether name is a service-worker or its manifest, across
// the common toolchains: Angular (ngsw.json, ngsw-worker.js, safety-worker.js),
// CRA (service-worker.js), hand-rolled (sw.js) and Vite-PWA/Workbox (workbox-*.js).
// These must never be cached, or a client can get pinned to a superseded release.
func isServiceWorker(name string) bool {
switch name {
case "ngsw.json", "ngsw-worker.js", "safety-worker.js", "service-worker.js", "sw.js":
return true
}
return strings.HasPrefix(name, "workbox-")
}
// isHashed reports whether name ends in a content-hash token: a run of at least 8
// alphanumerics containing a digit, set off by '.', '-' or '_' right before the
// extension (main.4f8a2b1c.js, index-DkJf3x9a.js, styles-4NDEUD5S.css). It is a
// heuristic — a false negative only costs a revalidation, never staleness — so it
// leans conservative (the digit requirement keeps it off plain words like about.js).
func isHashed(name string) bool {
base := strings.TrimSuffix(name, path.Ext(name))
i := strings.LastIndexAny(base, ".-_")
if i < 0 {
return false
}
token := base[i+1:]
if len(token) < 8 {
return false
}
hasDigit := false
for _, c := range token {
switch {
case c >= '0' && c <= '9':
hasDigit = true
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
default:
return false
}
}
return hasDigit
}
+123
View File
@@ -0,0 +1,123 @@
package spa
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/core/logz"
)
func discardLogger() logging.Logger { return logz.New(logz.Config{Writer: io.Discard}) }
// newTestHandler builds a handler over a temp dir seeded with one file of each
// caching class plus index.html.
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
dir := t.TempDir()
for name, body := range map[string]string{
"index.html": "<!doctype html><title>app</title>",
"main.4f8a2b1c.js": "console.log(1)",
"favicon.ico": "icon",
"ngsw.json": "{}",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return NewHandler(discardLogger(), dir)
}
func get(h http.Handler, target, accept string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil)
if accept != "" {
req.Header.Set("Accept", accept)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestCacheControl(t *testing.T) {
h := newTestHandler(t)
cases := []struct {
target, want string
}{
{"/main.4f8a2b1c.js", "public, max-age=31536000, immutable"}, // content-hashed
{"/favicon.ico", "public, max-age=3600"}, // verbatim, not hashed
{"/ngsw.json", "no-cache"}, // service-worker manifest
}
for _, c := range cases {
rec := get(h, c.target, "*/*")
if rec.Code != http.StatusOK {
t.Errorf("%s: status = %d, want 200", c.target, rec.Code)
continue
}
if got := rec.Header().Get("Cache-Control"); got != c.want {
t.Errorf("%s: Cache-Control = %q, want %q", c.target, got, c.want)
}
}
}
func TestIndexIsNoCache(t *testing.T) {
// The SPA shell (served for a navigation route) must revalidate every load.
rec := get(newTestHandler(t), "/dashboard", "text/html")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
t.Errorf("index Cache-Control = %q, want no-cache", got)
}
}
func TestFallback_RouteServesIndex(t *testing.T) {
rec := get(newTestHandler(t), "/deep/route", "text/html,application/xhtml+xml")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (index.html for a route)", rec.Code)
}
if body := rec.Body.String(); body == "" || body[0] != '<' {
t.Errorf("expected index.html body, got %q", body)
}
}
func TestFallback_MissingAssetIs404(t *testing.T) {
// A missing file with an extension must not be masked as HTML.
rec := get(newTestHandler(t), "/chunk.9f9f9f9f.js", "*/*")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a missing asset", rec.Code)
}
}
func TestFallback_NonHTMLAcceptIs404(t *testing.T) {
// A typed non-HTML client asking for a missing route gets 404, not the shell.
rec := get(newTestHandler(t), "/api/thing", "application/json")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a non-HTML client", rec.Code)
}
}
func TestFallback_RootServesIndex(t *testing.T) {
rec := get(newTestHandler(t), "/", "text/html")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 for /", rec.Code)
}
}
func TestIsHashed(t *testing.T) {
hashed := []string{"main.4f8a2b1c.js", "index-DkJf3x9a.js", "styles-4NDEUD5S.css", "app.a1b2c3d4.mjs"}
plain := []string{"favicon.ico", "index.html", "logo.png", "about.js", "main.js", "vendor.css"}
for _, n := range hashed {
if !isHashed(n) {
t.Errorf("isHashed(%q) = false, want true", n)
}
}
for _, n := range plain {
if isHashed(n) {
t.Errorf("isHashed(%q) = true, want false", n)
}
}
}