Version web assets by content so caches can't serve stale scripts

Cloudflare forces browser caching of .js/.css to max-age=14400 regardless
of the origin's no-cache, while the page itself stays uncached. After a
deploy, browsers paired the new index.html with the old app.js, which
failed on upload with "Cannot set properties of null (setting
'innerHTML')".

The image build now copies every asset into v/<content hash>/ and rewrites
index.html's references; nginx serves v/ as immutable and the page as
no-cache, so each deploy changes every asset URL. Security headers move to
a snippet included per location, since nginx drops server-level add_header
in locations that set their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
This commit is contained in:
2026-09-15 17:15:01 +09:00
parent aed2d54b45
commit 5420975f8e
4 changed files with 46 additions and 10 deletions
+17 -9
View File
@@ -1,9 +1,10 @@
# Static server for the web UI (web/).
#
# The page decodes and analyses audio inside the visitor's browser. The CSP's
# `connect-src 'none'` forbids the page and its worker from making any network
# request (fetch, XHR, WebSocket, beacon), so dropped audio has no way to leave
# the tab — enforced by the browser, not just promised by the code.
# `connect-src 'none'` (security-headers.conf) forbids the page and its worker
# from making any network request (fetch, XHR, WebSocket, beacon), so dropped
# audio has no way to leave the tab — enforced by the browser, not just promised
# by the code.
server {
listen 8080;
@@ -19,13 +20,20 @@ server {
gzip on;
gzip_types text/css application/javascript;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
# Revalidate on every load so an update isn't stuck behind a proxy cache.
add_header Cache-Control "no-cache" always;
# The page names the current asset version, so it is always revalidated.
# (Each location includes the security headers itself: nginx drops
# server-level add_header in any location that adds its own.)
location / {
include /etc/nginx/snippets/ujm-security-headers.conf;
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ =404;
}
# Assets live under v/<content hash>/ (see Dockerfile): a URL's content never
# changes, so it can be cached indefinitely.
location /v/ {
include /etc/nginx/snippets/ujm-security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Security headers for every response, included per location in nginx.conf.
#
# connect-src 'none': neither the page nor its Web Worker may make network
# requests, so audio cannot be sent anywhere. The worker's own policy comes from
# its script response, which is why every location carries these headers.
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;