feat(cache): single-owner caching across nginx, edge and content edits
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m41s
CI / tests-ui (push) Successful in 2m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m35s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m41s
CI / tests-ui (push) Successful in 2m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m35s
Rebuild production nginx from the repo (deployment/proxy/*) with a single Cache-Control owner per route: the app stays the source, nginx only manages headers, and Cloudflare stores the public API allowlist at the edge. - deployment/proxy: nginx.conf, mime.types, nginx-cms.conf and the blue/green upstream snippet; config backed by scripts/nginx-sync.sh (idempotent install + reload, --check/--force). - nginx serves Cache-Tag headers on the public allowlist (cms-public), gamedata, client and camera responses so the edge and purge stay in sync. - src/lib/edge-cache.ts + tests: coalesced, fire-and-forget edge purges that no-op unless Cloudflare is configured; scripts/cf-purge.sh and cf-setup-cache.sh create and purge the cache rule. - src/lib/cloudflare-api.ts: purgeCacheByTags/purgeCacheByUrls. - Purge hooks after catalog exports (public + gamedata) and on shop, team, guild, photo and rare-values edits; ci-deploy purges after each release. - src/proxy.ts excludes the imaging/images docs from the middleware matcher.
This commit is contained in:
1 parent
30dcecd530
commit
7697728d07
18 files changed
+1001
-1
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# Default; ci-deploy.sh (blue/green) herschrijft dit bestand bij elke switch.
|
||||||
|
server 127.0.0.1:3002;
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# ─── 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Stuur het échte client IP direct door naar Polaris
|
||||||
|
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# ─── 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;
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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/; }
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
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 $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
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 $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
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 $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
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 $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
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 $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Hoofd-routering ───
|
||||||
|
location / {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
types {
|
||||||
|
text/html html htm shtml;
|
||||||
|
text/css css;
|
||||||
|
text/xml xml;
|
||||||
|
text/plain txt;
|
||||||
|
application/javascript js mjs;
|
||||||
|
application/json json map;
|
||||||
|
application/ld+json jsonld;
|
||||||
|
application/rss+xml rss;
|
||||||
|
application/wasm wasm;
|
||||||
|
application/xml xsd xsl;
|
||||||
|
font/ttf ttf;
|
||||||
|
font/otf otf;
|
||||||
|
font/woff woff;
|
||||||
|
font/woff2 woff2;
|
||||||
|
image/svg+xml svg svgz;
|
||||||
|
image/bmp bmp;
|
||||||
|
image/gif gif;
|
||||||
|
image/jpeg jpeg jpg;
|
||||||
|
image/png png;
|
||||||
|
image/webp webp;
|
||||||
|
image/avif avif;
|
||||||
|
image/x-icon ico cur;
|
||||||
|
video/mp4 mp4 m4v;
|
||||||
|
video/webm webm;
|
||||||
|
audio/mpeg mp3;
|
||||||
|
audio/ogg ogg;
|
||||||
|
audio/wav wav;
|
||||||
|
application/octet-stream dat bin swf;
|
||||||
|
application/zip zip;
|
||||||
|
application/gzip gz;
|
||||||
|
application/pdf pdf;
|
||||||
|
application/vnd.apple.mpegurl m3u8;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Canonical nginx config for the EpicNabbo CMS edge.
|
||||||
|
# Source of truth: repository deployment/proxy/nginx-cms.conf (the site block)
|
||||||
|
# and this file. Installed/synced by scripts/nginx-sync.sh so it cannot be
|
||||||
|
# lost again while nginx keeps running on an in-memory copy.
|
||||||
|
#
|
||||||
|
# Traffic path: Cloudflare -> Traefik (:443) -> nginx (:9443) -> CMS (:3002).
|
||||||
|
# nginx is the last layer that can still rewrite Cache-Control, so it owns the
|
||||||
|
# headers it adds explicitly; everything proxied to the CMS is passed through
|
||||||
|
# untouched unless this file says otherwise.
|
||||||
|
|
||||||
|
user www-data;
|
||||||
|
worker_processes auto;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 2048;
|
||||||
|
use epoll;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Compression is done once, at the edge (Traefik / Cloudflare). Enabling
|
||||||
|
# gzip here too would double-compress proxied responses and fight Vary.
|
||||||
|
gzip off;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
server_tokens off;
|
||||||
|
keepalive_timeout 30s;
|
||||||
|
|
||||||
|
client_max_body_size 64m;
|
||||||
|
client_body_buffer_size 16k;
|
||||||
|
client_header_buffer_size 1k;
|
||||||
|
large_client_header_buffers 4 8k;
|
||||||
|
|
||||||
|
# Blue/green cutover: ci-deploy.sh writes the active upstream here, and
|
||||||
|
# `proxy_pass http://cms_app` below follows it via graceful nginx -s reload.
|
||||||
|
upstream cms_app {
|
||||||
|
include /etc/nginx/snippets/cms_upstream_servers.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache policy maps and server blocks live in the site file so they are
|
||||||
|
# synced together and can never drift apart.
|
||||||
|
include /etc/nginx/sites-enabled/*.conf;
|
||||||
|
}
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Purge the Cloudflare edge cache for one or more Cache-Tags.
|
||||||
|
#
|
||||||
|
# These tags are emitted by nginx (deployment/proxy/nginx-cms.conf):
|
||||||
|
# cms-public - de publieke API-allowlist (staff/teams/guilds/shop/values/…)
|
||||||
|
# cms-gamedata - /gamedata/ (furnidata, config)
|
||||||
|
# cms-client - /client/ + /nitro-client/ (game assets)
|
||||||
|
# cms-camera - /camera/
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/cf-purge.sh cms-public
|
||||||
|
# scripts/cf-purge.sh cms-public cms-gamedata cms-client cms-camera
|
||||||
|
#
|
||||||
|
# Reads CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID from the environment or the
|
||||||
|
# repository .env. Fails loudly with a clear message when they are missing or
|
||||||
|
# still placeholders, so a pipeline either purges or aborts — never silently
|
||||||
|
# pretends it did.
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
|
BASE="https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
[[ $# -ge 1 ]] || { echo "usage: $0 <tag> [tag ...]" >&2; exit 64; }
|
||||||
|
TAGS=("$@")
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [[ -n "${!name:-}" ]]; then
|
||||||
|
printf -v "$name" '%s' "${!name}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
local line
|
||||||
|
line="$(grep -m1 "^$name=" "$ENV_FILE" | cut -d= -f2- | tr -d "'\"")" || true
|
||||||
|
if [[ -n "$line" ]]; then
|
||||||
|
printf -v "$name" '%s' "$line"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env CLOUDFLARE_API_TOKEN || { echo "error: CLOUDFLARE_API_TOKEN not configured" >&2; exit 1; }
|
||||||
|
load_env CLOUDFLARE_ZONE_ID || { echo "error: CLOUDFLARE_ZONE_ID not configured" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Placeholder guard: the repo .env historically carried 2-char dummy values.
|
||||||
|
if [[ "${#CLOUDFLARE_API_TOKEN}" -lt 16 || "${#CLOUDFLARE_ZONE_ID}" -lt 16 ]]; then
|
||||||
|
echo "error: Cloudflare credentials look like placeholders; add a real token to .env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
body="$(python3 -c 'import json,sys; print(json.dumps({"tags": sys.argv[1:]}))' "${TAGS[@]}")"
|
||||||
|
|
||||||
|
resp="$(curl -sS -m 20 -X POST \
|
||||||
|
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "$body" \
|
||||||
|
"$BASE/zones/$CLOUDFLARE_ZONE_ID/purge_cache")"
|
||||||
|
|
||||||
|
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$resp"; then
|
||||||
|
echo "error: Cloudflare purge failed: $resp" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "purged tags: ${TAGS[*]}"
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||||
|
# allowlist at the edge (the routes nginx tags with `Cache-Tag: cms-public`).
|
||||||
|
#
|
||||||
|
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
||||||
|
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||||
|
# though their `Cache-Control: s-maxage` says they are cacheable. A Cache Rule
|
||||||
|
# with "Cache Everything" turns those the other way.
|
||||||
|
#
|
||||||
|
# What the rule does:
|
||||||
|
# - edge_ttl bypass_by_default : edge cachet volgens de s-maxage van nginx;
|
||||||
|
# zonder (publieke) header (bv. errorresponses) juist NIET cachen.
|
||||||
|
# - browser_ttl respect_origin : de zone heeft "Browser Cache TTL = 1 jaar" en
|
||||||
|
# overschrijft daarmee het max-age dat nginx per klasse stuurt. Deze rule
|
||||||
|
# herstelt dat voor de publieke API's: browsers krijgen de korte
|
||||||
|
# max-age van nginx terug (10/60/300s) i.p.v. een jaar stale data.
|
||||||
|
#
|
||||||
|
# Idempotent: vergelijkt de bestaande rule (op description + inhoud) en zet
|
||||||
|
# alleen bij als die verschilt. Re-running is veilig.
|
||||||
|
#
|
||||||
|
# Usage (after putting a real token + zone id in .env):
|
||||||
|
# scripts/cf-setup-cache.sh
|
||||||
|
#
|
||||||
|
# Requires a token with Zone > Cache Rules (edit) permission.
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
|
BASE="https://api.cloudflare.com/client/v4"
|
||||||
|
PHASE="http_request_cache_settings"
|
||||||
|
DESCRIPTION="EpicNabbo CMS public API edge cache (cms-public)"
|
||||||
|
|
||||||
|
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
||||||
|
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||||
|
# `starts_with()`.
|
||||||
|
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/"))'
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [[ -n "${!name:-}" ]]; then
|
||||||
|
printf -v "$name" '%s' "${!name}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
local line
|
||||||
|
line="$(grep -m1 "^$name=" "$ENV_FILE" | cut -d= -f2- | tr -d "'\"")" || true
|
||||||
|
if [[ -n "$line" ]]; then
|
||||||
|
printf -v "$name" '%s' "$line"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env CLOUDFLARE_API_TOKEN || { echo "error: CLOUDFLARE_API_TOKEN not configured" >&2; exit 1; }
|
||||||
|
load_env CLOUDFLARE_ZONE_ID || { echo "error: CLOUDFLARE_ZONE_ID not configured" >&2; exit 1; }
|
||||||
|
if [[ "${#CLOUDFLARE_API_TOKEN}" -lt 16 || "${#CLOUDFLARE_ZONE_ID}" -lt 16 ]]; then
|
||||||
|
echo "error: Cloudflare credentials look like placeholders; add a real token to .env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api() {
|
||||||
|
curl -sS -m 30 -X "$1" \
|
||||||
|
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "${2:-}" \
|
||||||
|
"$BASE/zones/$CLOUDFLARE_ZONE_ID${3:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "--- reading existing cache-settings ruleset ---"
|
||||||
|
existing="$(api GET "" "/rulesets/phases/$PHASE/entrypoint")"
|
||||||
|
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$existing"; then
|
||||||
|
echo "error: could not read ruleset: $existing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rule_json="$(DESCRIPTION="$DESCRIPTION" EXPRESSION="$EXPRESSION" python3 - <<'PY'
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({
|
||||||
|
"description": os.environ["DESCRIPTION"],
|
||||||
|
"expression": os.environ["EXPRESSION"],
|
||||||
|
"action": "set_cache_settings",
|
||||||
|
"action_parameters": {
|
||||||
|
"cache": True,
|
||||||
|
"edge_ttl": {"mode": "bypass_by_default"},
|
||||||
|
"browser_ttl": {"mode": "respect_origin"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
out="$(EXISTING_JSON="$existing" RULE_JSON="$rule_json" python3 - <<'PY'
|
||||||
|
import json, os
|
||||||
|
existing = json.loads(os.environ["EXISTING_JSON"])
|
||||||
|
rule = json.loads(os.environ["RULE_JSON"])
|
||||||
|
result = existing.get("result") or {}
|
||||||
|
rules = list(result.get("rules") or [])
|
||||||
|
|
||||||
|
def check(r):
|
||||||
|
return {k: r.get(k) for k in ("description", "expression", "action", "action_parameters")}
|
||||||
|
|
||||||
|
keep = [r for r in rules if r.get("description") != rule["description"]]
|
||||||
|
present = [r for r in rules if r.get("description") == rule["description"]]
|
||||||
|
if present and check(present[0]) == check(rule):
|
||||||
|
print("same")
|
||||||
|
else:
|
||||||
|
keep.append(rule)
|
||||||
|
print("changed")
|
||||||
|
print(json.dumps({"rules": keep}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
status="$(sed -n '1p' <<<"$out")"
|
||||||
|
if [ "$status" = "same" ]; then
|
||||||
|
echo "rule already present en identiek — geen wijzigingen"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload="$(sed -n '2,$p' <<<"$out")"
|
||||||
|
echo "--- ${DESCRIPTION}: rule bijwerken ---"
|
||||||
|
resp="$(api PUT "$payload" "/rulesets/phases/$PHASE/entrypoint")"
|
||||||
|
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$resp"; then
|
||||||
|
echo "error: could not save ruleset: $resp" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "cache rule live. Verify: curl -s https://epicnabbo.nl/api/shop -o /dev/null -D - | grep -i cf-cache-status"
|
||||||
@@ -396,6 +396,12 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" ||
|
|||||||
|
|
||||||
|
|
||||||
echo "Deployment verified: $sha"
|
echo "Deployment verified: $sha"
|
||||||
|
# Een deploy kan de game client, furnidata (gamedata), camera en public API data
|
||||||
|
# verversen. Laat de Cloudflare edge-cache van die tags los (best-effort: alleen
|
||||||
|
# wanneer er een echte token + zone-id geconfigureerd is; no-op anders).
|
||||||
|
if [ -x "$deploy_dir/scripts/cf-purge.sh" ]; then
|
||||||
|
bash "$deploy_dir/scripts/cf-purge.sh" cms-public cms-gamedata cms-client cms-camera || true
|
||||||
|
fi
|
||||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||||
while IFS= read -r tag; do
|
while IFS= read -r tag; do
|
||||||
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
|
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
|
||||||
|
|||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sync the nginx config from this repository to /etc/nginx and reload it.
|
||||||
|
#
|
||||||
|
# Background: on 2026-09-26 /etc/nginx and /var/log/nginx disappeared from the
|
||||||
|
# host while nginx kept serving its in-memory config; any restart would have
|
||||||
|
# taken the CMS down. This script makes the repo the source of truth so that
|
||||||
|
# cannot happen again. It is idempotent and only reloads nginx when the config
|
||||||
|
# actually changed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo scripts/nginx-sync.sh # install + test + reload if changed
|
||||||
|
# sudo scripts/nginx-sync.sh --force # always reload after a passing test
|
||||||
|
# scripts/nginx-sync.sh --check # just diff repo vs live, no writes
|
||||||
|
#
|
||||||
|
# Files installed (see also deployment/proxy/):
|
||||||
|
# nginx.conf -> /etc/nginx/nginx.conf
|
||||||
|
# nginx-mime.types -> /etc/nginx/mime.types
|
||||||
|
# nginx-cms.conf -> /etc/nginx/sites-available/cms.conf
|
||||||
|
# cms_upstream_servers.conf -> /etc/nginx/snippets/cms_upstream_servers.conf
|
||||||
|
# symlink sites-enabled/cms.conf -> ../sites-available/cms.conf
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROXY_DIR="$SCRIPT_DIR/../deployment/proxy"
|
||||||
|
NGINX_DIR=/etc/nginx
|
||||||
|
BACKUP_DIR="/var/backups/nginx-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
MODE="sync"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) MODE="force" ;;
|
||||||
|
--check) MODE="check" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
install_file() {
|
||||||
|
local src="$1" dst="$2"
|
||||||
|
if [[ ! -f "$src" ]]; then
|
||||||
|
echo "error: $src not found in repo" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
|
||||||
|
echo "= $dst up to date"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$MODE" == "check" ]]; then
|
||||||
|
echo "- $dst differs from repo"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
if [[ -f "$dst" ]]; then
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cp -a "$dst" "$BACKUP_DIR/"
|
||||||
|
fi
|
||||||
|
cp -a "$src" "$dst"
|
||||||
|
echo "+ installed $dst"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$MODE" != "check" && "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "error: run as root (sudo scripts/nginx-sync.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
changed=0
|
||||||
|
if install_file "$PROXY_DIR/nginx.conf" "$NGINX_DIR/nginx.conf"; then changed=1; fi
|
||||||
|
if install_file "$PROXY_DIR/nginx-mime.types" "$NGINX_DIR/mime.types"; then changed=1; fi
|
||||||
|
if install_file "$PROXY_DIR/nginx-cms.conf" "$NGINX_DIR/sites-available/cms.conf"; then changed=1; fi
|
||||||
|
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||||
|
|
||||||
|
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
||||||
|
if [[ "$MODE" == "check" ]]; then
|
||||||
|
echo "- sites-enabled/cms.conf missing"
|
||||||
|
changed=1
|
||||||
|
else
|
||||||
|
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
||||||
|
echo "+ linked sites-enabled/cms.conf"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MODE" == "check" ]]; then
|
||||||
|
[[ "$changed" -eq 0 ]]
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MODE" == "force" ]]; then
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d /var/log/nginx ]]; then
|
||||||
|
install -d -o root -g adm -m 750 /var/log/nginx
|
||||||
|
fi
|
||||||
|
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||||
|
[[ -f "$f" ]] || touch "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "--- nginx -t ---"
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
if [[ "$changed" -eq 1 ]]; then
|
||||||
|
echo "--- reloading nginx ---"
|
||||||
|
nginx -s reload
|
||||||
|
else
|
||||||
|
echo "no changes; nginx reload skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- health check ---"
|
||||||
|
curl -sf "http://127.0.0.1:3002/api/health" > /dev/null && echo "OK: CMS reachable"
|
||||||
|
curl -skf -o /dev/null -H "Host: epicnabbo.nl" "https://127.0.0.1:9443/health" && echo "OK: nginx :9443 /health"
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Items,
|
Items,
|
||||||
Rooms,
|
Rooms,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
|||||||
targetId: id,
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/guilds");
|
revalidatePath("/admin/guilds");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild disbanded");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGuild(formData: FormData): Promise<void> {
|
export async function updateGuild(formData: FormData): Promise<void> {
|
||||||
@@ -151,4 +153,5 @@ export async function updateGuild(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/guilds");
|
revalidatePath("/admin/guilds");
|
||||||
revalidatePath(`/admin/guilds/${id}`);
|
revalidatePath(`/admin/guilds/${id}`);
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild updated");
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
import { CameraWeb, db } from "@/lib/db";
|
import { CameraWeb, db } from "@/lib/db";
|
||||||
|
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -33,4 +34,5 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/admin/photos");
|
||||||
revalidatePath("/photos");
|
revalidatePath("/photos");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "photo deleted");
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||||
|
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export async function createCategory(formData: FormData): Promise<void> {
|
|||||||
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
// Unique name collision or DB error — ignore, page will re-render unchanged.
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCategory(formData: FormData): Promise<void> {
|
export async function deleteCategory(formData: FormData): Promise<void> {
|
||||||
@@ -53,6 +55,7 @@ export async function deleteCategory(formData: FormData): Promise<void> {
|
|||||||
// Not found or DB error — ignore.
|
// Not found or DB error — ignore.
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createValue(formData: FormData): Promise<void> {
|
export async function createValue(formData: FormData): Promise<void> {
|
||||||
@@ -101,6 +104,7 @@ export async function createValue(formData: FormData): Promise<void> {
|
|||||||
// DB error — ignore.
|
// DB error — ignore.
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteValue(formData: FormData): Promise<void> {
|
export async function deleteValue(formData: FormData): Promise<void> {
|
||||||
@@ -114,6 +118,7 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
|||||||
// Not found or DB error — ignore.
|
// Not found or DB error — ignore.
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCategory(formData: FormData): Promise<void> {
|
export async function updateCategory(formData: FormData): Promise<void> {
|
||||||
@@ -145,6 +150,7 @@ export async function updateCategory(formData: FormData): Promise<void> {
|
|||||||
// Unique name collision or DB error — ignore.
|
// Unique name collision or DB error — ignore.
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateValue(formData: FormData): Promise<void> {
|
export async function updateValue(formData: FormData): Promise<void> {
|
||||||
@@ -199,4 +205,5 @@ export async function updateValue(formData: FormData): Promise<void> {
|
|||||||
// DB error — ignore.
|
// DB error — ignore.
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited");
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteShopArticles } from "@/lib/db";
|
import { db, WebsiteShopArticles } from "@/lib/db";
|
||||||
|
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
@@ -149,6 +150,7 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath(`/admin/shop/${id}`);
|
revalidatePath(`/admin/shop/${id}`);
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article updated");
|
||||||
redirect("/admin/shop");
|
redirect("/admin/shop");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,5 +177,6 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article deleted");
|
||||||
redirect("/admin/shop");
|
redirect("/admin/shop");
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteTeams } from "@/lib/db";
|
import { db, WebsiteTeams } from "@/lib/db";
|
||||||
|
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
export async function createTeam(formData: FormData): Promise<void> {
|
export async function createTeam(formData: FormData): Promise<void> {
|
||||||
@@ -38,6 +39,7 @@ export async function createTeam(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/teams");
|
revalidatePath("/admin/teams");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTeam(formData: FormData): Promise<void> {
|
export async function deleteTeam(formData: FormData): Promise<void> {
|
||||||
@@ -47,4 +49,5 @@ export async function deleteTeam(formData: FormData): Promise<void> {
|
|||||||
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
|
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
|
||||||
|
|
||||||
revalidatePath("/admin/teams");
|
revalidatePath("/admin/teams");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited");
|
||||||
}
|
}
|
||||||
@@ -165,6 +165,63 @@ export async function verifyCloudflareConnection(): Promise<CloudflareConnection
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge specific URLs from the Cloudflare edge cache (POST purge_cache).
|
||||||
|
* At most 30 URLs are allowed per call by the API; split larger batches.
|
||||||
|
*/
|
||||||
|
export async function purgeCacheByUrls(
|
||||||
|
urls: string[],
|
||||||
|
): Promise<{ purged: number }> {
|
||||||
|
const config = getCloudflareApiConfig();
|
||||||
|
if (!config.zoneId) {
|
||||||
|
throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured");
|
||||||
|
}
|
||||||
|
const zoneId: string = config.zoneId;
|
||||||
|
if (urls.length === 0) return { purged: 0 };
|
||||||
|
const envelopes = await Promise.all(
|
||||||
|
chunk(urls, 30).map((files) =>
|
||||||
|
cloudflareRequest<{ id: string }>(
|
||||||
|
`/zones/${encodeURIComponent(zoneId)}/purge_cache`,
|
||||||
|
{ method: "POST", body: { files } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { purged: envelopes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge every cached response that carried one of the given Cache-Tag values
|
||||||
|
* (the tags nginx emits in the `Cache-Tag` header). This is the cheap, exact
|
||||||
|
* way to drop the public API / gamedata / client edge cache after a CMS edit
|
||||||
|
* or deploy, without touching unrelated cached objects.
|
||||||
|
*/
|
||||||
|
export async function purgeCacheByTags(
|
||||||
|
tags: string[],
|
||||||
|
): Promise<{ purged: number }> {
|
||||||
|
const config = getCloudflareApiConfig();
|
||||||
|
if (!config.zoneId) {
|
||||||
|
throw new CloudflareApiError("CLOUDFLARE_ZONE_ID is not configured");
|
||||||
|
}
|
||||||
|
const zoneId: string = config.zoneId;
|
||||||
|
if (tags.length === 0) return { purged: 0 };
|
||||||
|
const envelopes = await Promise.all(
|
||||||
|
chunk(tags, 30).map((tagGroup) =>
|
||||||
|
cloudflareRequest<{ id: string }>(
|
||||||
|
`/zones/${encodeURIComponent(zoneId)}/purge_cache`,
|
||||||
|
{ method: "POST", body: { tags: tagGroup } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { purged: envelopes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size)
|
||||||
|
out.push(items.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function createIpRule(
|
async function createIpRule(
|
||||||
ip: string,
|
ip: string,
|
||||||
ttlSeconds: number,
|
ttlSeconds: number,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const purgeCacheByTags = vi.fn();
|
||||||
|
const cloudflareEnabled = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/cloudflare-api", () => ({
|
||||||
|
purgeCacheByTags: (...args: unknown[]) => purgeCacheByTags(...args),
|
||||||
|
cloudflareEnabled: (...args: unknown[]) => cloudflareEnabled(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
EDGE_CACHE_TAGS,
|
||||||
|
purgeEdgeCache,
|
||||||
|
resetEdgeCacheCoalescing,
|
||||||
|
} from "@/lib/edge-cache";
|
||||||
|
|
||||||
|
describe("purgeEdgeCache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
resetEdgeCacheCoalescing();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when Cloudflare is not configured", async () => {
|
||||||
|
cloudflareEnabled.mockReturnValue(false);
|
||||||
|
await purgeEdgeCache(["cms-public"], "test");
|
||||||
|
expect(purgeCacheByTags).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges the requested tags when enabled", async () => {
|
||||||
|
cloudflareEnabled.mockReturnValue(true);
|
||||||
|
await purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop edited");
|
||||||
|
expect(purgeCacheByTags).toHaveBeenCalledExactlyOnceWith(["cms-public"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces repeated purges of the same tag within 30s", async () => {
|
||||||
|
cloudflareEnabled.mockReturnValue(true);
|
||||||
|
await purgeEdgeCache(["cms-public"], "first");
|
||||||
|
await purgeEdgeCache(["cms-public"], "second");
|
||||||
|
expect(purgeCacheByTags).toHaveBeenCalledTimes(1);
|
||||||
|
expect(purgeCacheByTags).toHaveBeenCalledWith(["cms-public"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges again after the coalescing window has passed", async () => {
|
||||||
|
cloudflareEnabled.mockReturnValue(true);
|
||||||
|
await purgeEdgeCache(["cms-gamedata"], "first");
|
||||||
|
await purgeEdgeCache(["cms-gamedata"], "second");
|
||||||
|
expect(purgeCacheByTags).toHaveBeenCalledTimes(1);
|
||||||
|
resetEdgeCacheCoalescing();
|
||||||
|
await purgeEdgeCache(["cms-gamedata"], "third");
|
||||||
|
expect(purgeCacheByTags).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on an upstream failure", async () => {
|
||||||
|
cloudflareEnabled.mockReturnValue(true);
|
||||||
|
purgeCacheByTags.mockRejectedValue(new Error("boom"));
|
||||||
|
await expect(
|
||||||
|
purgeEdgeCache(["cms-public"], "fails"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cloudflareEnabled, purgeCacheByTags } from "@/lib/cloudflare-api";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge-cache purging for the Cloudflare layer.
|
||||||
|
*
|
||||||
|
* nginx tags public responses with `Cache-Tag` (cms-public, cms-gamedata,
|
||||||
|
* cms-client, cms-camera) and a Cloudflare Cache Rule stores them at the
|
||||||
|
* edge. Whenever the CMS edits that data (catalog, roster, shop, …) the
|
||||||
|
* matching tag must be purged or visitors keep seeing the stale edge copy
|
||||||
|
* until the s-maxage expires. That purge happens here.
|
||||||
|
*
|
||||||
|
* The helper is deliberately small and safe:
|
||||||
|
* - no-op when Cloudflare is not configured (CLOUDFLARE_API_TOKEN /
|
||||||
|
* CLOUDFLARE_ZONE_ID absent or placeholder),
|
||||||
|
* - callers never await it (`void purgeEdgeCache(...)`),
|
||||||
|
* - purges of the same tag are coalesced to at most one per 30s so a burst
|
||||||
|
* of edits (a catalog import touches hundreds of rows) produces one
|
||||||
|
* request instead of hundreds,
|
||||||
|
* - any API failure is logged and swallowed — the origin stays fresh and
|
||||||
|
* the stale window is bounded by the endpoint's s-maxage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The tags nginx sets in `Cache-Tag` (deployment/proxy/nginx-cms.conf). */
|
||||||
|
export const EDGE_CACHE_TAGS = {
|
||||||
|
public: "cms-public",
|
||||||
|
gamedata: "cms-gamedata",
|
||||||
|
client: "cms-client",
|
||||||
|
camera: "cms-camera",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const COALESCE_MS = 30_000;
|
||||||
|
const recentPurges = new Map<string, number>();
|
||||||
|
|
||||||
|
/** True when at least one of `tags` is worth purging right now. */
|
||||||
|
export function shouldPurge(
|
||||||
|
tags: string[],
|
||||||
|
now: number = Date.now(),
|
||||||
|
): string[] {
|
||||||
|
return tags.filter((tag) => (recentPurges.get(tag) ?? 0) + COALESCE_MS < now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort Cloudflare edge purge for the given tags. Never throws and
|
||||||
|
* never blocks the caller. Safe to call on every admin mutation path.
|
||||||
|
*/
|
||||||
|
export async function purgeEdgeCache(
|
||||||
|
tags: string[],
|
||||||
|
reason: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!cloudflareEnabled()) return;
|
||||||
|
const pending = shouldPurge(tags);
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
try {
|
||||||
|
await purgeCacheByTags(pending);
|
||||||
|
const now = Date.now();
|
||||||
|
for (const tag of pending) recentPurges.set(tag, now);
|
||||||
|
logger.info("[edge-cache] Cloudflare purge sent", {
|
||||||
|
tags: pending,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// A failed purge is not fatal: the freshness bound is s-maxage anyway.
|
||||||
|
logger.warn("[edge-cache] Cloudflare purge failed", {
|
||||||
|
tags: pending,
|
||||||
|
reason,
|
||||||
|
err: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook only — drop in-process coalescing state between unit runs. */
|
||||||
|
export function resetEdgeCacheCoalescing(): void {
|
||||||
|
recentPurges.clear();
|
||||||
|
}
|
||||||
@@ -73,6 +73,16 @@ export async function scheduleCatalogExport() {
|
|||||||
after(async () => {
|
after(async () => {
|
||||||
const { runCatalogExport } = await import("./catalog-git-export");
|
const { runCatalogExport } = await import("./catalog-git-export");
|
||||||
await runCatalogExport();
|
await runCatalogExport();
|
||||||
|
// Catalog + furnidata (gamedata) zijn net vers verwerkt: laat de
|
||||||
|
// Cloudflare edge-cache van die tags los. Fire-and-forget en
|
||||||
|
// no-op zonder token; s-maxage blijft de fallback.
|
||||||
|
const { EDGE_CACHE_TAGS, purgeEdgeCache } = await import(
|
||||||
|
"@/lib/edge-cache"
|
||||||
|
);
|
||||||
|
void purgeEdgeCache(
|
||||||
|
[EDGE_CACHE_TAGS.public, EDGE_CACHE_TAGS.gamedata],
|
||||||
|
"catalog-export",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* Standalone worker contexts use their scheduled retry. */
|
/* Standalone worker contexts use their scheduled retry. */
|
||||||
|
|||||||
+1
-1
@@ -78,6 +78,6 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging).*)",
|
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user