diff --git a/CHANGELOG.md b/CHANGELOG.md index 0726af9..787112e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [1.7.0] — 2026-08-14 + +Minor — HTTP behaviour: caching, a strict SPA fallback, security headers, and a non-root image. + +### Added + +- **Per-file `Cache-Control`.** `index.html` and service-worker files (`ngsw.json`, `ngsw-worker.js`, + `sw.js`, `service-worker.js`, `safety-worker.js`, `workbox-*.js`) are `no-cache`; content-hashed + assets are `public, max-age=31536000, immutable`; everything else is `public, max-age=3600`. + Correct for Vite, CRA and Angular output. Closes, at the HTTP layer, the same stale-release trap + the v1.6.0 image fix closed at the container layer. +- **`X-Content-Type-Options: nosniff`** on every response. +- **Non-root container.** The image adds an unprivileged `spa` user and runs as it — a static binary + serving read-only files on `:8080` needs no privilege. + +### Changed + +- **The `index.html` fallback is now scoped to navigation requests.** A path that does not resolve to + a file is served `index.html` only when it has no file extension and the client accepts HTML + (`text/html` / `*/*`). A missing asset (`/main.js`) or a typed non-HTML client now gets **404** + instead of `index.html`, so a broken deploy fails loudly rather than shipping the SPA shell as + JavaScript (`Unexpected token '<'`). +- Bumped einherjar dependencies to v1.7.0. + +### Docs + +- README documents the caching table and the navigation-scoped fallback, and notes that **Angular** + must `COPY dist//browser/` (not `dist/`) — the application builder nests `index.html` + under `browser/`. + ## [1.6.0] — 2026-08-14 Minor — coordinated framework release (lockstep versioning). No changes to this module's own API. diff --git a/Dockerfile b/Dockerfile index 4473c79..96b7289 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ ./cmd/spa-server FROM alpine:3.21 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S spa && adduser -S -G spa -H -s /sbin/nologin spa WORKDIR /app COPY --from=builder /app/spa-server . @@ -32,5 +33,10 @@ LABEL dev.nochebuena.healthz="/health" # previous build — a silent stale deploy (worse for PWAs, whose service worker then # caches the stale ngsw.json). A runtime bind-mount works with or without it, and a # child image cannot un-declare an inherited VOLUME, so it must not be declared at all. + +# Drop root: the server is a static binary serving read-only files on a >1024 port, +# so it needs no privilege. COPY'd assets are world-readable, so the unprivileged +# user reads /srv/www without a chown. +USER spa EXPOSE 8080 ENTRYPOINT ["/app/spa-server"] diff --git a/README.md b/README.md index 277ef6e..d965cce 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # einherjar/spa-server -[![version](https://img.shields.io/badge/version-v1.6.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/spa-server) +[![version](https://img.shields.io/badge/version-v1.7.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/spa-server) [![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) > A shield wall holds because every warrior knows their position. The SPA asks only for the wall — not for every soldier's name. -`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for any path that does not resolve to a file on disk — the standard SPA routing contract. +`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for navigation routes that do not resolve to a file — the standard SPA routing contract — while a missing asset returns `404` rather than the HTML shell. The module ships as a ready-to-use Docker base image. Deploying a SPA to a container requires a single `COPY` instruction. No nginx, no custom configuration, no index.html redirect logic to maintain. @@ -15,11 +15,23 @@ The module ships as a ready-to-use Docker base image. Deploying a SPA to a conta ## Container usage ```dockerfile -FROM code.nochebuena.dev/einherjar/spa-server:v1.6.0 +FROM code.nochebuena.dev/einherjar/spa-server:v1.7.0 COPY dist/ /srv/www/ ``` -That is the complete Dockerfile for a production SPA container. +That is the complete Dockerfile for a production SPA container. It runs as an +unprivileged user and serves `/srv/www` on port 8080. + +`COPY dist/ /srv/www/` is correct for **Vite** and **CRA**, which emit `index.html` +at the root of `dist/`. **Angular**'s application builder instead emits +`dist//browser/` (with `index.html` inside `browser/`, next to +`3rdpartylicenses.txt`), so copy that subdirectory — otherwise no `index.html` lands +at the root and every request falls through to a 404: + +```dockerfile +FROM code.nochebuena.dev/einherjar/spa-server:v1.7.0 +COPY dist/my-app/browser/ /srv/www/ +``` --- @@ -51,8 +63,34 @@ That is the complete Dockerfile for a production SPA container. |---|---| | `/app.js` — file exists | Served directly with correct `Content-Type` | | `/assets/logo.png` — file exists | Served directly | -| `/dashboard` — no matching file | `index.html` served (SPA router handles it) | +| `/dashboard` — no file, `Accept: text/html` | `index.html` served (SPA router handles it) | | `/` — directory | `index.html` served (directory listing is disabled) | +| `/main.js` — **no file, has an extension** | **`404`** — a missing asset is not masked as HTML | +| `/api/x` — no file, `Accept: application/json` | **`404`** — a non-HTML client is not handed `index.html` | + +The fallback to `index.html` is deliberately scoped to **navigation** requests (no +file extension, and `Accept` includes `text/html` or `*/*`). A request for a missing +asset returns `404` instead of `index.html`, so a broken deploy fails at the first +request rather than delivering the SPA shell as JavaScript (`Unexpected token '<'`). + +--- + +## Caching + +`Cache-Control` is set per file, correct for Vite, CRA and Angular alike: + +| File | `Cache-Control` | +|---|---| +| `index.html` | `no-cache` — revalidated every load, so a new deploy is never masked | +| Service workers (`ngsw.json`, `ngsw-worker.js`, `sw.js`, `service-worker.js`, `safety-worker.js`, `workbox-*.js`) | `no-cache` — a client is never pinned to a superseded release | +| Content-hashed assets (`main.4f8a2b1c.js`, `index-DkJf3x9a.js`, …) | `public, max-age=31536000, immutable` | +| Everything else (`favicon.ico`, `icons/`, verbatim assets) | `public, max-age=3600` | + +A hashed filename is detected by a trailing hash-like token (≥8 alphanumerics +including a digit) before the extension — a heuristic whose only failure mode is a +missed year-long cache, never staleness. + +Every response also carries `X-Content-Type-Options: nosniff`. --- diff --git a/go.mod b/go.mod index a5e167c..2c59bda 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module code.nochebuena.dev/einherjar/spa-server go 1.26 require ( - code.nochebuena.dev/einherjar/contracts v1.6.0 - code.nochebuena.dev/einherjar/core v1.6.0 + code.nochebuena.dev/einherjar/contracts v1.7.0 + code.nochebuena.dev/einherjar/core v1.7.0 ) diff --git a/go.sum b/go.sum index 3ced3d8..536b4d6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -code.nochebuena.dev/einherjar/contracts v1.6.0 h1:Y+8B+m4kQR5l/6lMY7SYdvIbwhFOnliCXDv9oBCOUP4= -code.nochebuena.dev/einherjar/contracts v1.6.0/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI= -code.nochebuena.dev/einherjar/core v1.6.0 h1:6cQIYZliw0hcuk7Cy44swleLC1Ch8WFxTkkHsGxkAFk= -code.nochebuena.dev/einherjar/core v1.6.0/go.mod h1:azRKvJBtMWGp8jhveG1fZc9jlPTwXu8clvBdXIjTB9k= +code.nochebuena.dev/einherjar/contracts v1.7.0 h1:yhbtmvE8u6KXcuG95p888+4tDIiTXDf5z/siKrjGbrc= +code.nochebuena.dev/einherjar/contracts v1.7.0/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI= +code.nochebuena.dev/einherjar/core v1.7.0 h1:gdjNEgO8E/ALppyBldj27iKQQlWbL/OWV6asdxWLXqU= +code.nochebuena.dev/einherjar/core v1.7.0/go.mod h1:IvSCG7XL4gNyoylwlClKj3jepWBLAKb2QKAgumTRkug= diff --git a/server.go b/server.go index 9358438..5f7ba3f 100644 --- a/server.go +++ b/server.go @@ -35,11 +35,22 @@ func (s *Server) OnInit() error { s.srv = &http.Server{ Addr: fmt.Sprintf(":%d", s.cfg.Port), - Handler: mux, + Handler: securityHeaders(mux), } return nil } +// securityHeaders applies response headers that should hold for every route. +// X-Content-Type-Options: nosniff stops a browser from MIME-sniffing a response +// into an executable type — the safety net for a missing asset that slips through +// as HTML, or any upstream that mislabels a Content-Type. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + // OnStart begins serving HTTP requests in a background goroutine. // The TCP listener binds synchronously so a port conflict surfaces immediately. func (s *Server) OnStart() error { diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..6e97949 --- /dev/null +++ b/server_test.go @@ -0,0 +1,19 @@ +package spaserver + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// securityHeaders must stamp nosniff on every response, regardless of the route. +func TestSecurityHeaders_Nosniff(t *testing.T) { + h := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } +} diff --git a/spa/handler.go b/spa/handler.go index 57fe099..f08e66d 100644 --- a/spa/handler.go +++ b/spa/handler.go @@ -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 +} diff --git a/spa/handler_test.go b/spa/handler_test.go new file mode 100644 index 0000000..e6cdb8a --- /dev/null +++ b/spa/handler_test.go @@ -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": "app", + "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) + } + } +}