Serve index.html no-cache on every route; check the theme script's CSP hash

`try_files` served the SPA fallback inside `location /`, so `/` and every
client route went out without `Cache-Control: no-cache`. The fallback is now
the last argument, an internal redirect to the exact no-cache location.

`src/csp.check.ts` recomputes the inline theme script's sha256 and fails
`bun run check` with the new value when the CSP no longer names it.
This commit is contained in:
2026-10-07 09:32:06 +02:00
parent ff3a31e515
commit 7d6cc5b0bf
3 changed files with 39 additions and 2 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
#
# script-src: the bundle, plus the theme script inline in index.html by its
# hash. Edit that script and this hash must change with it (sha256 of the text
# between the tags, base64), or the app paints light-then-dark again.
# between the tags, base64), or the app paints light-then-dark again;
# `bun run check` (src/csp.check.ts) fails with the new value.
# style-src 'unsafe-inline': Monaco, Radix's scroll lock and the toasts insert
# <style> elements at runtime, which no hash can name.
# img/frame-src take any http(s): a camera stream or website widget points
+4 -1
View File
@@ -42,10 +42,13 @@ server {
include /etc/nginx/security-headers.conf;
# The fallback is the last argument, so it is an internal redirect to the
# exact location below and `/` and every SPA route get its no-cache too; as
# a middle argument it was served from here, without one.
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri /index.html =404;
try_files $uri /index.html;
}
# Vite puts a content hash in every asset's name, so one can never change
+33
View File
@@ -0,0 +1,33 @@
/**
* The CSP's hash for the theme script, checked against the script.
*
* ponytail: a script rather than a suite, as with the other `.check.ts` files:
*
* cd frontend && bun run src/csp.check.ts
*
* `index.html` runs one inline script, the theme class before first paint, and
* `nginx-security-headers.conf` admits it by its sha256. Edit one without the
* other and the browser blocks the script, so the app paints light-then-dark
* again, with nothing failing anywhere but the console.
*/
import assert from "node:assert/strict"
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
const read = (path: string) =>
readFileSync(new URL(path, import.meta.url), "utf8")
const inline = [
...read("../index.html").matchAll(/<script>([\s\S]*?)<\/script>/g),
]
assert.equal(inline.length, 1, "index.html has one inline script")
const hash = createHash("sha256").update(inline[0][1]).digest("base64")
const csp = read("../nginx-security-headers.conf")
assert.ok(
csp.includes(`'sha256-${hash}'`),
`the theme script's hash is not in nginx-security-headers.conf; set it to 'sha256-${hash}'`,
)
console.log("csp: ok")