From 7697728d07f3b7a3a272301417175fc1666df8c0 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 28 Sep 2026 21:55:18 +0200 Subject: [PATCH] feat(cache): single-owner caching across nginx, edge and content edits 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. --- deployment/proxy/cms_upstream_servers.conf | 2 + deployment/proxy/nginx-cms.conf | 388 +++++++++++++++++++++ deployment/proxy/nginx-mime.types | 34 ++ deployment/proxy/nginx.conf | 49 +++ scripts/cf-purge.sh | 64 ++++ scripts/cf-setup-cache.sh | 125 +++++++ scripts/ci-deploy.sh | 6 + scripts/nginx-sync.sh | 110 ++++++ src/actions/admin-guilds.ts | 3 + src/actions/admin-photos.ts | 2 + src/actions/admin-rare-values.ts | 7 + src/actions/admin-shop.ts | 3 + src/actions/admin-teams.ts | 3 + src/lib/cloudflare-api.ts | 57 +++ src/lib/edge-cache.test.ts | 60 ++++ src/lib/edge-cache.ts | 77 ++++ src/lib/services/catalog-git-queue.ts | 10 + src/proxy.ts | 2 +- 18 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 deployment/proxy/cms_upstream_servers.conf create mode 100644 deployment/proxy/nginx-cms.conf create mode 100644 deployment/proxy/nginx-mime.types create mode 100644 deployment/proxy/nginx.conf create mode 100755 scripts/cf-purge.sh create mode 100755 scripts/cf-setup-cache.sh create mode 100755 scripts/nginx-sync.sh create mode 100644 src/lib/edge-cache.test.ts create mode 100644 src/lib/edge-cache.ts diff --git a/deployment/proxy/cms_upstream_servers.conf b/deployment/proxy/cms_upstream_servers.conf new file mode 100644 index 00000000..786b1856 --- /dev/null +++ b/deployment/proxy/cms_upstream_servers.conf @@ -0,0 +1,2 @@ +# Default; ci-deploy.sh (blue/green) herschrijft dit bestand bij elke switch. +server 127.0.0.1:3002; \ No newline at end of file diff --git a/deployment/proxy/nginx-cms.conf b/deployment/proxy/nginx-cms.conf new file mode 100644 index 00000000..638e2596 --- /dev/null +++ b/deployment/proxy/nginx-cms.conf @@ -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; + } +} \ No newline at end of file diff --git a/deployment/proxy/nginx-mime.types b/deployment/proxy/nginx-mime.types new file mode 100644 index 00000000..acad08f9 --- /dev/null +++ b/deployment/proxy/nginx-mime.types @@ -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; +} \ No newline at end of file diff --git a/deployment/proxy/nginx.conf b/deployment/proxy/nginx.conf new file mode 100644 index 00000000..cea267e4 --- /dev/null +++ b/deployment/proxy/nginx.conf @@ -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; +} \ No newline at end of file diff --git a/scripts/cf-purge.sh b/scripts/cf-purge.sh new file mode 100755 index 00000000..d86751d6 --- /dev/null +++ b/scripts/cf-purge.sh @@ -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 ...]" >&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[*]}" \ No newline at end of file diff --git a/scripts/cf-setup-cache.sh b/scripts/cf-setup-cache.sh new file mode 100755 index 00000000..f35f056a --- /dev/null +++ b/scripts/cf-setup-cache.sh @@ -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" \ No newline at end of file diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index f96a97be..1c06b299 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -396,6 +396,12 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || 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. while IFS= read -r tag; do if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then diff --git a/scripts/nginx-sync.sh b/scripts/nginx-sync.sh new file mode 100755 index 00000000..16e68ce2 --- /dev/null +++ b/scripts/nginx-sync.sh @@ -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" \ No newline at end of file diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 5d4e4288..bd979d36 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -13,6 +13,7 @@ import { Items, Rooms, } from "@/lib/db"; +import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache"; import { PERMS } from "@/lib/permissions"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -89,6 +90,7 @@ export async function disbandGuild(formData: FormData): Promise { targetId: id, }); revalidatePath("/admin/guilds"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild disbanded"); } export async function updateGuild(formData: FormData): Promise { @@ -151,4 +153,5 @@ export async function updateGuild(formData: FormData): Promise { revalidatePath("/admin/guilds"); revalidatePath(`/admin/guilds/${id}`); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild updated"); } diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts index 60a9161b..955cd4f6 100644 --- a/src/actions/admin-photos.ts +++ b/src/actions/admin-photos.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; import { CameraWeb, db } from "@/lib/db"; +import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache"; import { PERMS } from "@/lib/permissions"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -33,4 +34,5 @@ export async function deletePhoto(formData: FormData): Promise { revalidatePath("/admin/photos"); revalidatePath("/photos"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "photo deleted"); } diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index 9dff2ca4..d319b47a 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; +import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache"; import { formPositiveBigInt } from "@/lib/form-data"; import { PERMS } from "@/lib/permissions"; @@ -34,6 +35,7 @@ export async function createCategory(formData: FormData): Promise { // Unique name collision or DB error — ignore, page will re-render unchanged. } revalidatePath("/admin/rare-values"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); } export async function deleteCategory(formData: FormData): Promise { @@ -53,6 +55,7 @@ export async function deleteCategory(formData: FormData): Promise { // Not found or DB error — ignore. } revalidatePath("/admin/rare-values"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); } export async function createValue(formData: FormData): Promise { @@ -101,6 +104,7 @@ export async function createValue(formData: FormData): Promise { // DB error — ignore. } revalidatePath("/admin/rare-values"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); } export async function deleteValue(formData: FormData): Promise { @@ -114,6 +118,7 @@ export async function deleteValue(formData: FormData): Promise { // Not found or DB error — ignore. } revalidatePath("/admin/rare-values"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); } export async function updateCategory(formData: FormData): Promise { @@ -145,6 +150,7 @@ export async function updateCategory(formData: FormData): Promise { // Unique name collision or DB error — ignore. } revalidatePath("/admin/rare-values"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); } export async function updateValue(formData: FormData): Promise { @@ -199,4 +205,5 @@ export async function updateValue(formData: FormData): Promise { // DB error — ignore. } revalidatePath("/admin/rare-values"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); } diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts index 4125c2ac..fc376e87 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteShopArticles } from "@/lib/db"; +import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache"; import { formPositiveBigInt } from "@/lib/form-data"; import { PERMS } from "@/lib/permissions"; import { logServerError } from "@/lib/server-log"; @@ -149,6 +150,7 @@ export async function updateShopArticle(formData: FormData): Promise { } revalidatePath(`/admin/shop/${id}`); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article updated"); redirect("/admin/shop"); } @@ -175,5 +177,6 @@ export async function deleteShopArticle(formData: FormData): Promise { return; } + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "shop article deleted"); redirect("/admin/shop"); } diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts index 5d4a2469..d96f1646 100644 --- a/src/actions/admin-teams.ts +++ b/src/actions/admin-teams.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteTeams } from "@/lib/db"; +import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache"; import { PERMS } from "@/lib/permissions"; export async function createTeam(formData: FormData): Promise { @@ -38,6 +39,7 @@ export async function createTeam(formData: FormData): Promise { }); revalidatePath("/admin/teams"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited"); } export async function deleteTeam(formData: FormData): Promise { @@ -47,4 +49,5 @@ export async function deleteTeam(formData: FormData): Promise { await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id)); revalidatePath("/admin/teams"); + void purgeEdgeCache([EDGE_CACHE_TAGS.public], "team edited"); } diff --git a/src/lib/cloudflare-api.ts b/src/lib/cloudflare-api.ts index 82d5a31d..e3eeb7bc 100644 --- a/src/lib/cloudflare-api.ts +++ b/src/lib/cloudflare-api.ts @@ -165,6 +165,63 @@ export async function verifyCloudflareConnection(): Promise { + 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(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( ip: string, ttlSeconds: number, diff --git a/src/lib/edge-cache.test.ts b/src/lib/edge-cache.test.ts new file mode 100644 index 00000000..ca9ca61f --- /dev/null +++ b/src/lib/edge-cache.test.ts @@ -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(); + }); +}); diff --git a/src/lib/edge-cache.ts b/src/lib/edge-cache.ts new file mode 100644 index 00000000..702782fb --- /dev/null +++ b/src/lib/edge-cache.ts @@ -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(); + +/** 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 { + 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(); +} diff --git a/src/lib/services/catalog-git-queue.ts b/src/lib/services/catalog-git-queue.ts index af7f113d..c0ca0bd0 100644 --- a/src/lib/services/catalog-git-queue.ts +++ b/src/lib/services/catalog-git-queue.ts @@ -73,6 +73,16 @@ export async function scheduleCatalogExport() { after(async () => { const { runCatalogExport } = await import("./catalog-git-export"); 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 { /* Standalone worker contexts use their scheduled retry. */ diff --git a/src/proxy.ts b/src/proxy.ts index e9a2c4cd..2909d917 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -78,6 +78,6 @@ export const proxy = async (req: import("next/server").NextRequest) => { export const config = { matcher: [ - "/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging).*)", + "/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)", ], };