# ─── 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; } }