# 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 — `` 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; } }