All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m13s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m25s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m40s
CD / Promote to Int (push) Successful in 15s
CI / Lint, typecheck, test (push) Successful in 7m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m45s
CI / Import/export fidelity gate (push) Successful in 55s
`apps/web/index.html` carries a hard `lang="en"`. The app corrects it at runtime (#163), but nginx answers every SPA route with that same file, so a crawler or a no-JS visit — `/public/...` on prod is exactly that — saw `en` for German content, permanently. WCAG 3.1.1 is about the delivered document, not the one JavaScript later fixes. nginx-only, no backend involved: a `map` on `Accept-Language` and a `sub_filter` in the index.html path. Only the FIRST tag decides, which is what "the browser's preferred language" means and mirrors #163 — `de-CH` counts as German, `en-US,de` does not. `Vary: Accept-Language` is new. The response now genuinely depends on a request header, and without it a shared cache could hand one language's copy to the other. Everything else in the location is untouched: same CSP, same `Cache-Control: no-cache`, same `nosniff`. The known limit is documented in the config rather than worked around: nginx cannot read `instance.defaultLocale`, so an unlisted or absent Accept-Language yields `en` even on a German instance. For public content that is not the authoritative rendering anyway — the api's server shell (`/api/v1/public/...`) already renders those with the instance locale. Verified against a real nginx 1.27 (the image the stage runs) with the config mounted as-is: `de-DE,de;q=0.9,en;q=0.8` and `de` yield `lang="de"`; `en-US,en;q=0.9`, `en-US,en;q=0.9,de;q=0.8`, `fr-FR,fr` and a request with no header yield `lang="en"`; an SPA route (`/public/teich/seite`) negotiates the same way; the gzipped response is rewritten too, and a JavaScript asset comes through byte-identical.
71 lines
3.4 KiB
Nginx Configuration File
71 lines
3.4 KiB
Nginx Configuration File
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
|
|
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
|
|
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
|
|
# SPA route — which nginx answers with index.html — would see `en` forever,
|
|
# even for German content.
|
|
#
|
|
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
|
|
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
|
|
# German; `en-US,de` does not, because that visitor asked for English first.
|
|
#
|
|
# Known limit, documented rather than worked around: nginx does not know
|
|
# `instance.defaultLocale` from the database, so a visitor with no (or an
|
|
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
|
|
# content that is not the authoritative rendering anyway — the api's server
|
|
# shell (`/api/v1/public/...`) renders those with the instance locale.
|
|
map $http_accept_language $spa_lang {
|
|
default en;
|
|
~*^de de;
|
|
}
|
|
|
|
# SPA serving: static assets with long-lived caching, everything else
|
|
# falls back to index.html (client-side routing).
|
|
server {
|
|
listen 8080;
|
|
server_name _;
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
gzip on;
|
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
|
gzip_min_length 1024;
|
|
|
|
location /healthz {
|
|
add_header Content-Type text/plain;
|
|
return 200 'ok';
|
|
}
|
|
|
|
location /assets/ {
|
|
# Vite emits content-hashed filenames — safe to cache forever.
|
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
try_files $uri =404;
|
|
}
|
|
|
|
location / {
|
|
add_header Cache-Control "no-cache";
|
|
# Strict CSP (security.md, ADR 0016): everything self-hosted, zero
|
|
# third-party origins — fonts, scripts, styles, and XHR/WebSocket all
|
|
# from this origin only (the GDPR "zero external requests" posture).
|
|
# `style-src 'unsafe-inline'` covers the app's inline style attributes
|
|
# (CSS custom properties, layout); scripts are all external files.
|
|
# font-src includes `data:` for the subsetted fonts Excalidraw embeds
|
|
# into saved sketch SVGs (inlined by the plugin fallback renderer on
|
|
# public pages). data: fonts trigger no network request, so the
|
|
# zero-third-party-request guarantee (security.md) is unaffected.
|
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
# The shell's language (issue #179). Only the html document is
|
|
# rewritten, and only its first match — `<html lang="en">` is the
|
|
# first and only occurrence in index.html. Everything else this
|
|
# location serves passes through untouched.
|
|
sub_filter_types text/html;
|
|
sub_filter_once on;
|
|
sub_filter 'lang="en"' 'lang="$spa_lang"';
|
|
# The response now depends on a request header, so shared caches must
|
|
# not serve one language's copy to the other. This location is
|
|
# `no-cache` anyway; the header states the dependency correctly.
|
|
add_header Vary "Accept-Language" always;
|
|
try_files $uri /index.html;
|
|
}
|
|
}
|