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:
@@ -4,3 +4,4 @@
|
|||||||
*
|
*
|
||||||
!web/
|
!web/
|
||||||
!deploy/nginx.conf
|
!deploy/nginx.conf
|
||||||
|
!deploy/security-headers.conf
|
||||||
|
|||||||
+20
-1
@@ -8,10 +8,29 @@
|
|||||||
#
|
#
|
||||||
# The desktop app (PyQt5) is unaffected and not part of this image.
|
# The desktop app (PyQt5) is unaffected and not part of this image.
|
||||||
|
|
||||||
|
# ---- assets: version every file except the page by content ------------------
|
||||||
|
# Everything but index.html goes under v/<hash of the assets>/, and index.html's
|
||||||
|
# references are rewritten to match. Each deploy therefore changes every asset
|
||||||
|
# URL, so no cache (browser or Cloudflare, which forces .js/.css browser caching
|
||||||
|
# to 4 h) can pair a new index.html with an old app.js. Module imports and the
|
||||||
|
# worker URL are relative, so they resolve inside the versioned directory as is.
|
||||||
|
FROM alpine:3.22 AS assets
|
||||||
|
COPY web/ /src/
|
||||||
|
RUN set -eu; \
|
||||||
|
cd /src; \
|
||||||
|
v="$(find . -type f ! -name index.html | sort | xargs sha256sum | sha256sum | cut -c1-12)"; \
|
||||||
|
mkdir -p "/out/v/$v"; \
|
||||||
|
for f in *; do [ "$f" = index.html ] || cp -R "$f" "/out/v/$v/"; done; \
|
||||||
|
sed -E "s#(href|src)=\"(app\.css|app\.js|vendor/[^\"]+)\"#\1=\"v/$v/\2\"#g" index.html > /out/index.html; \
|
||||||
|
grep -q "src=\"v/$v/app.js\"" /out/index.html; \
|
||||||
|
grep -q "href=\"v/$v/app.css\"" /out/index.html
|
||||||
|
|
||||||
|
# ---- runtime -------------------------------------------------------------------
|
||||||
FROM nginxinc/nginx-unprivileged:stable-alpine
|
FROM nginxinc/nginx-unprivileged:stable-alpine
|
||||||
|
|
||||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY web/ /usr/share/nginx/html/
|
COPY deploy/security-headers.conf /etc/nginx/snippets/ujm-security-headers.conf
|
||||||
|
COPY --from=assets /out/ /usr/share/nginx/html/
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -1,9 +1,10 @@
|
|||||||
# Static server for the web UI (web/).
|
# Static server for the web UI (web/).
|
||||||
#
|
#
|
||||||
# The page decodes and analyses audio inside the visitor's browser. The CSP's
|
# 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
|
# `connect-src 'none'` (security-headers.conf) forbids the page and its worker
|
||||||
# request (fetch, XHR, WebSocket, beacon), so dropped audio has no way to leave
|
# from making any network request (fetch, XHR, WebSocket, beacon), so dropped
|
||||||
# the tab — enforced by the browser, not just promised by the code.
|
# audio has no way to leave the tab — enforced by the browser, not just promised
|
||||||
|
# by the code.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
@@ -19,13 +20,20 @@ server {
|
|||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/css application/javascript;
|
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;
|
# The page names the current asset version, so it is always revalidated.
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
# (Each location includes the security headers itself: nginx drops
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
# server-level add_header in any location that adds its own.)
|
||||||
# Revalidate on every load so an update isn't stuck behind a proxy cache.
|
|
||||||
add_header Cache-Control "no-cache" always;
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
include /etc/nginx/snippets/ujm-security-headers.conf;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
try_files $uri $uri/ =404;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user