Files
EpicNext-Cms/deployment/proxy/nginx-cms.conf
T
openhands dbaccd7cfc fix(gamedata): never cache a missing gamedata file
A missing gamedata file got no Cache-Control at all, because add_header
without `always` only applies to 2xx/3xx. Cloudflare then fell back to the
zone setting "Browser Cache TTL = 1 year", so the 404 came back as
`max-age=31536000` with `cf-cache-status: HIT` — pinned in the visitor's
browser and at the edge. An icon requested while its import was still
running stayed a 404 for the rest of the year, even after the file existed.
That was the "some icons load, some don't" report.

Give every gamedata location a named 404 handler that sends no-store, and
split icons/ out as its own cache class: those files are rewritten under
the same name (repair-icons, reimport), so an hourly must-revalidate keeps
a repaired icon visible within the hour instead of days later.
2026-10-01 18:00:36 +02:00

528 lines
22 KiB
Plaintext

# ─── EpicNabbo CMS — nginx site config ───
# Source of truth: deployment/proxy/nginx-cms.conf in the EpicNext-Cms repo.
# Installed at /etc/nginx/sites-available/cms.conf by scripts/nginx-sync.sh.
#
# Ingeladen binnen http{} uit /etc/nginx/sites-enabled/*.conf.
#
# PRINCIPE — één eigenaar per URL-klasse:
# * Alleen nginx (dit bestand) mag Cache-Control toevoegen voor routes die
# een publieke, gedeelde cache toestaan.
# * Alles wat de app zelf (src/proxy.ts) als no-store stuurt, blijft no-store.
# * Er is GEEN byte-cache meer (geen proxy_cache_*): de app deed ooit zelf
# al single-flight/stale-while-revalidate in src/lib/cache.ts. Daarmee is
# "dubbele cache" (nginx HIT naast de app) structureel onmogelijk.
# * De headers die hieronder staan zijn de enige Cache-Control die een
# client/CDN te zien krijgt; er wordt nooit een tweede toegevoegd.
# ─── Maps (moeten op http level staan) ───
map $request_method $cors_headers {
OPTIONS 1;
default 0;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ─── Cachebeleid: één plek die beslist of een antwoord gedeeld mag worden ───
#
# Waarom op nginx: Next.js overschrijft `Cache-Control` op dynamische route
# handlers (next/dist/server/send-response.js weigert een al aanwezige header
# te overschrijven) en src/proxy.ts zet die paden bovendien op no-store. Deze
# maps nemen de publieke beslissing daarom expliciet over van de app, zodat
# browser + CDN daadwerkelijk cachen — met één enkele header.
# Nooit als "publiek" aankondigen als er een sessie aan hangt. NextAuth v5
# zet `__Secure-authjs.session-token` (en `authjs.*` zonder prefix); de
# Nitro-client gebruikt een eigen cookie. Elke cookie waarvan de naam op
# session-token eindigt of met authjs. begint telt als "ingelogd", plus elk
# Authorization-header. Zo kan een persoonlijke variant nooit publiek worden.
map $http_cookie $cms_sess_cookie {
default 0;
"~*session-token=" 1;
"~*authjs\." 1;
}
map $http_authorization $cms_authz_header {
default 1;
"" 0;
}
# "1" zodra er ook maar één auth-signaal aanwezig is.
map "$cms_sess_cookie$cms_authz_header" $cms_skip_cache {
default 1;
"~^00$" 0;
}
# Cacheklasse per endpoint. De TTL's komen overeen met wat de app zelf al
# aangeeft (publicCacheControl in src/lib/api.ts) zodat de edge niets
# verscherper maakt dan de applicatie toestaat. Klasse 0 = no-store.
map $uri $cms_cc_class {
default 0;
# online count wordt door elke pagina en de SSE-stream gepolld
~^/api/online(/count)?$ 1;
# snel verouderende, maar publieke lijsten
~^/api/(photos|leaderboard|radio/current-dj|radio/points/leaderboard)$ 2;
# stabiele catalogus- en rosterdata
~^/api/(staff|teams|guilds|shop|values)(/categories|/[0-9]+)?$ 3;
}
# Eén bron van waarheid: klasse + al dan niet ingelogd. De `|`-scheiding is
# nginx' string-samenvoeging; `~^1\|0` leest "klasse 1 en niet ingelogd".
map "$cms_cc_class|$cms_skip_cache" $cms_public_cc {
default "private, no-cache, no-store, max-age=0, must-revalidate";
"~^1\|0" "public, max-age=10, s-maxage=10, stale-while-revalidate=30";
"~^2\|0" "public, max-age=60, s-maxage=60, stale-while-revalidate=180";
"~^3\|0" "public, max-age=300, s-maxage=300, stale-while-revalidate=600";
}
# ─── Mime fix ───
types {
application/json jsonc;
}
# ==========================================
# REDIRECT HTTP -> HTTPS (Poort 9444)
# ==========================================
server {
listen 9444 default_server;
listen [::]:9444 default_server;
server_name _;
location / {
return 301 https://$host$request_uri;
}
}
# ==========================================
# WEBSOCKET GAME SERVER (ws.epicnabbo.nl)
# ==========================================
server {
listen 9443 ssl;
listen [::]:9443 ssl;
server_name ws.epicnabbo.nl;
ssl_certificate /etc/ssl/epicnabbo-backend.pem;
ssl_certificate_key /etc/ssl/epicnabbo-backend.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# ─── Trusted Edge Gate ───
# Real Cloudflare edges and Traefik are the only peers trusted to supply a
# CF-Connecting-IP (see cloudflare-ips.conf). Any other peer that does is
# spoofing and is rejected before it reaches the CMS. Legitimate direct
# visitors (game client, :9443) never carry that header and pass through
# with their real peer address.
if ($cms_disallow_forwarding) {
return 403;
}
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
location / {
proxy_pass http://127.0.0.1:2096;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Echt client IP (trusted peers via real_ip, directe clients = eigen peer)
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
# ==========================================
# MAIN HTTPS SERVER (Poort 9443)
# ==========================================
server {
listen 9443 ssl reuseport default_server;
listen [::]:9443 ssl reuseport default_server;
listen 9443 quic reuseport;
listen [::]:9443 quic reuseport;
http2 on;
server_name epicnabbo.nl www.epicnabbo.nl;
ssl_certificate /etc/ssl/epicnabbo-backend.pem;
ssl_certificate_key /etc/ssl/epicnabbo-backend.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_early_data on;
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
index index.html;
# ─── Security Headers ───
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ─── Trusted Edge Gate ───
# Real Cloudflare edges / Traefik are the only peers allowed to supply a
# CF-Connecting-IP (see cloudflare-ips.conf). Any other peer presenting one
# is spoofing (direct :9443 traffic), and is rejected before it reaches the
# CMS. Legitimate direct visitors never carry that header and pass through
# with their real peer address.
if ($cms_disallow_forwarding) {
return 403;
}
# ─── Client Limits & Timeouts ───
client_max_body_size 20m;
client_body_buffer_size 16k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
client_body_timeout 12s;
client_header_timeout 12s;
keepalive_timeout 30s;
send_timeout 10s;
# Abuse limits. Deliberately NOT set at server scope: a room load and a page
# load are not the same request profile, so each location picks its own zone.
# /gamedata/* has no request limit at all — it is a disk cache, so limiting
# it only cost players their icons. The page routes carry the budget.
limit_conn cms_conn_per_ip 30;
# Traefik health-check route herstellen
location = /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
# ─── Statische Bestanden & Assets ───
location ^~ /client/ {
alias /var/www/Octane/dist/;
try_files $uri $uri/ =404;
limit_req zone=cms_static_per_ip burst=1000 nodelay;
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
add_header Cache-Control "public, max-age=2592000";
access_log off;
add_header Cache-Tag "cms-client";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
}
}
location ^~ /nitro-client/ {
alias /var/www/Octane/dist/;
try_files $uri $uri/ =404;
limit_req zone=cms_static_per_ip burst=1000 nodelay;
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
add_header Cache-Control "public, max-age=2592000";
access_log off;
add_header Cache-Tag "cms-client";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
}
}
location = /gamedata { return 301 /gamedata/config/; }
location = /gamedata/ { return 301 /gamedata/config/; }
# ─── Gamedata: vier cache-klassen, want niet alles onder /gamedata/ is
# even veranderlijk.
#
# Dit pad had één regel voor de hele boom: `max-age=604800` (7 dagen). De
# Habbo-client haalt FurnitureData.json hier op, dus na een import bleef het
# client-side dagenlang de oude versie tonen — een nieuw geïmporteerd
# meubel was gewoon onzichtbaar. De purge van de `cms-gamedata`-tag
# (edge-cache.ts) raakt alleen de Cloudflare-kopie, niet de browser.
#
# 1. config/ — FurnitureData.json + de vertaalde bestanden. Verandert
# bij elke import. Kort, en `must-revalidate` sluit de
# "stuur uit de cache"-route uit zodat de client na de
# TTL een 304 vraagt in plaats van de oude body te hergebruiken.
# 2. bundled/ — nitro-bundles per sprite. De inhoud kan veranderen zonder
# dat de bestandsnaam verandert (schalen, repareren), dus
# ook revalideren, maar minder vaak: ze worden veel vaker
# opgehaald dan ze worden geschreven.
# 3. icons/ — `{classname}_icon.png`. Wordt wél herschreven onder
# dezelfde naam (repair-icons.ts, herimport), dus ook
# klasse 4's "nooit herschreven" geldt hier niet. Wel
# minder vaak dan 2: per uur een must-revalidate is één
# 304 per icon per uur, en een gerepareerd icon is zo
# binnen een uur zichtbaar in plaats van dagenlang oud.
# 4. alles wat overblijft (c_images, album*, clothes, …) — content-addressed
# of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang.
location ^~ /gamedata/config/ {
alias /var/www/Gamedata/config/;
add_header Cache-Control "public, max-age=300, must-revalidate";
access_log off;
add_header Cache-Tag "cms-gamedata";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
error_page 404 = @gamedata_missing;
}
location ^~ /gamedata/bundled/ {
alias /var/www/Gamedata/bundled/;
add_header Cache-Control "public, max-age=3600, must-revalidate";
access_log off;
add_header Cache-Tag "cms-gamedata";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
error_page 404 = @gamedata_missing;
}
location ^~ /gamedata/icons/ {
alias /var/www/Gamedata/icons/;
add_header Cache-Control "public, max-age=3600, must-revalidate";
access_log off;
add_header Cache-Tag "cms-gamedata";
# Geen limit_req: gamedata is schijf-cache, geen CMS-backend. Een
# kamerladen vuurt honderden bestanden in één burst af en elke limiet
# hier leidde alleen tot zichtbaar gemiste icons.
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
error_page 404 = @gamedata_missing;
}
location /gamedata/ {
alias /var/www/Gamedata/;
add_header Cache-Control "public, max-age=604800";
access_log off;
add_header Cache-Tag "cms-gamedata";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
error_page 404 = @gamedata_missing;
}
# Een ONTBREKEND gamedata-bestand mag nooit gecacht worden, en daarom
# krijgt elke 404 hier een eigen handler.
#
# Zonder deze handler stuurde nginx op een 404 helemaal geen Cache-Control:
# `add_header` geldt zonder `always` alleen voor 2xx/3xx. Cloudflare vond
# dan geen expliciete cache-instructie en nam de zone-instelling over:
# "Browser Cache TTL = 1 jaar". Gevolg: de 404 kwam terug als
# `cache-control: max-age=31536000` met `cf-cache-status: HIT` — dus
# vastgezet in de browser van de bezoeker én op de edge. Een icon dat één
# keer te vroeg werd opgevraagd (import nog bezig) bleef daarom het hele
# jaar een 404, ook nadat het bestand er wél stond. Dat was de "sommige
# icons laden wel, sommige niet"-klacht.
#
# `no-store` (niet een korte TTL): het bestand kan elk moment verschijnen,
# dus er is geen enkel venster waarin we een 404 willen vasthouden. De
# Cache-Tag blijft meegegeven zodat een al gecachte 404 alsnog te purgen is
# via `scripts/cf-purge.sh cms-gamedata`.
location @gamedata_missing {
add_header Cache-Control "no-store" always;
add_header Cache-Tag "cms-gamedata" always;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
return 404;
}
location /camera/ {
alias /var/www/Camera/;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Tag "cms-camera";
}
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ─── Static Next.js Assets ───
location /_next/static/ {
proxy_pass http://cms_app;
proxy_set_header Connection "";
proxy_http_version 1.1;
# Enige eigenaar: een enkele immutable header; de app-header wordt
# altijd verwisseld zodat er nooit twee tegensprekende ontstaan
# (ook op 404's).
proxy_hide_header Cache-Control;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /_next/data/ {
proxy_pass http://cms_app;
proxy_set_header Connection "";
proxy_http_version 1.1;
proxy_hide_header Cache-Control;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# ─── API Proxy's ───
location /api/auth/ {
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
# Auth is per sessie: nooit cachen, en de app-header onderdrukken zodat
# er precies één Cache-Control overblijft.
proxy_hide_header Cache-Control;
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
}
# ─── Publieke API: één gedeelde Cache-Control, geen byte-cache ───
#
# nginx is de enige plek die hier cacheverantwoordelijkheid heeft: de app
# zet dit op no-store (Next-force) en Traefik + Cloudflare voegen niets
# toe, dus er is geen tweede laag die met deze header concurreert. De
# body zelf wordt NIET tussen-gecachet (geen proxy_cache_*): stampede-
# bescherming doet src/lib/cache.ts (in-process single-flight + Redis).
# De header zet de TTL voor browser + CDN (10/60/300s + SWR).
location ~ ^/api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ {
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
proxy_hide_header Cache-Control;
add_header Cache-Control $cms_public_cc;
# Cloudflare cache-tag: laat de edge precies deze publieke API's cachen
# (via een cache-rule) en purge alleen deze tag na een CMS-wijziging.
add_header Cache-Tag "cms-public";
}
# ─── SSE / lange streams ───
#
# Drie dingen moeten kloppen of een EventSource-stroom knapt af:
# 1. proxy_buffering off — anders houdt nginx het antwoord vast tot de
# verbinding sluit, dus de browser ziet de stream pas als een blok.
# 2. proxy_read_timeout — de default van 60s beëindigt een stroom die
# tijdens een batch-job even stilvalt, waarna de client reconnectt en
# opnieuw 504 krijgt: een reconnect-loop die de app juist belast.
# 3. send_timeout — de server-level 10s meet de pauze tussen twee writes.
# Een stream die 25s pingt, of een batch die minuten niets doet, wordt
# daar dus losgekapt. Daarom hier een eigen, ruime waarde.
location ~ ^/api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ {
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
proxy_buffering off;
gzip off;
chunked_transfer_encoding on;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
send_timeout 3600s;
proxy_hide_header Cache-Control;
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
# Vrijwel elke SSE-route miste dit; zonder de header blijft nginx
# alsnog bufferen, ook met proxy_buffering off.
add_header X-Accel-Buffering "no" always;
}
location /api/badges/custom {
proxy_pass http://127.0.0.1:2096;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
# De emulator levert zelf geen Cache-Control; zonder proxy_hide_header
# zou de app-header hier een tweede keer worden toegevoegd.
proxy_hide_header Cache-Control;
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
}
# ─── Imaging & media: de app levert de eigen Cache-Control ───
# De catch-all hieronder forceert no-store; avatars en uploads zijn
# onveranderlijk per sleutel en moeten door de browser gecachet worden.
location /api/imaging/ {
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
proxy_read_timeout 30s;
}
location /api/media/ {
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
}
# ─── Hoofd-routering ───
location / {
proxy_pass http://cms_app;
limit_req zone=cms_req_per_ip burst=60 nodelay;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header CF-Connecting-IP "";
proxy_set_header Connection "";
# De HTML is per sessie: `auth()` in de homepage-layout stuurt
# ingelogde bezoekers door naar /me, en de CSP-nonce is per request.
# Dus nooit cachen — maar wel als één enkele, expliciete header.
# Zonder proxy_hide_header voeg je hier een tweede, tegensprekende
# Cache-Control toe aan degene die Next al meestuurt.
proxy_hide_header Cache-Control;
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
}
}