Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c55bd448 | ||
|
|
4b575d3a1d | ||
|
|
61107848f8 | ||
|
|
1e3406a7ae | ||
|
|
ef6c671903 | ||
|
|
ef2f8c30f0 | ||
|
|
73005a3c05 | ||
|
|
918134df69 | ||
|
|
d64d864175 | ||
|
|
62de3e3a8b | ||
|
|
9f024dc6ac | ||
|
|
05c2706eaf | ||
|
|
ac23d7d1d9 | ||
|
|
3c8abb6fbf | ||
|
|
640e5a5dd0 | ||
|
|
3fa643828d | ||
|
|
888765c47f | ||
|
|
f3407ebfdf | ||
|
|
56676effc3 | ||
|
|
d7d6382b60 | ||
|
|
007818b03f | ||
|
|
b24045812b | ||
|
|
0d943fe3dc | ||
|
|
6e82a02e64 | ||
|
|
08ff14d0ac | ||
|
|
b17da6ab25 | ||
|
|
56ca775405 | ||
|
|
8ee99e8ce8 | ||
|
|
5ea1d50ad1 | ||
|
|
6ef7486ca4 | ||
|
|
7c15cfd26d | ||
|
|
2ca277d858 | ||
|
|
758e25452d | ||
|
|
60d375144d | ||
|
|
1c816019b1 | ||
|
|
ceff6f98d9 | ||
|
|
8395e60f87 | ||
|
|
a92b951540 | ||
|
|
d5938f94ed | ||
|
|
991328ff0e | ||
|
|
1835221096 | ||
|
|
e7a6245ac2 | ||
|
|
da43b6c80c | ||
|
|
eb67424a1c | ||
|
|
3776475db4 | ||
|
|
54b9186e52 | ||
|
|
fe684faf70 | ||
|
|
22f834ef45 | ||
|
|
7b249a7222 | ||
|
|
57529fd1ea | ||
|
|
acbb833eca | ||
|
|
6912669754 | ||
|
|
7388ad3451 | ||
|
|
b13c9f47be | ||
|
|
f14a0e5fe7 | ||
|
|
505ed2bfe2 | ||
|
|
548ae541b2 | ||
|
|
05883b1ee6 | ||
|
|
ad015e1e9c | ||
|
|
62e3e49ad1 | ||
|
|
338591691f | ||
|
|
f56109836e | ||
|
|
b1a45902b4 | ||
|
|
7d8874c8cd | ||
|
|
a06db72c65 | ||
|
|
afc8909d3f | ||
|
|
990ebdb158 | ||
|
|
1a4888df50 | ||
|
|
2978483ea9 | ||
|
|
43742e8d99 | ||
|
|
6793f77733 | ||
|
|
adffac7360 | ||
|
|
fdb7af7ef5 | ||
|
|
48291ab641 | ||
|
|
6b34293cd3 | ||
|
|
5cb42c8dfb | ||
|
|
759ae91745 | ||
|
|
179484642f | ||
|
|
6cc45d7413 | ||
|
|
3933214953 | ||
|
|
8561c3f85e | ||
|
|
d2350a6427 | ||
|
|
0845f80768 | ||
|
|
3265c149da | ||
|
|
57710a7fe3 | ||
|
|
5b2eb91c5c | ||
|
|
11ad6d4376 | ||
|
|
6c3d81920e | ||
|
|
108c6ce03d | ||
|
|
6bffc53779 | ||
|
|
3d828a61ab | ||
|
|
7f07c111ac | ||
|
|
8218039c64 | ||
|
|
f705c67fc7 | ||
|
|
c8b3054527 | ||
|
|
8ec3df541e | ||
|
|
8ee144745a | ||
|
|
64ad9baf39 | ||
|
|
704e33638f | ||
|
|
eddb7edea4 | ||
|
|
1c9ddcd48a | ||
|
|
30ff970c38 | ||
|
|
f99980052b | ||
|
|
f181cd6af4 | ||
|
|
8a6d92afd8 | ||
|
|
dbaccd7cfc | ||
|
|
4e036b08d5 | ||
|
|
f0dcf440a7 | ||
|
|
4a1211a931 | ||
|
|
e3c010f383 | ||
|
|
a6cc3cafa9 | ||
|
|
cede541813 | ||
|
|
e0efbef30d | ||
|
|
cebcf440c5 | ||
|
|
9550b3d66f | ||
|
|
28ce0f911c | ||
|
|
d73baf1458 | ||
|
|
2f7e557d5e | ||
|
|
4be7eaed59 | ||
|
|
9cc57cddfc | ||
|
|
7507c3b55c | ||
|
|
90b65c92a2 | ||
|
|
7697728d07 | ||
|
|
30dcecd530 | ||
|
|
e4f83a8036 | ||
|
|
f187cd70a9 | ||
|
|
d2d01141f1 | ||
|
|
c2981bd970 | ||
|
|
d9ef7f8360 | ||
|
|
1f9ad02410 | ||
|
|
80d7ae14ba | ||
|
|
bc00ecf08c | ||
|
|
944527e078 | ||
|
|
d176fad4da | ||
|
|
9ee22db8ba | ||
|
|
b26e2e0de4 | ||
|
|
306e209e29 | ||
|
|
17de94d984 | ||
|
|
420210ffa0 | ||
|
|
155bf750c3 | ||
|
|
f81b114b69 | ||
|
|
203399aab7 | ||
|
|
f490fcc9da | ||
|
|
fe5a7a6185 | ||
|
|
8486ac4053 | ||
|
|
7f6febf906 | ||
|
|
7392b843ad | ||
|
|
9562a75378 | ||
|
|
84d53139a9 | ||
|
|
3e1a3f92c8 | ||
|
|
301edd2c9a | ||
|
|
5e4fc9ab59 | ||
|
|
ee25545b7f | ||
|
|
f32a6dadd0 | ||
|
|
64edb81ab7 | ||
|
|
c56696b230 | ||
|
|
6264f9fb20 | ||
|
|
4479753160 | ||
|
|
f0c27eb815 | ||
|
|
fd4d0fa1cb | ||
|
|
98a184953a | ||
|
|
d8f2a21011 | ||
|
|
761bfb2940 | ||
|
|
292268f418 | ||
|
|
b2777634f7 | ||
|
|
628d24c0c7 | ||
|
|
898204ce83 | ||
|
|
7707722f4c | ||
|
|
234a2aaf1d | ||
|
|
aec5771397 | ||
|
|
b13b3a50ff | ||
|
|
ac60a867d9 | ||
|
|
6dc80b0b05 | ||
|
|
93862c2275 | ||
|
|
99eb3af17b | ||
|
|
4b68728dbd | ||
|
|
b1eeda8de0 | ||
|
|
6d4e3486e1 | ||
|
|
8a66db4ed7 | ||
|
|
c3ff497050 | ||
|
|
463bc2cb47 | ||
|
|
1db49263eb | ||
|
|
a039ba13cc | ||
|
|
9813dbc2a4 | ||
|
|
6c53f4680c | ||
|
|
9d571e0c29 | ||
|
|
ba81d16f00 | ||
|
|
c916e42572 | ||
|
|
91e649398c | ||
|
|
03774bb411 | ||
|
|
d6111387e4 | ||
|
|
469f69ddd7 | ||
|
|
d0db352f36 | ||
|
|
a6e808a099 | ||
|
|
4b5dda467c | ||
|
|
4acafcfef6 | ||
|
|
d131124515 | ||
|
|
5923b736fa | ||
|
|
3862649369 |
No files matched your search
@@ -11,6 +11,27 @@ DATABASE_CONNECT_TIMEOUT_MS=5000
|
|||||||
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||||
REDIS_CACHE_TTL_DEFAULT=7200
|
REDIS_CACHE_TTL_DEFAULT=7200
|
||||||
|
# In-process cache entries kept per instance, evicted least-recently-used. Raise
|
||||||
|
# it if hot keys are evicted while memory headroom remains (default 2000).
|
||||||
|
CACHE_MEMORY_MAX_ENTRIES=2000
|
||||||
|
# Renders kept per imaging cache directory, counted as .img/.json pairs. A sweep
|
||||||
|
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
||||||
|
IMAGING_CACHE_MAX_ENTRIES=20000
|
||||||
|
|
||||||
|
# --- CACHE TTL TUNING (seconds; defaults apply when unset) ---
|
||||||
|
# Higher = less DB load but staler data. Publishing news rotates its cache
|
||||||
|
# revision immediately, so news TTLs only delay content that bypasses that path.
|
||||||
|
CMS_HOME_PAYLOAD_TTL_SEC=15
|
||||||
|
CMS_NEWS_LIST_TTL_SEC=300
|
||||||
|
CMS_NEWS_ARCHIVE_TTL_SEC=60
|
||||||
|
CMS_SEARCH_TTL_SEC=30
|
||||||
|
CMS_ONLINE_COUNT_TTL_SEC=10
|
||||||
|
CMS_COUNTERS_TTL_SEC=300
|
||||||
|
CMS_SITE_SETTINGS_TTL_SEC=300
|
||||||
|
CMS_SITE_SETTINGS_MEMORY_TTL_SEC=60
|
||||||
|
# Boot-time cache warm-up (primes hot keys after each deploy). Set false on
|
||||||
|
# tiny hosts where the boot COUNT(*) burst is undesirable.
|
||||||
|
CMS_WARMUP_ENABLED=true
|
||||||
|
|
||||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -39,6 +60,51 @@ APP_KEY=base64:your-app-key-here=
|
|||||||
# Bcrypt cost factor for new password hashes.
|
# Bcrypt cost factor for new password hashes.
|
||||||
BCRYPT_COST=12
|
BCRYPT_COST=12
|
||||||
|
|
||||||
|
# --- ANTI-DDOS (app-layer gate, production only) ---
|
||||||
|
# On by default in production. Set to "false" to disable (not recommended).
|
||||||
|
ANTI_DDOS_ENABLED=true
|
||||||
|
# Per-category request thresholds over the given window (per client IP).
|
||||||
|
ANTI_DDOS_PAGES_LIMIT=300
|
||||||
|
ANTI_DDOS_PAGES_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_API_LIMIT=600
|
||||||
|
ANTI_DDOS_API_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_AUTH_LIMIT=20
|
||||||
|
ANTI_DDOS_AUTH_WINDOW_SEC=60
|
||||||
|
# Whole-site safety valve per window (sheds everything for global_halt_ms when hit).
|
||||||
|
ANTI_DDOS_GLOBAL_LIMIT=18000
|
||||||
|
ANTI_DDOS_GLOBAL_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_GLOBAL_HALT_MS=10000
|
||||||
|
# Violations accumulate inside this window before an IP is hard-blocked.
|
||||||
|
ANTI_DDOS_VIOLATION_WINDOW_SEC=600
|
||||||
|
ANTI_DDOS_MAX_VIOLATIONS=10
|
||||||
|
# Escalation tiers "minViolations:ttlSeconds" — how long an offender stays blocked.
|
||||||
|
ANTI_DDOS_BLOCK_TIERS=5:600,20:3600,50:86400
|
||||||
|
|
||||||
|
# --- CLOUDFLARE API (automatic edge blocks, optional) ---
|
||||||
|
# When set, the anti-DDoS gate automatically mirrors hard-blocked IPs to the
|
||||||
|
# zone's IP Access Rules so repeat offenders are dropped at the Cloudflare
|
||||||
|
# edge (works on every plan, incl. Free). Token permissions required:
|
||||||
|
# Zone > Zone > Read and Zone > Firewall > Edit
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
|
CLOUDFLARE_ZONE_ID=
|
||||||
|
# Runtime toggle; leave true to auto-create Cloudflare blocks at the block
|
||||||
|
# threshold. Also overridable live from the admin panel.
|
||||||
|
CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
||||||
|
# Override for tests/staging (production uses the public endpoint by default).
|
||||||
|
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||||
|
|
||||||
|
# --- CAPTCHA (env fallback; admin settings win when set) ---
|
||||||
|
# Enable a captcha on register/login/forgot/resend without touching the DB.
|
||||||
|
# "none" (default), "turnstile", "recaptcha" or "hcaptcha". Example:
|
||||||
|
# CAPTCHA_PROVIDER=turnstile
|
||||||
|
CAPTCHA_PROVIDER=
|
||||||
|
# Provider keys. The SITE key is public (it ships to every browser), the
|
||||||
|
# SECRET is server-only and must never be committed — set it on the host or as
|
||||||
|
# a masked CI/CD variable. Cloudflare Turnstile keys:
|
||||||
|
# https://dash.cloudflare.com/turnstile
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# --- PATHS ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
@@ -50,6 +116,13 @@ DB_BACKUP_KEEP=7
|
|||||||
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||||
HEALTH_ALERT_COOLDOWN_MIN=15
|
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||||
|
|
||||||
|
# --- FURNITURE ASSETS ---
|
||||||
|
# Extension the local Nitro client requests for furniture bundles. The CMS
|
||||||
|
# writes new/imported/repaired bundles with this extension and prefers it when
|
||||||
|
# resolving existing ones (the other stays readable as a legacy fallback).
|
||||||
|
# ".hab" (default) or ".nitro".
|
||||||
|
FURNI_BUNDLE_EXT=.hab
|
||||||
|
|
||||||
# --- RCON (Low Latency Loop) ---
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3003
|
RCON_PORT=3003
|
||||||
|
|||||||
@@ -8,19 +8,15 @@ on:
|
|||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: /opt/ms-playwright
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# Lint, typecheck & unit tests
|
|
||||||
# Draait op de host (self-hosted) waar Node 26 +
|
|
||||||
# pnpm 11 geïnstalleerd zijn en internet beschikbaar is.
|
|
||||||
# De job-container (docker mode) heeft GEEN outbound
|
|
||||||
# internet, dus we draaien alles direct op de host.
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
check:
|
check:
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -28,6 +24,12 @@ jobs:
|
|||||||
- name: Toolchain check
|
- name: Toolchain check
|
||||||
run: node scripts/check-node-toolchain.mjs
|
run: node scripts/check-node-toolchain.mjs
|
||||||
|
|
||||||
|
- name: README version sync
|
||||||
|
run: node scripts/sync-readme-versions.mjs --check
|
||||||
|
|
||||||
|
- name: Deploy port-selection tests
|
||||||
|
run: bash scripts/ci-deploy-ports.test.sh
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -43,7 +45,20 @@ jobs:
|
|||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
- name: Test
|
tests-unit:
|
||||||
|
needs: check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Unit & coverage tests
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: 1
|
SKIP_ENV_VALIDATION: 1
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
@@ -58,10 +73,35 @@ jobs:
|
|||||||
time pnpm test:coverage --maxWorkers=4
|
time pnpm test:coverage --maxWorkers=4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
tests-integration:
|
||||||
|
needs: check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: MariaDB and Redis integration tests
|
- name: MariaDB and Redis integration tests
|
||||||
run: pnpm test:integration
|
run: pnpm test:integration
|
||||||
|
|
||||||
# Compare against reviewed Linux references; updates are explicit.
|
tests-ui:
|
||||||
|
needs: check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install UI test browser
|
- name: Install UI test browser
|
||||||
run: pnpm exec playwright install chromium
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
@@ -79,14 +119,82 @@ jobs:
|
|||||||
test-results/ui/
|
test-results/ui/
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# Validate branch/PR Docker images before integration into a deployment branch.
|
# Cross-browser imaging: the deploy gate only runs chromium, so this verifies
|
||||||
preflight:
|
# a real render (decode + APNG animation + visual baseline) across every
|
||||||
|
# bundled engine against the public site. Blocking on purpose: this is the
|
||||||
|
# signal that avatar imaging works on every system, so a failure here must
|
||||||
|
# stop the pipeline. IMAGING_EDGE marks the target as edge-fronted, enabling
|
||||||
|
# the public-routing smoke and skipping the CMS-only /imaging assertions.
|
||||||
|
tests-imaging-browsers:
|
||||||
needs: check
|
needs: check
|
||||||
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install cross-browser engines
|
||||||
|
run: pnpm exec playwright install --with-deps chromium firefox webkit
|
||||||
|
|
||||||
|
- name: Cross-browser imaging checks
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
|
||||||
|
IMAGING_EDGE: "1"
|
||||||
|
run: pnpm test:imaging
|
||||||
|
|
||||||
|
# Advisory live-quality checks: broken uploads/theme assets, legacy malformed
|
||||||
|
# avatar figures and warm-page latency. These depend on live content and data
|
||||||
|
# rather than the code under test, so they are informational only —
|
||||||
|
# `continue-on-error` keeps the pipeline green while still surfacing the
|
||||||
|
# signal in the job log and uploaded report. The deterministic caching contract
|
||||||
|
# is enforced by the blocking deploy gate instead.
|
||||||
|
tests-quality:
|
||||||
|
needs: check
|
||||||
|
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install quality test browser
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
|
- name: Advisory quality checks
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
|
||||||
|
run: pnpm test:e2e:quality
|
||||||
|
|
||||||
|
- name: Upload quality results
|
||||||
|
if: always()
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||||
|
with:
|
||||||
|
name: quality-results
|
||||||
|
path: |
|
||||||
|
playwright-report/quality/
|
||||||
|
test-results/quality/
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
preflight:
|
||||||
|
needs: [tests-unit, tests-integration, tests-ui]
|
||||||
|
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -109,18 +217,13 @@ jobs:
|
|||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# Docker build & deploy
|
|
||||||
# Draait op de host (self-hosted) zodat Docker
|
|
||||||
# toegang heeft tot de daemon en volumes.
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: check
|
needs: [tests-unit, tests-integration, tests-ui]
|
||||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Renovate
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 5 * * *" # Every day at 05:00 UTC
|
|
||||||
workflow_dispatch: # Manual trigger
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
renovate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Fix Git safe directory
|
|
||||||
run: "git config --global --add safe.directory '*' && git remote set-url origin https://epicnabbo.nl || true"
|
|
||||||
- name: Install Bash in Alpine
|
|
||||||
run: "if [ -f /etc/alpine-release ]; then apk add --no-cache bash; fi"
|
|
||||||
- name: Self-hosted Renovate
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Ensure cache dir exists before Docker starts
|
|
||||||
mkdir -p /var/tmp/renovate-cache
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
--user "$(id -u):$(id -g)" \
|
|
||||||
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \
|
|
||||||
-e RENOVATE_AUTODISCOVER=false \
|
|
||||||
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
|
|
||||||
-e RENOVATE_ONBOARDING=false \
|
|
||||||
-e LOG_LEVEL=info \
|
|
||||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
|
||||||
ghcr.io/renovatebot/renovate:latest
|
|
||||||
@@ -6,6 +6,8 @@ node_modules.prev/
|
|||||||
.next-staging/
|
.next-staging/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
src/generated/
|
src/generated/
|
||||||
# Runtime avatar/badge imaging disk cache
|
# Runtime avatar/badge imaging disk cache
|
||||||
@@ -13,6 +15,8 @@ public/cache/
|
|||||||
|
|
||||||
prod.log
|
prod.log
|
||||||
*.log
|
*.log
|
||||||
|
# Imager health-alert state (last-known up/down + timestamp)
|
||||||
|
logs/imager-health.state
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
db_backup_*.sql
|
db_backup_*.sql
|
||||||
@@ -52,3 +56,7 @@ blob-report/
|
|||||||
.env.install.*
|
.env.install.*
|
||||||
build-reports/
|
build-reports/
|
||||||
!/docs/performance-budgets.md
|
!/docs/performance-budgets.md
|
||||||
|
|
||||||
|
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
||||||
|
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
||||||
|
backups/
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
image: node:26
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- test
|
|
||||||
- build
|
|
||||||
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
|
|
||||||
before_script:
|
|
||||||
- corepack enable
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
lint:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- pnpm run lint
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- pnpm run typecheck
|
|
||||||
|
|
||||||
test:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- pnpm run test
|
|
||||||
|
|
||||||
build:
|
|
||||||
stage: build
|
|
||||||
script:
|
|
||||||
- pnpm run build
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by Gitea Runner. Do not edit it manually unless you know what you are doing. Removing this file will cause Gitea Runner to re-register as a new runner.",
|
||||||
|
"id": 22,
|
||||||
|
"uuid": "ffb52201-e18e-4a0f-96e9-cf8a0b849365",
|
||||||
|
"name": "v5-runner",
|
||||||
|
"token": "0484b5a82d3bda9a62972a6d2646ca7cb90bc4e2",
|
||||||
|
"address": "https://gitlab.epicnabbo.nl/",
|
||||||
|
"labels": [
|
||||||
|
"self-hosted:host",
|
||||||
|
"ubuntu-latest:docker://node:18-bullseye",
|
||||||
|
"debian-latest:docker://debian:bullseye-slim"
|
||||||
|
],
|
||||||
|
"ephemeral": false
|
||||||
|
}
|
||||||
@@ -1,40 +1,50 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
FROM node:26.10.0-alpine AS migrations
|
||||||
FROM node:26.8.2-alpine AS migrations
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# Keep the bootstrap aligned with package.json packageManager.
|
|
||||||
# The apk cache is persisted in a BuildKit cache mount so git is not
|
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||||
# re-downloaded on every build.
|
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||||
|
# Alpine levert geen bash mee.
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
apk add --no-cache git \
|
apk add --no-cache git bash \
|
||||||
&& npm install -g pnpm@11.25.0
|
&& npm install -g pnpm@12.10.1
|
||||||
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
|
||||||
# on the builder. This is what stops disk usage from growing unbounded: the
|
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||||
# downloaded dependency store is shared and reused instead of being copied into
|
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||||
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
|
||||||
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
|
||||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
|
||||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
|
||||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
|
||||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||||
# pnpm fetch: download all deps into the shared cache-mounted store.
|
|
||||||
RUN --mount=type=cache,target=/pnpm \
|
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||||
pnpm fetch --ignore-scripts
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
# Install offline from the cache-mounted store; the store itself stays in the
|
pnpm install --frozen-lockfile --ignore-scripts
|
||||||
# build cache between builds.
|
|
||||||
RUN --mount=type=cache,target=/pnpm \
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||||
|
|
||||||
FROM migrations AS builder
|
FROM migrations AS builder
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||||
# Fixture values exist only for this build command; production secrets are runtime-only.
|
|
||||||
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
# The build runs the webpack builder (see the `build` script in package.json).
|
||||||
# changed parts.
|
# Turbopack's compiler is a single native process that grows past 12GB RSS on
|
||||||
|
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
|
||||||
|
# --max-old-space-size cap does NOT help, because that memory is native
|
||||||
|
# Turbopack memory rather than the V8 heap.
|
||||||
|
#
|
||||||
|
# `pnpm run build` goes through scripts/with-memory-cap.sh. BuildKit's build
|
||||||
|
# container has /sys/fs/cgroup mounted read-only (no cgroup MemoryMax) and
|
||||||
|
# `ulimit -v` breaks V8-based builds (see the script header), so this stage
|
||||||
|
# explicitly opts out of the cap. The real bound here is the webpack builder
|
||||||
|
# + the V8 heap cap above, and the build runs isolated in its own container,
|
||||||
|
# not on the host; the host itself is protected by the same wrapper through
|
||||||
|
# systemd.
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||||
|
ENV CMS_MEMORY_CAP_BACKEND=none
|
||||||
|
|
||||||
|
# Bouw de Next.js applicatie met caching
|
||||||
RUN --mount=type=cache,target=/app/.next/cache \
|
RUN --mount=type=cache,target=/app/.next/cache \
|
||||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||||
@@ -42,7 +52,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
|||||||
pnpm run build \
|
pnpm run build \
|
||||||
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||||
|
|
||||||
FROM node:26.8.2-alpine AS runner
|
FROM node:26.10.0-alpine AS runner
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -62,8 +72,6 @@ COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migr
|
|||||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3002
|
EXPOSE 3002
|
||||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
|
||||||
# health; docker-compose overrides this with its own probe if needed.
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
|||||||
@@ -10,14 +10,21 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
|
|
||||||
| Component | Version | Notes |
|
| Component | Version | Notes |
|
||||||
| --------------- | -------------- | ---------------------------------------- |
|
| --------------- | -------------- | ---------------------------------------- |
|
||||||
| Node.js | 26.8.1 | Current release pinned in `.nvmrc` |
|
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
|
||||||
| pnpm | >= 11.25.0 | Recommended package manager |
|
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||||
| npm | >= 11.x | Supported alternative |
|
| npm | >= 11.x | Supported alternative |
|
||||||
| yarn | >= 4.x | Supported alternative |
|
| yarn | >= 4.x | Supported alternative |
|
||||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||||
| Docker | 24+ | Optional — for containerized deployment |
|
| Docker | 24+ | Optional — for containerized deployment |
|
||||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||||
|
|
||||||
|
Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.
|
||||||
|
|
||||||
|
> Builds must run through the `pnpm` scripts. This host has no swap and
|
||||||
|
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
|
||||||
|
> kernel and can take the database and the live release down with it. See
|
||||||
|
> [Memory-capped commands](#memory-capped-commands).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -102,6 +109,10 @@ pnpm build && pnpm start # or: npm run build && npm start
|
|||||||
|
|
||||||
Open `http://localhost:3002` in your browser.
|
Open `http://localhost:3002` in your browser.
|
||||||
|
|
||||||
|
> Always go through these scripts. `next build` is refused outright when it is
|
||||||
|
> not running under the memory cap — see
|
||||||
|
> [Memory-capped commands](#memory-capped-commands).
|
||||||
|
|
||||||
### 6. First Login
|
### 6. First Login
|
||||||
|
|
||||||
1. Register an account at `/register`, or log in with an existing emulator account.
|
1. Register an account at `/register`, or log in with an existing emulator account.
|
||||||
@@ -180,7 +191,9 @@ it does not fetch changes to this Git-built CMS. Use the updater for releases.
|
|||||||
A clone does **not** install an automatic scheduler. If desired, explicitly add a
|
A clone does **not** install an automatic scheduler. If desired, explicitly add a
|
||||||
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
|
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
|
||||||
directory. Do not configure both CI and Compose updates for the same instance.
|
directory. Do not configure both CI and Compose updates for the same instance.
|
||||||
The updater refuses an active `epicnext-cms-app` CI-managed container.
|
On a CI-managed host (either slot container running, or the nginx upstream
|
||||||
|
pointing at a blue/green slot) the updater skips with exit 0, so a cron run
|
||||||
|
that legitimately has nothing to do does not look like a failed deployment.
|
||||||
|
|
||||||
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
|
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
|
||||||
failure automatically recreates the CMS with its previous image and verifies its
|
failure automatically recreates the CMS with its previous image and verifies its
|
||||||
@@ -583,7 +596,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
|
|||||||
### Requirements (host)
|
### Requirements (host)
|
||||||
|
|
||||||
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
||||||
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (`.nitro` assets) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
||||||
|
|
||||||
### Configuration — `/docker/Polaris-imager/.env`
|
### Configuration — `/docker/Polaris-imager/.env`
|
||||||
|
|
||||||
@@ -626,9 +639,20 @@ A production issue that this section documents: **the `avatar-imaging-pixinode`
|
|||||||
|
|
||||||
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
|
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
|
||||||
|
|
||||||
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
|
### Figure string validation
|
||||||
|
|
||||||
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens.
|
The URL builder (`src/lib/imager.ts`) and the proxy routes share one rule set
|
||||||
|
(`src/lib/avatar-figure.ts`): a figure must look like `part-id` segments
|
||||||
|
separated by dots (`hd-180-1.ch-210-66`). Some clients append extra segments the
|
||||||
|
renderer still accepts (e.g. a trailing `.0`), so the pattern is deliberately
|
||||||
|
loose; over-strict validation once turned valid avatars into 400s.
|
||||||
|
|
||||||
|
Before a URL is built, the stored look is normalised: anything the imager would
|
||||||
|
reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a
|
||||||
|
path-traversal attempt, an over-long value — is replaced with
|
||||||
|
`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken
|
||||||
|
avatars without weakening the route, which still answers `400` to a malformed
|
||||||
|
figure (`src/app/api/imaging/avatar/route.ts`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -685,25 +709,427 @@ For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-tur
|
|||||||
|
|
||||||
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (Valkey-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (Valkey-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
||||||
|
|
||||||
|
#### nginx micro-cache (edge of the origin)
|
||||||
|
|
||||||
|
A short-lived nginx cache sits in front of the origin for an explicit whitelist of
|
||||||
|
public API reads. It is configured in `/etc/nginx/sites-enabled/cms.conf` and its
|
||||||
|
backends come from `/etc/nginx/snippets/cms_upstream_servers.conf`.
|
||||||
|
|
||||||
|
Why nginx and not the app: Next.js emits
|
||||||
|
`private, no-cache, no-store, max-age=0, must-revalidate` for dynamic route
|
||||||
|
handlers, and its response helper then drops the `Cache-Control` set by
|
||||||
|
`publicCacheControl()`. Every one of the whitelisted routes was verified to be
|
||||||
|
free of `auth()`, `cookies()`, `headers()` and request data, so the edge may
|
||||||
|
cache them on their behalf.
|
||||||
|
|
||||||
|
| Class | Endpoints | TTL |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `/api/online`, `/api/online/count` | 10s |
|
||||||
|
| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s |
|
||||||
|
| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s |
|
||||||
|
|
||||||
|
`/api/radio/shouts` is intentionally excluded: it is live chat.
|
||||||
|
|
||||||
|
Three details are easy to get wrong and are worth keeping intact:
|
||||||
|
|
||||||
|
- **`proxy_ignore_headers "Cache-Control" "Vary";` is what makes the cache work.**
|
||||||
|
`proxy_hide_header` only strips the header from the response to the client; the
|
||||||
|
cache module has already read the upstream's `no-store` by then and stores
|
||||||
|
nothing. Ignore the upstream directive and let nginx emit a single
|
||||||
|
`Cache-Control` of its own.
|
||||||
|
- **`$upstream_status` is empty on a cache HIT** — it is only populated on a MISS.
|
||||||
|
The header map therefore matches both `|2` and the empty-status form, so HITs
|
||||||
|
get the same class as the MISS that stored them. A status-gated map alone sends
|
||||||
|
every HIT down the `default` branch and quietly returns `private`.
|
||||||
|
- **A session must never be advertised as `public`.** With a session cookie nginx
|
||||||
|
already refuses to store the response (`proxy_no_cache`), but without the skip
|
||||||
|
flag in the header map the response would still claim `public, s-maxage=...` and
|
||||||
|
Cloudflare could share it. The map keys on
|
||||||
|
`"$cms_cc_class|$cms_skip_cache|$upstream_status"`.
|
||||||
|
|
||||||
|
HTML is deliberately **not** cached: `src/app/(site)/page.tsx` performs `auth()`
|
||||||
|
with a redirect to `/me` and reads `headers()` for a per-request CSP nonce.
|
||||||
|
|
||||||
|
### Files changed for stability and caching
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache |
|
||||||
|
| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` |
|
||||||
|
| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover |
|
||||||
|
| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||||
|
| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||||
|
| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
|
||||||
|
| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
|
||||||
|
| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state |
|
||||||
|
| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` |
|
||||||
|
| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed |
|
||||||
|
| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had |
|
||||||
|
| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested |
|
||||||
|
| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) |
|
||||||
|
|
||||||
|
> The nginx files live on the host only, not in this repository. Rebuilding the
|
||||||
|
> host loses the cache configuration — keep a copy under version control if that
|
||||||
|
> becomes a real risk.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production Deployment (PM2)
|
## Cloudflare & Anti-DDoS Protection
|
||||||
|
|
||||||
```bash
|
Two layers work together to keep the origin alive during misuse, floods, and
|
||||||
pnpm build
|
repeat-offender attacks:
|
||||||
pm2 start pnpm --name "next" -- start
|
|
||||||
pm2 save
|
1. **App-layer gate** (production only) — per-IP request buckets per category
|
||||||
|
(pages / API / auth), a whole-site safety valve, and escalation tiers that
|
||||||
|
temporarily hard-block offending IPs (`src/lib/ddos-guard.ts`).
|
||||||
|
`/api/health` is always exempt so the Docker liveness probe never trips it.
|
||||||
|
2. **Automatic Cloudflare edge blocks** (optional, works on every Cloudflare
|
||||||
|
plan including Free) — when the gate hard-blocks an IP, the CMS mirrors the
|
||||||
|
block to the zone's **IP Access Rules** (`src/lib/cloudflare-api.ts`), so a
|
||||||
|
repeat offender is dropped at the Cloudflare edge before it reaches your
|
||||||
|
server. Blocks expire together with the gate's tiered block (a 30s sweep
|
||||||
|
removes them; see `src/instrumentation.ts`).
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- The domain must actually be **proxied through Cloudflare** (orange cloud) —
|
||||||
|
automatic edge rules can only stop traffic that transits the edge. Restrict
|
||||||
|
direct access to the origin ports (e.g. only allow your server IP(s),
|
||||||
|
Cloudflare IPs, or swap the firewall to 443/80 only) so attackers cannot
|
||||||
|
bypass Cloudflare.
|
||||||
|
- A Cloudflare API token with the following zone permissions:
|
||||||
|
- `Zone > Zone > Read`
|
||||||
|
- `Zone > Firewall > Edit`
|
||||||
|
Create one at **My Profile → API Tokens → Create Token** (custom token).
|
||||||
|
|
||||||
|
### 1. Environment variables
|
||||||
|
|
||||||
|
`.env.example` documents every anti-DDoS / Cloudflare variable. In your real
|
||||||
|
`.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# --- App-layer gate (production only) ---
|
||||||
|
ANTI_DDOS_ENABLED=true
|
||||||
|
ANTI_DDOS_PAGES_LIMIT=300
|
||||||
|
ANTI_DDOS_PAGES_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_API_LIMIT=600
|
||||||
|
ANTI_DDOS_API_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_AUTH_LIMIT=20
|
||||||
|
ANTI_DDOS_AUTH_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_GLOBAL_LIMIT=18000
|
||||||
|
ANTI_DDOS_GLOBAL_WINDOW_SEC=60
|
||||||
|
ANTI_DDOS_GLOBAL_HALT_MS=10000
|
||||||
|
ANTI_DDOS_VIOLATION_WINDOW_SEC=600
|
||||||
|
ANTI_DDOS_MAX_VIOLATIONS=10
|
||||||
|
ANTI_DDOS_BLOCK_TIERS=5:600,20:3600,50:86400
|
||||||
|
|
||||||
|
# --- Cloudflare API (automatic edge blocks) ---
|
||||||
|
CLOUDFLARE_API_TOKEN=<your API token>
|
||||||
|
CLOUDFLARE_ZONE_ID=<your zone id — Dashboard → your domain → Overview>
|
||||||
|
CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart after updates:
|
Credentials live in env only and are never stored in Redis-visible config.
|
||||||
|
Restart the container after changing them.
|
||||||
|
|
||||||
|
### 2. Verify in the admin panel
|
||||||
|
|
||||||
|
Open **DevOps → Anti-DDoS protection**
|
||||||
|
(`/admin/devops/antiddos`, requires `settings.view`) and:
|
||||||
|
|
||||||
|
- Check the **Cloudflare** card shows the request arrived proxied, and the
|
||||||
|
green **API configured** badge.
|
||||||
|
- Click **Verify connection** — it confirms the token can read the zone and
|
||||||
|
shows the zone name.
|
||||||
|
- Keep the **Automatically create Cloudflare edge blocks** toggle on.
|
||||||
|
- The **Cloudflare edge blocks** card lists every auto-created rule with its
|
||||||
|
remaining TTL; **Remove rule** deletes it — and **Unban** lifts both the
|
||||||
|
host-block and the edge rule in one action.
|
||||||
|
|
||||||
|
### 3. Notes
|
||||||
|
|
||||||
|
- **Only proxied traffic** is edge-blocked. The gate verifies a request
|
||||||
|
actually transited Cloudflare (`CF-Ray` / `CDN-Loop`); a spoofable
|
||||||
|
`CF-Connecting-IP` alone is never trusted.
|
||||||
|
- **Free plan**: IP Access Rules support is plan-independent; the limit is
|
||||||
|
~300 rules per zone and the 30s sweep keeps expired rules cleaned up.
|
||||||
|
- **Distributed botnets**: many unique IPs each under the threshold are shed
|
||||||
|
by the whole-site valve (short global halt) rather than banned one-by-one,
|
||||||
|
which avoids collateral damage to shared IPs.
|
||||||
|
- **Volumetric L3/L4 floods** cannot be stopped by application code — for
|
||||||
|
those, keep the domain on Cloudflare's orange cloud so Cloudflare's own
|
||||||
|
always-on DDoS protection absorbs them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Captcha (Cloudflare Turnstile)
|
||||||
|
|
||||||
|
The **register**, **login**, **forgot-password** and **resend-verification**
|
||||||
|
forms can be protected by a captcha. **Cloudflare Turnstile** is the supported
|
||||||
|
provider and is free; reCAPTCHA and hCaptcha are also wired up. If you only
|
||||||
|
read one thing, read the precedence table below.
|
||||||
|
|
||||||
|
### Where the captcha is configured (and who wins)
|
||||||
|
|
||||||
|
There are two places, and the **admin panel always wins**:
|
||||||
|
|
||||||
|
1. **Admin panel** — *Admin → CMS Settings → Captcha* (stored in the database).
|
||||||
|
2. **Environment variables** — used **only** as a fallback when the admin panel
|
||||||
|
has no value.
|
||||||
|
|
||||||
|
Resolution order, highest first:
|
||||||
|
|
||||||
|
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `turnstile` | anything | **Turnstile** (panel wins) |
|
||||||
|
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
|
||||||
|
| *empty / never set* | `turnstile` | **Turnstile** (env fallback) |
|
||||||
|
| *empty / never set* | *empty / never set* | **Off** |
|
||||||
|
|
||||||
|
In short: **enable it in the admin panel and the env vars are ignored; leave the
|
||||||
|
panel empty and the env vars take over.** An explicit `none` in the panel turns
|
||||||
|
the captcha **off** even when `CAPTCHA_PROVIDER=turnstile` is set.
|
||||||
|
|
||||||
|
### Option A — configure with environment variables (per deploy)
|
||||||
|
|
||||||
|
1. Create a Turnstile widget in the
|
||||||
|
[Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile):
|
||||||
|
**Add widget**, name it, and add the hostnames your hotel is served on
|
||||||
|
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). *Managed* mode is fine. Copy the
|
||||||
|
**Site Key** and the **Secret Key**.
|
||||||
|
2. Put them in your real `.env` (never commit this file):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CAPTCHA_PROVIDER=turnstile
|
||||||
|
TURNSTILE_SITE_KEY=<your site key> # public — safe to expose
|
||||||
|
TURNSTILE_SECRET_KEY=<your secret key> # SECRET — server-side only
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **site key** is **public**: it is rendered into the page for the
|
||||||
|
browser.
|
||||||
|
- The **secret key** is **secret**: only the server uses it to call
|
||||||
|
Cloudflare's `siteverify` endpoint. **Never commit it** — keep it in the
|
||||||
|
host `.env`, or a **masked CI/CD variable** if you deploy from CI.
|
||||||
|
3. Restart the container so the new env is picked up.
|
||||||
|
|
||||||
|
### Option B — configure in the admin panel
|
||||||
|
|
||||||
|
**Admin → CMS Settings → Captcha**: pick the provider and paste the site key and
|
||||||
|
secret. This overrides the env vars and takes effect **without a restart**.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Open `/register`. When active, the Turnstile widget appears above the submit
|
||||||
|
button.
|
||||||
|
- The e2e suite can assert the widget contract:
|
||||||
|
`E2E_EXPECT_CAPTCHA=turnstile pnpm test:e2e` (spec: `e2e/register.spec.ts`).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Fail-closed**: if the provider is Turnstile but no secret key is available,
|
||||||
|
verification fails and the form is blocked — it never silently lets bots in.
|
||||||
|
- The Content-Security-Policy already allows `https://challenges.cloudflare.com`
|
||||||
|
(`src/lib/csp.ts`), so no header changes are needed.
|
||||||
|
- Turnstile does **not** require the Cloudflare proxy (orange cloud); it works
|
||||||
|
independently of the anti-DDoS layer above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment (blue/green)
|
||||||
|
|
||||||
|
PM2 is **not** used in production. It is neither started nor supervised here; the
|
||||||
|
CMS runs as a single Docker container per replica, started by
|
||||||
|
`scripts/ci-deploy.sh`.
|
||||||
|
|
||||||
|
```
|
||||||
|
git push main
|
||||||
|
└─ Gitea Actions "deploy" job
|
||||||
|
└─ bash scripts/ci-deploy.sh
|
||||||
|
├─ build image epicnext-cms:<sha>
|
||||||
|
├─ pnpm db:migrate
|
||||||
|
├─ start candidate on the idle port (live replica keeps serving)
|
||||||
|
├─ health gate + release-hash check + Playwright e2e
|
||||||
|
├─ switch the nginx upstream, reload (cutover)
|
||||||
|
└─ stop and retire the previous replica
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why host ports instead of `--scale`
|
||||||
|
|
||||||
|
The containers run with `network_mode: host`, so every replica binds a **host**
|
||||||
|
port rather than sharing a published docker port. `docker compose up --scale
|
||||||
|
cms=2` therefore cannot work here: the replicas would collide on 3002. Instead
|
||||||
|
there are two fixed slots, and each release lands in the idle one:
|
||||||
|
|
||||||
|
| Slot | Host port | Container name |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | 3002 | `epicnext-cms-app` |
|
||||||
|
| B | 3003 | `epicnext-cms-green` |
|
||||||
|
|
||||||
|
`docker-compose.yml` defines both services (`cms`, and `cms-green` behind the
|
||||||
|
`green` profile) from one shared YAML anchor so they cannot drift apart. Note
|
||||||
|
that the CI deploy path deliberately uses `docker run` rather than compose, so
|
||||||
|
the resource limits are declared in **both** places — limits that only existed
|
||||||
|
in compose would never apply to a real release.
|
||||||
|
|
||||||
|
### Compose on a CI host
|
||||||
|
|
||||||
|
Compose and CI both want port 3002, so only one of them can own a host. A stray
|
||||||
|
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
|
||||||
|
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
|
||||||
|
green slot, and every later release stopped on "Port 3002 is already in use" —
|
||||||
|
after the build, the migrations and the browser gate. Two guards now prevent
|
||||||
|
that:
|
||||||
|
|
||||||
|
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
|
||||||
|
`epicnext-cms-app`, but after a cutover to the green slot that container is
|
||||||
|
stopped and deleted, so the guard stopped firing while the host stayed
|
||||||
|
CI-managed. It now checks both slot containers and the nginx upstream, and
|
||||||
|
reports the skip with exit 0 instead of failing the cron.
|
||||||
|
- `ci-deploy.sh` retires a compose replica of *this* checkout
|
||||||
|
(`com.docker.compose.project.config_files`) from the candidate port before
|
||||||
|
starting the candidate — but never a slot container, and never the port nginx
|
||||||
|
currently serves. Anything else still fails loudly in `assert_port_free`.
|
||||||
|
|
||||||
|
### The nginx upstream is the switch
|
||||||
|
|
||||||
|
nginx does not know about container names; it reads a plain list of backends from
|
||||||
|
`/etc/nginx/snippets/cms_upstream_servers.conf`, which `ci-deploy.sh` rewrites
|
||||||
|
during the cutover:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream cms_app {
|
||||||
|
least_conn;
|
||||||
|
keepalive 32;
|
||||||
|
include /etc/nginx/snippets/cms_upstream_servers.conf;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
server 127.0.0.1:3002 max_fails=2 fail_timeout=10s;
|
||||||
|
```
|
||||||
|
|
||||||
|
`switch_upstream()` writes the new backend, runs `nginx -t`, and only then
|
||||||
|
reloads. If the test fails the previous file is restored untouched, so a typo can
|
||||||
|
never take the site down.
|
||||||
|
|
||||||
|
### Failover between replicas
|
||||||
|
|
||||||
|
`max_fails`/`fail_timeout` mark a dead replica as unavailable, but only
|
||||||
|
`proxy_next_upstream` actually retries the other one:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
|
||||||
|
proxy_next_upstream_tries 2;
|
||||||
|
proxy_next_upstream_timeout 10s;
|
||||||
|
```
|
||||||
|
|
||||||
|
`non_idempotent` is deliberately **absent**, so `POST` is never replayed against
|
||||||
|
the second replica — a retried import or write would otherwise apply twice. A
|
||||||
|
stream that already emitted events is likewise not resumed elsewhere.
|
||||||
|
|
||||||
|
### Resource limits
|
||||||
|
|
||||||
|
`--memory=4g --memory-swap=5g --cpus=2 --pids-limit=512` per replica. The limits
|
||||||
|
were previously removed ("Next may run unrestricted"); on a shared host that
|
||||||
|
means a single leak can starve MariaDB, nginx and Traefik.
|
||||||
|
|
||||||
|
> **Watch this on the first heavy import.** The 4 GiB ceiling is not yet
|
||||||
|
> exercised by a real bulk import. If the container is OOM-killed during a large
|
||||||
|
> furniture import, raise `mem_limit` in `docker-compose.yml` *and* `mem_limit` /
|
||||||
|
> `mem_swap_limit` in `scripts/ci-deploy.sh` together.
|
||||||
|
|
||||||
|
### Manual deploys
|
||||||
|
|
||||||
|
`./deploy.sh` is a thin wrapper around `scripts/ci-deploy.sh`, so a manual
|
||||||
|
release and a CI release follow exactly the same path. The previous version
|
||||||
|
killed whatever held port 3002 with `fuser -k` and ran `docker compose down`
|
||||||
|
before anything new existed — guaranteed downtime on every failed build. The
|
||||||
|
branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory-capped commands
|
||||||
|
|
||||||
|
This host runs with `vm.overcommit_memory=0` **and no swap**. When a process
|
||||||
|
asks for more memory than is free, the kernel does not wait — it calls the
|
||||||
|
OOM-killer immediately, and it picks its victim across the **whole machine**,
|
||||||
|
not just the offending process. An uncapped build does not merely fail: it can
|
||||||
|
take the MariaDB process, nginx and the live release down with it.
|
||||||
|
|
||||||
|
Every heavy command therefore runs inside its own cgroup with a hard
|
||||||
|
`MemoryMax`, via `scripts/with-memory-cap.sh`. If the build outgrows its
|
||||||
|
ceiling, only that cgroup is killed — the build fails, the site keeps serving.
|
||||||
|
|
||||||
|
The systemd backend also sets `MemoryHigh` (95% of the ceiling) so the kernel
|
||||||
|
reclaims and throttles **before** the hard limit, and it clamps the ceiling to
|
||||||
|
`MemAvailable - reserve` (`CMS_MEMORY_CAP_RESERVE`, default `4g`). That stops a
|
||||||
|
cap larger than the free memory from dragging the whole host into the global
|
||||||
|
OOM-killer. `MemorySwapMax=0` is set explicitly (this host has no swap). If
|
||||||
|
fewer than 1 GB would remain, the script refuses to start.
|
||||||
|
|
||||||
|
| Script | Ceiling | Covers |
|
||||||
|
| --------------------- | ------- | --------------------------------------- |
|
||||||
|
| `pnpm dev` | 8 GB | Dev server |
|
||||||
|
| `pnpm build` | 10 GB | Production build |
|
||||||
|
| `pnpm analyze` | 10 GB | Build + bundle-size report |
|
||||||
|
| `pnpm test` | 8 GB | Vitest |
|
||||||
|
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
|
||||||
|
| `pnpm test:ui` | 8 GB | Playwright |
|
||||||
|
| `pnpm test:e2e` | 8 GB | Playwright |
|
||||||
|
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
|
||||||
|
| `pnpm test:integration` | 8 GB | Vitest integration config |
|
||||||
|
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
|
||||||
|
|
||||||
|
### Running the builder by hand
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
npx next build # ✗ refused before it allocates anything
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
pm2 restart next
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`next build` loads `next.config.ts`, which **refuses any production build that
|
||||||
|
is not running under the memory cap**. This closes the one hole the `pnpm`
|
||||||
|
scripts leave open: invoking the builder directly — from a terminal, an IDE, or
|
||||||
|
an automated agent — would otherwise bypass the cgroup entirely and go
|
||||||
|
unbounded.
|
||||||
|
|
||||||
|
The refusal looks like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Refusing to run an uncapped production build.
|
||||||
|
|
||||||
|
On this host an unbounded `next build` gets OOM-killed by the kernel,
|
||||||
|
and the killer may take the database, nginx or the live release with it.
|
||||||
|
|
||||||
|
Use the capped build instead:
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix: use `pnpm build`. If you are genuinely inside an isolated environment
|
||||||
|
where the container *is* the boundary (the Docker build, a CI runner), set
|
||||||
|
`CMS_MEMORY_CAPPED=1` to opt out deliberately.
|
||||||
|
|
||||||
|
### Backends
|
||||||
|
|
||||||
|
`scripts/with-memory-cap.sh` picks its mechanism automatically:
|
||||||
|
|
||||||
|
| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes |
|
||||||
|
| ------------------------ | ------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `auto` *(default)* | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. |
|
||||||
|
| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. |
|
||||||
|
| `none` | None — warning only | Docker build and GitLab runner, each already isolated. |
|
||||||
|
|
||||||
|
On a host **without** systemd and without `CMS_MEMORY_CAP_BACKEND=none`, the
|
||||||
|
script refuses to run rather than proceeding unbounded.
|
||||||
|
|
||||||
|
Tunables for the systemd backend: `CMS_MEMORY_CAP_RESERVE` (default `4g`) is
|
||||||
|
the headroom left for everything else on the machine; the effective ceiling is
|
||||||
|
`min(requested, MemAvailable - reserve)`.
|
||||||
|
|
||||||
|
> Do not "fix" a cap failure by lowering `--max-old-space-size` or by raising
|
||||||
|
> `CMS_MEMORY_CAP_VIRTUAL` (the default is `40g` on purpose). A `ulimit -v` of
|
||||||
|
> 10g makes V8 clamp its own heap to ~2.25 GB and webpack dies with
|
||||||
|
> `std::bad_alloc`. Measure first; raise the ceiling deliberately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
@@ -711,10 +1137,23 @@ pm2 restart next
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------- | -------------------------------------------------- |
|
| ------------------------- | -------------------------------------------------- |
|
||||||
| `pnpm dev` | Start development server (hot reload) |
|
| `pnpm dev` | Start development server (hot reload) |
|
||||||
| `pnpm build` | Production build |
|
| `pnpm build` | Production build (memory-capped) |
|
||||||
| `pnpm start` | Start production server |
|
| `pnpm start` | Start production server |
|
||||||
| `pnpm typecheck` | Run TypeScript type checking |
|
| `pnpm typecheck` | Run TypeScript type checking |
|
||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
|
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
|
||||||
|
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
|
||||||
|
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
|
||||||
|
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
|
||||||
|
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
|
||||||
|
| `pnpm test:integration` | Integration tests (own Vitest config) |
|
||||||
|
| `pnpm test:housekeeping` | Housekeeping feature tests |
|
||||||
|
| `pnpm lint` | Lint and format check (Biome) |
|
||||||
|
| `pnpm biome:lint` | Alias of `pnpm lint` |
|
||||||
|
| `pnpm format` | Format files in place (Biome) |
|
||||||
|
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
|
||||||
|
| `pnpm i18n:check` | Audit CMS translation coverage |
|
||||||
|
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
|
||||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||||
| `pnpm db:migrate:status` | Show migration status |
|
| `pnpm db:migrate:status` | Show migration status |
|
||||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
||||||
@@ -722,13 +1161,19 @@ pm2 restart next
|
|||||||
| `pnpm db:studio` | Drizzle Studio (dev) |
|
| `pnpm db:studio` | Drizzle Studio (dev) |
|
||||||
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
||||||
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
||||||
|
| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) |
|
||||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
||||||
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
||||||
| `pnpm analyze` | Build + open bundle analyzer |
|
| `pnpm analyze` | Build + report per-route bundle sizes |
|
||||||
|
| `pnpm performance:report` | Re-render the performance report from a build |
|
||||||
| `pnpm jobs:worker` | Start background task worker |
|
| `pnpm jobs:worker` | Start background task worker |
|
||||||
| `pnpm biome:check` | Lint and format code |
|
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
|
||||||
|
|
||||||
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
|
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
|
||||||
|
>
|
||||||
|
> The heavy ones run memory-capped — see
|
||||||
|
> [Memory-capped commands](#memory-capped-commands). A production `next build`
|
||||||
|
> run outside the wrapper is refused rather than executed unbounded.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -822,6 +1267,13 @@ pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
|
|||||||
|
|
||||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||||
|
|
||||||
|
### Captcha — Cloudflare Turnstile
|
||||||
|
|
||||||
|
Protect the register, login, forgot-password and resend-verification forms.
|
||||||
|
Set `CAPTCHA_PROVIDER=turnstile` plus the Turnstile keys in `.env`, or configure
|
||||||
|
it in **Admin → CMS Settings**. Full setup, precedence and verification:
|
||||||
|
[Captcha (Cloudflare Turnstile)](#captcha-cloudflare-turnstile).
|
||||||
|
|
||||||
### Byparr (Cloudflare Bypass)
|
### Byparr (Cloudflare Bypass)
|
||||||
|
|
||||||
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
|
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
|
||||||
@@ -853,10 +1305,29 @@ The CMS automatically translates furniture names and descriptions to **13 langua
|
|||||||
pnpm dev # Start with hot reload
|
pnpm dev # Start with hot reload
|
||||||
pnpm typecheck # Type check all files
|
pnpm typecheck # Type check all files
|
||||||
pnpm test # Run test suite
|
pnpm test # Run test suite
|
||||||
pnpm analyze # Build and analyze bundle sizes
|
pnpm analyze # Build and report per-route bundle sizes
|
||||||
pnpm biome:check # Lint and format
|
pnpm biome:check # Lint and format
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### End-to-end tests
|
||||||
|
|
||||||
|
`pnpm test:e2e` is the **blocking deploy gate**. It only contains checks the CMS
|
||||||
|
can guarantee on its own (anonymous redirects, the register form fields and the
|
||||||
|
captcha DOM contract, cache-control headers), so a green run means a code deploy
|
||||||
|
is safe.
|
||||||
|
|
||||||
|
Checks that depend on **live content or data** — a missing upload or theme asset
|
||||||
|
(`/help` article images), a legacy malformed avatar figure, warm-page latency —
|
||||||
|
live in a separate advisory suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:e2e:quality # playwright.quality.config.ts, never blocks a deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
It is run against the public site as a `continue-on-error` CI job, so it still
|
||||||
|
surfaces a real content problem without turning the pipeline red. Point either
|
||||||
|
suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||||
@@ -870,3 +1341,5 @@ pnpm biome:check # Lint and format
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
||||||
|
test line
|
||||||
|
// CI monitor test 1791828076
|
||||||
@@ -38,6 +38,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": [
|
||||||
|
"src/components/admin/catalog-manager/sortable-tree.tsx",
|
||||||
|
"src/components/admin/studio/organize-imports-dialog/mall-helpers.tsx",
|
||||||
|
"src/app/admin/import/furni/nitro-editor-dialog.tsx"
|
||||||
|
],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["src/components/admin/catalog-manager/sortable-tree.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"css": {
|
"css": {
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# Forcefully free port 3002 and redeploy the CMS
|
# Handmatige release uitvoeren.
|
||||||
PORT=3002
|
#
|
||||||
|
# Dit script is bewust een dunne wrapper. Het echte werk zit in
|
||||||
|
# `scripts/ci-deploy.sh`, want dat is wat Gitea Actions ook draait. Eén deploypad
|
||||||
|
# betekent dat een handmatige release niet anders kan werken dan een release uit
|
||||||
|
# CI, dus er is geen tweede, slechter onderhouden pad meer.
|
||||||
|
#
|
||||||
|
# Waarom dit niet meer zelf doet wat het deed:
|
||||||
|
# - `fuser -k 3002/tcp` sloopte de live release bij elke mislukte build;
|
||||||
|
# - `docker compose down` haalde de site omlaag vóórdat er iets nieuws stond;
|
||||||
|
# - de container draait via `docker run` uit ci-deploy.sh, niet via compose, dus
|
||||||
|
# compose beheerde hier nooit de release die er echt draaide.
|
||||||
|
#
|
||||||
|
# `scripts/ci-deploy.sh` start nu blue/green: de nieuwe release komt op de vrije
|
||||||
|
# poort terwijl de live release door blijft draaien, en nginx gaat pas om nadat
|
||||||
|
# de kandidaat gezond is en de e2e-test heeft gewonnen.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
echo "--- Preparing for deployment ---"
|
deploy_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$deploy_dir"
|
||||||
|
|
||||||
# Check if fuser is installed, if not, warn the user
|
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
|
||||||
if ! command -v fuser &> /dev/null; then
|
sed -n '2,20p' "$deploy_dir/deploy.sh" | sed 's/^# \{0,1\}//'
|
||||||
echo "Error: 'fuser' command not found. Please install psmisc (e.g., sudo apt install psmisc)."
|
exit 0
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Forcefully freeing port $PORT..."
|
|
||||||
# -k kills processes, -n tcp specifies tcp socket
|
|
||||||
fuser -k $PORT/tcp || echo "No process found on port $PORT or already free."
|
|
||||||
|
|
||||||
echo "Stopping containers..."
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
echo "Starting deployment..."
|
|
||||||
if docker compose up -d --build; then
|
|
||||||
./scripts/alert.sh "Deployment successful for EpicNext-Cms"
|
|
||||||
echo "--- Deployment successful ---"
|
|
||||||
else
|
|
||||||
./scripts/alert.sh "Deployment FAILED for EpicNext-Cms"
|
|
||||||
echo "--- Deployment FAILED ---"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# De branch-guard in ci-deploy.sh accepteert alleen main/master, en controleert
|
||||||
|
# daarna of de HEAD-commit nog de nieuwste op de remote is. Deployen vanuit een
|
||||||
|
# feature-branch kan dus niet per ongeluk; dat was eerder wél mogelijk.
|
||||||
|
exec bash "$deploy_dir/scripts/ci-deploy.sh" "$@"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Trusted edge networks + live Cloudflare CDN ranges.
|
||||||
|
# Managed/regenerated by scripts/cf-ips-sync.sh - do not hand-edit the ranges.
|
||||||
|
|
||||||
|
# Topology: Cloudflare -> Traefik (:443, docker bridge proxy_traefik-proxy) ->
|
||||||
|
# nginx (:9443) -> CMS. nginx ALSO receives direct connections on :9443 from
|
||||||
|
# Cloudflare edges and from the game client (ws.epicnabbo.nl is not proxied).
|
||||||
|
|
||||||
|
# nginx only trusts the peers listed here as a source of $remote_addr
|
||||||
|
# (via CF-Connecting-IP). Anyone else presenting a CF-Connecting-IP or
|
||||||
|
# CF-ray header is spoofing and is rejected in nginx-cms.conf.
|
||||||
|
|
||||||
|
# 1 = peer is a trusted edge or internal network (keyed on the raw peer,
|
||||||
|
# unaffected by real_ip rewrites).
|
||||||
|
geo $realip_remote_addr $cms_trusted_edge {
|
||||||
|
default 0;
|
||||||
|
127.0.0.0/8 1; # localhost (health checks, admin)
|
||||||
|
::1 1; # localhost v6
|
||||||
|
172.22.0.0/16 1; # Traefik (proxyserver_traefik-proxy)
|
||||||
|
# --- Cloudflare IPv4 ranges (live from cloudflare.com/ips-v4) ---
|
||||||
|
173.245.48.0/20 1;
|
||||||
|
103.21.244.0/22 1;
|
||||||
|
103.22.200.0/22 1;
|
||||||
|
103.31.4.0/22 1;
|
||||||
|
141.101.64.0/18 1;
|
||||||
|
108.162.192.0/18 1;
|
||||||
|
190.93.240.0/20 1;
|
||||||
|
188.114.96.0/20 1;
|
||||||
|
197.234.240.0/22 1;
|
||||||
|
198.41.128.0/17 1;
|
||||||
|
162.158.0.0/15 1;
|
||||||
|
104.16.0.0/13 1;
|
||||||
|
104.24.0.0/14 1;
|
||||||
|
172.64.0.0/13 1;
|
||||||
|
131.0.72.0/22 1;
|
||||||
|
# --- Cloudflare IPv6 ranges (live from cloudflare.com/ips-v6) ---
|
||||||
|
2400:cb00::/32 1;
|
||||||
|
2606:4700::/32 1;
|
||||||
|
2803:f800::/32 1;
|
||||||
|
2405:b500::/32 1;
|
||||||
|
2405:8100::/32 1;
|
||||||
|
2a06:98c0::/29 1;
|
||||||
|
2c0f:f248::/32 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1 when an UNTRUSTED peer still presents a CF-Connecting-IP header: that is a
|
||||||
|
# spoof attempt (only real Cloudflare edges or Traefik may do that lawfully).
|
||||||
|
map "$cms_trusted_edge:$http_cf_connecting_ip" $cms_disallow_forwarding {
|
||||||
|
default 0;
|
||||||
|
"~^0:.+" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rewrite $remote_addr from CF-Connecting-IP but ONLY for the trusted peers
|
||||||
|
# above. Direct game clients (untrusted) keep their real peer address.
|
||||||
|
set_real_ip_from 127.0.0.0/8;
|
||||||
|
set_real_ip_from ::1;
|
||||||
|
set_real_ip_from 172.22.0.0/16;
|
||||||
|
set_real_ip_from 173.245.48.0/20;
|
||||||
|
set_real_ip_from 103.21.244.0/22;
|
||||||
|
set_real_ip_from 103.22.200.0/22;
|
||||||
|
set_real_ip_from 103.31.4.0/22;
|
||||||
|
set_real_ip_from 141.101.64.0/18;
|
||||||
|
set_real_ip_from 108.162.192.0/18;
|
||||||
|
set_real_ip_from 190.93.240.0/20;
|
||||||
|
set_real_ip_from 188.114.96.0/20;
|
||||||
|
set_real_ip_from 197.234.240.0/22;
|
||||||
|
set_real_ip_from 198.41.128.0/17;
|
||||||
|
set_real_ip_from 162.158.0.0/15;
|
||||||
|
set_real_ip_from 104.16.0.0/13;
|
||||||
|
set_real_ip_from 104.24.0.0/14;
|
||||||
|
set_real_ip_from 172.64.0.0/13;
|
||||||
|
set_real_ip_from 131.0.72.0/22;
|
||||||
|
set_real_ip_from 2400:cb00::/32;
|
||||||
|
set_real_ip_from 2606:4700::/32;
|
||||||
|
set_real_ip_from 2803:f800::/32;
|
||||||
|
set_real_ip_from 2405:b500::/32;
|
||||||
|
set_real_ip_from 2405:8100::/32;
|
||||||
|
set_real_ip_from 2a06:98c0::/29;
|
||||||
|
set_real_ip_from 2c0f:f248::/32;
|
||||||
|
|
||||||
|
real_ip_header CF-Connecting-IP;
|
||||||
|
real_ip_recursive off;
|
||||||
@@ -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,660 @@
|
|||||||
|
# ─── 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bestandsnaam van een furni-icon, opgehaald uit de URL. De locatie voor
|
||||||
|
# /swf/dcr/hof_furni/icons/ heeft die nodig om hetzelfde bestand bij de
|
||||||
|
# gamedata-boom op te kunnen vragen. Leeg laten voor elke andere URL, zodat
|
||||||
|
# niets anders deze waarde per ongeluk gebruikt.
|
||||||
|
map $uri $furni_icon_file {
|
||||||
|
~^/swf/dcr/hof_furni/icons/(?<icon_file>.+)$ $icon_file;
|
||||||
|
default "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache-Control per status voor het CMS-valrimpeltje. Een 404 mag nooit
|
||||||
|
# gecacht worden (zie @gamedata_missing hieronder), dus die krijgt `no-store`
|
||||||
|
# in plaats van de icon-TTL.
|
||||||
|
map $upstream_status $furni_icon_cc {
|
||||||
|
200 "public, max-age=604800, stale-while-revalidate=2592000";
|
||||||
|
default "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Mime fix ───
|
||||||
|
types {
|
||||||
|
application/json jsonc;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# REDIRECT HTTP -> HTTPS (Poort 9444)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 9444 default_server;
|
||||||
|
listen [::]:9444 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# WEBSOCKET GAME SERVER (ws.epicnabbo.nl)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 9443 ssl;
|
||||||
|
listen [::]:9443 ssl;
|
||||||
|
server_name ws.epicnabbo.nl;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/epicnabbo-backend.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/epicnabbo-backend.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# ─── Trusted Edge Gate ───
|
||||||
|
# Real Cloudflare edges and Traefik are the only peers trusted to supply a
|
||||||
|
# CF-Connecting-IP (see cloudflare-ips.conf). Any other peer that does is
|
||||||
|
# spoofing and is rejected before it reaches the CMS. Legitimate direct
|
||||||
|
# visitors (game client, :9443) never carry that header and pass through
|
||||||
|
# with their real peer address.
|
||||||
|
if ($cms_disallow_forwarding) {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "OK";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:2096;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
|
||||||
|
# Echt client IP (trusted peers via real_ip, directe clients = eigen peer)
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# MAIN HTTPS SERVER (Poort 9443)
|
||||||
|
# ==========================================
|
||||||
|
server {
|
||||||
|
listen 9443 ssl reuseport default_server;
|
||||||
|
listen [::]:9443 ssl reuseport default_server;
|
||||||
|
listen 9443 quic reuseport;
|
||||||
|
listen [::]:9443 quic reuseport;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name epicnabbo.nl www.epicnabbo.nl;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/epicnabbo-backend.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/epicnabbo-backend.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_early_data on;
|
||||||
|
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
|
||||||
|
|
||||||
|
# Resuming a session skips the full handshake, which is most of the cost of
|
||||||
|
# a TLS connection. Without this nginx performs no session resumption at all:
|
||||||
|
# every visitor paid a full handshake on every request. 50M shared sessions
|
||||||
|
# is roughly 1GB at the default 20-byte key id plus overhead.
|
||||||
|
ssl_session_cache shared:CMS_TLS:50m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# `index index.html` without a `root` left nginx resolving every
|
||||||
|
# try_files/$uri against the compiled-in default /etc/nginx/html. The
|
||||||
|
# /robots.txt and /favicon.ico probes then stat() a path the worker cannot
|
||||||
|
# traverse, and because a failed stat is logged at crit the error log filled
|
||||||
|
# with 149 crit lines per scan. Pointing root at the CMS document root makes
|
||||||
|
# the same probe a plain 404, which log_not_found already suppresses.
|
||||||
|
root /var/www/html;
|
||||||
|
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# ─── Security Headers ───
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
# ─── Trusted Edge Gate ───
|
||||||
|
# Real Cloudflare edges / Traefik are the only peers allowed to supply a
|
||||||
|
# CF-Connecting-IP (see cloudflare-ips.conf). Any other peer presenting one
|
||||||
|
# is spoofing (direct :9443 traffic), and is rejected before it reaches the
|
||||||
|
# CMS. Legitimate direct visitors never carry that header and pass through
|
||||||
|
# with their real peer address.
|
||||||
|
if ($cms_disallow_forwarding) {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Client Limits & Timeouts ───
|
||||||
|
client_max_body_size 20m;
|
||||||
|
client_body_buffer_size 16k;
|
||||||
|
client_header_buffer_size 1k;
|
||||||
|
large_client_header_buffers 4 8k;
|
||||||
|
client_body_timeout 12s;
|
||||||
|
client_header_timeout 12s;
|
||||||
|
keepalive_timeout 30s;
|
||||||
|
send_timeout 10s;
|
||||||
|
|
||||||
|
# Abuse limits. Deliberately NOT set at server scope: a room load and a page
|
||||||
|
# load are not the same request profile, so each location picks its own zone.
|
||||||
|
# /gamedata/* has no request limit at all — it is a disk cache, so limiting
|
||||||
|
# it only cost players their icons. The page routes carry the budget.
|
||||||
|
limit_conn cms_conn_per_ip 30;
|
||||||
|
|
||||||
|
# Traefik health-check route herstellen
|
||||||
|
location = /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "OK";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Statische Bestanden & Assets ───
|
||||||
|
location ^~ /client/ {
|
||||||
|
alias /var/www/Octane/dist/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
limit_req zone=cms_static_per_ip burst=1000 nodelay;
|
||||||
|
|
||||||
|
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Tag "cms-client";
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /nitro-client/ {
|
||||||
|
alias /var/www/Octane/dist/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
limit_req zone=cms_static_per_ip burst=1000 nodelay;
|
||||||
|
|
||||||
|
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Tag "cms-client";
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
if ($cors_headers) {
|
||||||
|
add_header Access-Control-Max-Age 1728000;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8";
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /gamedata { return 301 /gamedata/config/; }
|
||||||
|
location = /gamedata/ { return 301 /gamedata/config/; }
|
||||||
|
|
||||||
|
# ─── Gamedata: vier cache-klassen, want niet alles onder /gamedata/ is
|
||||||
|
# even veranderlijk.
|
||||||
|
#
|
||||||
|
# Dit pad had één regel voor de hele boom: `max-age=604800` (7 dagen). De
|
||||||
|
# Habbo-client haalt FurnitureData.json hier op, dus na een import bleef het
|
||||||
|
# client-side dagenlang de oude versie tonen — een nieuw geïmporteerd
|
||||||
|
# meubel was gewoon onzichtbaar. De purge van de `cms-gamedata`-tag
|
||||||
|
# (edge-cache.ts) raakt alleen de Cloudflare-kopie, niet de browser.
|
||||||
|
#
|
||||||
|
# 1. config/ — FurnitureData.json + de vertaalde bestanden. Verandert
|
||||||
|
# bij elke import. Kort, en `must-revalidate` sluit de
|
||||||
|
# "stuur uit de cache"-route uit zodat de client na de
|
||||||
|
# TTL een 304 vraagt in plaats van de oude body te hergebruiken.
|
||||||
|
# 2. bundled/ — nitro-bundles per sprite. De inhoud kan veranderen zonder
|
||||||
|
# dat de bestandsnaam verandert (schalen, repareren), dus
|
||||||
|
# ook revalideren, maar minder vaak: ze worden veel vaker
|
||||||
|
# opgehaald dan ze worden geschreven.
|
||||||
|
# 3. icons/ — `{classname}_icon.png`. Wordt wél herschreven onder
|
||||||
|
# dezelfde naam (repair-icons.ts, herimport), dus ook
|
||||||
|
# klasse 4's "nooit herschreven" geldt hier niet. Wel
|
||||||
|
# minder vaak dan 2: per uur een must-revalidate is één
|
||||||
|
# 304 per icon per uur, en een gerepareerd icon is zo
|
||||||
|
# binnen een uur zichtbaar in plaats van dagenlang oud.
|
||||||
|
# 4. alles wat overblijft (c_images, album*, clothes, …) — content-addressed
|
||||||
|
# of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang.
|
||||||
|
location ^~ /gamedata/config/ {
|
||||||
|
alias /var/www/Gamedata/config/;
|
||||||
|
add_header Cache-Control "public, max-age=300, must-revalidate";
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Tag "cms-gamedata";
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
if ($cors_headers) {
|
||||||
|
add_header Access-Control-Max-Age 1728000;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8";
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
error_page 404 = @gamedata_missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /gamedata/bundled/ {
|
||||||
|
alias /var/www/Gamedata/bundled/;
|
||||||
|
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Tag "cms-gamedata";
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
if ($cors_headers) {
|
||||||
|
add_header Access-Control-Max-Age 1728000;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8";
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
error_page 404 = @gamedata_missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /gamedata/icons/ {
|
||||||
|
alias /var/www/Gamedata/icons/;
|
||||||
|
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Tag "cms-gamedata";
|
||||||
|
|
||||||
|
# Geen limit_req: gamedata is schijf-cache, geen CMS-backend. Een
|
||||||
|
# kamerladen vuurt honderden bestanden in één burst af en elke limiet
|
||||||
|
# hier leidde alleen tot zichtbaar gemiste icons.
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
if ($cors_headers) {
|
||||||
|
add_header Access-Control-Max-Age 1728000;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8";
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
error_page 404 = @gamedata_missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /gamedata/ {
|
||||||
|
alias /var/www/Gamedata/;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Tag "cms-gamedata";
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
if ($cors_headers) {
|
||||||
|
add_header Access-Control-Max-Age 1728000;
|
||||||
|
add_header Content-Type "text/plain; charset=utf-8";
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
error_page 404 = @gamedata_missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Een ONTBREKEND gamedata-bestand mag nooit gecacht worden, en daarom
|
||||||
|
# krijgt elke 404 hier een eigen handler.
|
||||||
|
#
|
||||||
|
# Zonder deze handler stuurde nginx op een 404 helemaal geen Cache-Control:
|
||||||
|
# `add_header` geldt zonder `always` alleen voor 2xx/3xx. Cloudflare vond
|
||||||
|
# dan geen expliciete cache-instructie en nam de zone-instelling over:
|
||||||
|
# "Browser Cache TTL = 1 jaar". Gevolg: de 404 kwam terug als
|
||||||
|
# `cache-control: max-age=31536000` met `cf-cache-status: HIT` — dus
|
||||||
|
# vastgezet in de browser van de bezoeker én op de edge. Een icon dat één
|
||||||
|
# keer te vroeg werd opgevraagd (import nog bezig) bleef daarom het hele
|
||||||
|
# jaar een 404, ook nadat het bestand er wél stond. Dat was de "sommige
|
||||||
|
# icons laden wel, sommige niet"-klacht.
|
||||||
|
#
|
||||||
|
# `no-store` (niet een korte TTL): het bestand kan elk moment verschijnen,
|
||||||
|
# dus er is geen enkel venster waarin we een 404 willen vasthouden. De
|
||||||
|
# Cache-Tag blijft meegegeven zodat een al gecachte 404 alsnog te purgen is
|
||||||
|
# via `scripts/cf-purge.sh cms-gamedata`.
|
||||||
|
location @gamedata_missing {
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Cache-Tag "cms-gamedata" always;
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Furni-icons: gamedata-boom eerst, CMS-boom als valrimpeltje ───
|
||||||
|
#
|
||||||
|
# De CMS bouwde zijn icon-URL's altijd vanaf /swf/dcr/hof_furni/icons/,
|
||||||
|
# maar de catalogus komt uit /var/www/Gamedata/icons:
|
||||||
|
# - van de 16.263 classnames in items_base staat er 15.338 hier onder de
|
||||||
|
# "veilige" naam (`highscore_perteam_1_icon.png`), tegen 11.267 in
|
||||||
|
# public/swf/dcr/hof_furni/icons;
|
||||||
|
# - de swf-boom houdt kleurvarianten bovendien vast met een letterlijke
|
||||||
|
# `*` in de bestandsnaam (`highscore_perteam*1_icon.png`), dus elke
|
||||||
|
# aanvraag met de veilige naam mist;
|
||||||
|
# - en `hof.furni.url` wijst de Nitro-client al naar deze boom.
|
||||||
|
# Resultaat was een 404 voor `highscore_perteam_1_icon.png` en duizenden
|
||||||
|
# andere, terwijl `/gamedata/icons/` ze wél heeft.
|
||||||
|
#
|
||||||
|
# Deze locatie leest de gamedata-boom rechtstreeks van schijf — nginx heeft
|
||||||
|
# er leesrechten op en het is de boom die de client ook gebruikt. Wat daar
|
||||||
|
# niet staat wordt doorgestuurd naar de CMS, die uit public/ serveert,
|
||||||
|
# zodat niets wat nu al laadde stopt met laden.
|
||||||
|
#
|
||||||
|
# Een echte miss is een no-store 404, nooit een gecachte: `add_header`
|
||||||
|
# zonder `always` zegt niets over een 404, en zonder de expliciete handler
|
||||||
|
# hieronder neemt Cloudflare de zone-TTL (1 jaar) over. Zelfde les als bij
|
||||||
|
# @gamedata_missing hierboven.
|
||||||
|
location ^~ /swf/dcr/hof_furni/icons/ {
|
||||||
|
alias /var/www/Gamedata/icons/;
|
||||||
|
error_page 404 = @furni_icon_from_cms;
|
||||||
|
# 403 = het pad valt op een map, dus `alias` eindigt op een directory.
|
||||||
|
# Dat is geen icon, dus dezelfde route als een miss.
|
||||||
|
error_page 403 = @furni_icon_missing;
|
||||||
|
|
||||||
|
# Geen limit_req: zelfde reden als /gamedata/icons/. Een kamerladen
|
||||||
|
# vuurt honderden icons in één burst af.
|
||||||
|
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=2592000";
|
||||||
|
add_header Cache-Tag "cms-furni-icons";
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Niet in de gamedata-boom: val terug op public/swf/dcr/hof_furni/icons/,
|
||||||
|
# waar de CMS naartoe importeert. Het pad wordt hier opnieuw opgebouwd
|
||||||
|
# omdat een named location geen prefix van `$uri` afstropt.
|
||||||
|
#
|
||||||
|
# De Cache-Control komt uit `$furni_icon_cc` (status-afhankelijk) in plaats
|
||||||
|
# van uit een tweede `error_page`: nginx negeert `error_page` die binnen
|
||||||
|
# een named location staat die zelf via `error_page` bereikt is, dus een
|
||||||
|
# geketende 404-handler bestaat hier niet.
|
||||||
|
location @furni_icon_from_cms {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://cms_app/swf/dcr/hof_furni/icons/$furni_icon_file;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
|
||||||
|
# `=404` (geen URI) vervangt de body door nginx' eigen foutpagina. Next
|
||||||
|
# stuurt voor een miss een volledige HTML-pagina van ~300 KB mee, en een
|
||||||
|
# gebroken icon hoeft geen 300 KB aan markup op te halen. Dit is geen
|
||||||
|
# interne redirect, dus de `add_header` hieronder blijven gelden.
|
||||||
|
error_page 404 =404;
|
||||||
|
|
||||||
|
# `proxy_hide_header` haalt de Cache-Control van de CMS weg, anders
|
||||||
|
# staan er twee in één antwoord (Next stuurt voor /swf/** een
|
||||||
|
# `public, max-age=604800`, `location /` elders nog een
|
||||||
|
# `private, no-cache`). Eén header per antwoord.
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control $furni_icon_cc always;
|
||||||
|
add_header Cache-Tag "cms-furni-icons";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @furni_icon_missing {
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Cache-Tag "cms-furni-icons" always;
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
|
access_log off;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /camera/ {
|
||||||
|
alias /var/www/Camera/;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header Cache-Tag "cms-camera";
|
||||||
|
}
|
||||||
|
|
||||||
|
# robots.txt is generated by the CMS (src/app/robots.ts, force-dynamic
|
||||||
|
# because it needs APP_URL) and sitemap.xml points crawlers at it. This
|
||||||
|
# location used to answer from disk with try_files, which made it a
|
||||||
|
# guaranteed 404: the file does not exist in public/, so crawlers were told
|
||||||
|
# to obey a robots.txt they could never read. Proxy it like the route it
|
||||||
|
# actually is. favicon.ico below stays on disk — log_not_found already
|
||||||
|
# keeps its miss quiet.
|
||||||
|
location = /robots.txt {
|
||||||
|
access_log off;
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
||||||
|
|
||||||
|
# ─── Static Next.js Assets ───
|
||||||
|
location /_next/static/ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
# Enige eigenaar: een enkele immutable header; de app-header wordt
|
||||||
|
# altijd verwisseld zodat er nooit twee tegensprekende ontstaan
|
||||||
|
# (ook op 404's).
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /_next/data/ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "public, max-age=0, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── API Proxy's ───
|
||||||
|
location /api/auth/ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
# Auth is per sessie: nooit cachen, en de app-header onderdrukken zodat
|
||||||
|
# er precies één Cache-Control overblijft.
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Publieke API: één gedeelde Cache-Control, geen byte-cache ───
|
||||||
|
#
|
||||||
|
# nginx is de enige plek die hier cacheverantwoordelijkheid heeft: de app
|
||||||
|
# zet dit op no-store (Next-force) en Traefik + Cloudflare voegen niets
|
||||||
|
# toe, dus er is geen tweede laag die met deze header concurreert. De
|
||||||
|
# body zelf wordt NIET tussen-gecachet (geen proxy_cache_*): stampede-
|
||||||
|
# bescherming doet src/lib/cache.ts (in-process single-flight + Redis).
|
||||||
|
# De header zet de TTL voor browser + CDN (10/60/300s + SWR).
|
||||||
|
location ~ ^/api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control $cms_public_cc;
|
||||||
|
# Cloudflare cache-tag: laat de edge precies deze publieke API's cachen
|
||||||
|
# (via een cache-rule) en purge alleen deze tag na een CMS-wijziging.
|
||||||
|
add_header Cache-Tag "cms-public";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── SSE / lange streams ───
|
||||||
|
#
|
||||||
|
# Drie dingen moeten kloppen of een EventSource-stroom knapt af:
|
||||||
|
# 1. proxy_buffering off — anders houdt nginx het antwoord vast tot de
|
||||||
|
# verbinding sluit, dus de browser ziet de stream pas als een blok.
|
||||||
|
# 2. proxy_read_timeout — de default van 60s beëindigt een stroom die
|
||||||
|
# tijdens een batch-job even stilvalt, waarna de client reconnectt en
|
||||||
|
# opnieuw 504 krijgt: een reconnect-loop die de app juist belast.
|
||||||
|
# 3. send_timeout — de server-level 10s meet de pauze tussen twee writes.
|
||||||
|
# Een stream die 25s pingt, of een batch die minuten niets doet, wordt
|
||||||
|
# daar dus losgekapt. Daarom hier een eigen, ruime waarde.
|
||||||
|
location ~ ^/api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
gzip off;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
send_timeout 3600s;
|
||||||
|
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
||||||
|
# Vrijwel elke SSE-route miste dit; zonder de header blijft nginx
|
||||||
|
# alsnog bufferen, ook met proxy_buffering off.
|
||||||
|
add_header X-Accel-Buffering "no" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/badges/custom {
|
||||||
|
proxy_pass http://127.0.0.1:2096;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
# De emulator levert zelf geen Cache-Control; zonder proxy_hide_header
|
||||||
|
# zou de app-header hier een tweede keer worden toegevoegd.
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Imaging & media: de app levert de eigen Cache-Control ───
|
||||||
|
# De catch-all hieronder forceert no-store; avatars en uploads zijn
|
||||||
|
# onveranderlijk per sleutel en moeten door de browser gecachet worden.
|
||||||
|
location /api/imaging/ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/media/ {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Hoofd-routering ───
|
||||||
|
location / {
|
||||||
|
proxy_pass http://cms_app;
|
||||||
|
limit_req zone=cms_req_per_ip burst=60 nodelay;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header CF-Connecting-IP "";
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
# De HTML is per sessie: `auth()` in de homepage-layout stuurt
|
||||||
|
# ingelogde bezoekers door naar /me, en de CSP-nonce is per request.
|
||||||
|
# Dus nooit cachen — maar wel als één enkele, expliciete header.
|
||||||
|
# Zonder proxy_hide_header voeg je hier een tweede, tegensprekende
|
||||||
|
# Cache-Control toe aan degene die Next al meestuurt.
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,73 @@
|
|||||||
|
# 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),
|
||||||
|
# with direct Cloudflare-origin and game-client (ws.epicnabbo.nl) connections
|
||||||
|
# also terminating on :9443.
|
||||||
|
# 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;
|
||||||
|
# Raise the file-descriptor rlimit for the workers. Must stay <= the master's
|
||||||
|
# RLIMIT_NOFILE *hard* limit, otherwise nginx refuses to start with
|
||||||
|
# "setrlimit(RLIMIT_NOFILE) failed". Bounded from above by the systemd drop-in
|
||||||
|
# /etc/systemd/system/nginx.service.d/override.conf (LimitNOFILE=65536).
|
||||||
|
worker_rlimit_nofile 65536;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Rate limiting per client IP.
|
||||||
|
#
|
||||||
|
# Two zones, because a room load and a page load are not the same thing.
|
||||||
|
# Loading a Nitro room fires several hundred gamedata icons in one burst;
|
||||||
|
# at the page rate that produced 503s on real players. Static assets
|
||||||
|
# therefore get their own, much higher allowance. These are small immutable
|
||||||
|
# files, so a request rate is not what protects them anyway — nginx already
|
||||||
|
# serves them with must-revalidate, and the CMS upstream stays behind
|
||||||
|
# cms_req_per_ip for the expensive routes.
|
||||||
|
limit_req_zone $binary_remote_addr zone=cms_req_per_ip:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=cms_static_per_ip:10m rate=1000r/s;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=cms_conn_per_ip:10m;
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
|
||||||
|
# Trusted edge / real-IP handling (regenerated by scripts/cf-ips-sync.sh
|
||||||
|
# from the live Cloudflare ranges; installed via scripts/nginx-sync.sh).
|
||||||
|
include /etc/nginx/conf.d/cloudflare-ips.conf;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[Unit]
|
||||||
|
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
|
||||||
|
# and health probes). This is NOT optional: a web process alone does not
|
||||||
|
# establish that scheduled work runs. The CMS reports it as a failed
|
||||||
|
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
|
||||||
|
# missing, which is exactly what happened while nothing supervised this.
|
||||||
|
#
|
||||||
|
# It runs on the host rather than in a container on purpose: the schedule
|
||||||
|
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
|
||||||
|
# and it must survive CMS deploys (a container is replaced on every release).
|
||||||
|
Description=AtomNext CMS scheduled-job worker
|
||||||
|
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
|
||||||
|
After=network-online.target docker.service mariadb.service
|
||||||
|
Wants=network-online.target
|
||||||
|
# Start ordering only; the worker tolerates the database being briefly absent
|
||||||
|
# and retries, so do not make it hard-fail when mariadb is slow to boot.
|
||||||
|
Wants=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/var/www/atom-nexst
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
|
||||||
|
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
|
||||||
|
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
|
||||||
|
# bad DATABASE_URL) from spinning.
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
# Give a crashed job time to finish its DB transaction before the next start,
|
||||||
|
# otherwise a mid-transaction kill can loop on the same failure.
|
||||||
|
TimeoutStopSec=30
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=cms-jobs-worker
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Service]
|
||||||
|
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
|
||||||
|
# inherits that soft limit, so worker_connections 2048 could not actually be
|
||||||
|
# reached and every start logged:
|
||||||
|
# "2048 worker_connections exceed open file resource limit: 1024"
|
||||||
|
# Raise both soft and hard to 65536 so the master's rlimit covers
|
||||||
|
# worker_connections before nginx is even started.
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
|
||||||
|
# down until someone noticed. nginx is the only thing serving the site, so it
|
||||||
|
# must come back on its own. `on-failure` restarts only abnormal exits, which
|
||||||
|
# keeps an operator-initiated `systemctl stop` from being undone.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
# Give in-flight requests time to drain on stop/reload instead of severing
|
||||||
|
# keepalive connections and long-polling SSE streams mid-response.
|
||||||
|
TimeoutStopSec=30
|
||||||
@@ -1,38 +1,61 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Next.js CMS — blue/green
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
x-cms: &cms
|
||||||
|
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||||
|
# No `network: host` on the build. BuildKit (v0.26, Docker 29) refuses to grant
|
||||||
|
# host networking unless every caller passes --allow=network.host, and
|
||||||
|
# `docker compose build` has no such flag — so asking for it here turned every
|
||||||
|
# rebuild into an immediate "additional privileges requested" failure, which
|
||||||
|
# left the previous release serving traffic. The build only needs outbound
|
||||||
|
# internet (apk, pnpm, next/font/google), which the default bridge provides.
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||||
|
# Runtime host networking IS required: blue/green needs per-release host ports
|
||||||
|
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
|
||||||
|
network_mode: host
|
||||||
|
stop_grace_period: 15s
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./public/nitro-assets:/app/public/nitro-assets
|
||||||
|
- ./public/swf:/app/public/swf
|
||||||
|
- ./storage:/app/storage
|
||||||
|
- /var/www/Gamedata:/var/www/Gamedata
|
||||||
|
|
||||||
|
# ── Resource limits ──
|
||||||
|
mem_limit: 6g
|
||||||
|
memswap_limit: 7g
|
||||||
|
cpus: 2.0
|
||||||
|
pids_limit: 512
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cms:
|
cms:
|
||||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
<<: *cms
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
|
||||||
network: host
|
|
||||||
container_name: epicnext-cms
|
container_name: epicnext-cms
|
||||||
stop_grace_period: 10s
|
|
||||||
network_mode: host
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
volumes:
|
- PORT=3002
|
||||||
- ./public/nitro-assets:/app/public/nitro-assets
|
|
||||||
- ./public/swf:/app/public/swf
|
|
||||||
- ./storage:/app/storage
|
|
||||||
- /var/www/Gamedata:/var/www/Gamedata
|
|
||||||
|
|
||||||
# ── Resource limits aangepast voor 24 GB RAM ──
|
cms-green:
|
||||||
# Verwijderd: mem_limit, memswap_limit en cpus. Next.js mag onbeperkt presteren.
|
<<: *cms
|
||||||
pids_limit: 1024 # Verhoogd van 512 zodat Node.js/Next.js oneindig veel async requests aankan
|
container_name: epicnext-cms-green
|
||||||
|
profiles: ["green"]
|
||||||
|
environment:
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- PORT=3003
|
||||||
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
# ── Byparr (Cloudflare bypass for clone sources) ──
|
|
||||||
byparr:
|
byparr:
|
||||||
image: ghcr.io/thephaseless/byparr:latest
|
image: ghcr.io/thephaseless/byparr:latest
|
||||||
container_name: byparr
|
container_name: byparr
|
||||||
@@ -40,71 +63,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
|
||||||
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
|
||||||
pids_limit: 256
|
pids_limit: 256
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
|
|
||||||
mariadb-turbo:
|
|
||||||
image: mariadb:11
|
|
||||||
container_name: mariadb-turbo
|
|
||||||
profiles: ["db"]
|
|
||||||
network_mode: host
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
|
|
||||||
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
|
|
||||||
- MARIADB_USER=${MARIADB_USER:-cms}
|
|
||||||
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
|
|
||||||
- MARIADB_AUTO_UPGRADE=1
|
|
||||||
command: [
|
|
||||||
"--character-set-server=utf8mb4",
|
|
||||||
"--collation-server=utf8mb4_unicode_ci",
|
|
||||||
"--max-allowed-packet=512M",
|
|
||||||
"--net-buffer-length=1M",
|
|
||||||
"--connect-timeout=30",
|
|
||||||
"--wait-timeout=3600",
|
|
||||||
"--interactive-timeout=3600",
|
|
||||||
"--net-read-timeout=600",
|
|
||||||
"--net-write-timeout=600",
|
|
||||||
"--innodb-flush-log-at-trx-commit=2",
|
|
||||||
"--innodb-buffer-pool-size=2G", # Perfect ingesteld voor een 24 GB server!
|
|
||||||
"--innodb-buffer-pool-instances=4",
|
|
||||||
"--innodb-log-file-size=1G",
|
|
||||||
"--innodb-log-buffer-size=64M",
|
|
||||||
"--innodb-flush-method=O_DIRECT",
|
|
||||||
"--innodb-autoextend-increment=512",
|
|
||||||
"--innodb-max-dirty-pages-pct=90",
|
|
||||||
"--bulk-insert-buffer-size=512M",
|
|
||||||
"--innodb-doublewrite=0",
|
|
||||||
"--innodb-use-native-aio=0",
|
|
||||||
"--tmp-table-size=256M",
|
|
||||||
"--max-heap-table-size=256M",
|
|
||||||
"--read-buffer-size=4M",
|
|
||||||
"--read-rnd-buffer-size=16M",
|
|
||||||
"--performance-schema=OFF",
|
|
||||||
"--skip-name-resolve",
|
|
||||||
]
|
|
||||||
volumes:
|
|
||||||
- mariadb-turbo-data:/var/lib/mysql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
# Geoptimaliseerd: We verhogen de limiet naar 6 GB of halen hem volledig weg,
|
|
||||||
# zodat de InnoDB buffer pool en zware JSON imports nooit Out Of Memory gaan.
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mariadb-turbo-data:
|
|
||||||
driver: local
|
|
||||||
@@ -56,9 +56,9 @@ Lenis has been removed; the public site now uses native scrolling. Other used
|
|||||||
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
||||||
`clearMocks: false` preserves initialization-time permission contract assertions.
|
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||||
|
|
||||||
Renovate uses separate development/UI/Vitest groups with manual merge and a
|
Dependency updates are manual: Renovate has been removed, so there is no bot
|
||||||
three-day release age. The existing external Gitea bot configuration is retained.
|
opening upgrade pull requests. Node/pnpm upgrades remain coordinated with
|
||||||
Node/pnpm upgrades remain coordinated with Docker and the runner toolchain.
|
Docker and the runner toolchain.
|
||||||
Obsolete global overrides were removed; a scoped esbuild override remains because
|
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||||
|
|||||||
@@ -12,17 +12,25 @@ The command writes `report.json` and `report.md` and prints the Markdown report.
|
|||||||
|
|
||||||
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
|
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
|
||||||
|
|
||||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
||||||
|
|
||||||
|
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||||
|
|
||||||
|
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||||
|
|
||||||
|
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||||
|
|
||||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||||
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
|
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from the manifest's chunk lists are excluded.
|
||||||
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
|
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
|
||||||
|
|
||||||
## Initial limits
|
## Initial limits
|
||||||
|
|
||||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
|
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
|
||||||
|
|
||||||
|
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
|
||||||
|
|
||||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE `website_daily_reward_claims` (
|
||||||
|
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` int NOT NULL,
|
||||||
|
`claim_date` date NOT NULL,
|
||||||
|
`streak` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`reward_day` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`currency` varchar(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` int NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `website_daily_rewards` (
|
||||||
|
`id` int unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`day` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`currency` varchar(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` int NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
|
||||||
|
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1788791201804,
|
"when": 1788791201804,
|
||||||
"tag": "0000_premium_spirit",
|
"tag": "0000_premium_spirit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791828901011,
|
||||||
|
"tag": "0001_breezy_maestro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
-- 0033_referrals_daily_rewards.sql
|
||||||
|
-- Referral attribution tables (mirror the live-DB shape used by the existing
|
||||||
|
-- referral claim flow in src/actions/referral.ts) plus the CMS-owned daily
|
||||||
|
-- login reward schedule and claim ledger. All CREATE statements are idempotent
|
||||||
|
-- (IF NOT EXISTS) so fresh installs get the tables and existing hotels keep
|
||||||
|
-- whatever rows they already have.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_referrals` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`referrals_total` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP NULL,
|
||||||
|
`updated_at` TIMESTAMP NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `user_referrals_user_idx` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `referrals` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`referred_user_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`referred_user_ip` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NULL,
|
||||||
|
`updated_at` TIMESTAMP NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `referrals_user_idx` (`user_id`),
|
||||||
|
KEY `referrals_referred_user_idx` (`referred_user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `claimed_referral_logs` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`ip_address` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NULL,
|
||||||
|
`updated_at` TIMESTAMP NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `claimed_referral_logs_user_idx` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Daily login reward schedule: one row per streak day. The cycle repeats after
|
||||||
|
-- the highest day (day 1 of the cycle is used for any missing day).
|
||||||
|
CREATE TABLE IF NOT EXISTS `website_daily_rewards` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`day` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
`currency` VARCHAR(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` INT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `website_daily_rewards_day_uk` (`day`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Daily reward claims ledger; one row per user per claim date.
|
||||||
|
CREATE TABLE IF NOT EXISTS `website_daily_reward_claims` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`claim_date` DATE NOT NULL,
|
||||||
|
`streak` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
`reward_day` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
`currency` VARCHAR(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` INT NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `daily_claim_user_date_uk` (`user_id`, `claim_date`),
|
||||||
|
KEY `daily_claim_created_idx` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Seed a sensible 7-day reward schedule only when the table is still empty.
|
||||||
|
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
|
||||||
|
SELECT 1, 'duckets', 60
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `website_daily_rewards`);
|
||||||
|
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
|
||||||
|
SELECT 3, 'credits', 50
|
||||||
|
FROM DUAL
|
||||||
|
WHERE (SELECT COUNT(*) FROM `website_daily_rewards`) = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `website_daily_rewards` WHERE `day` = 3);
|
||||||
|
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
|
||||||
|
SELECT 5, 'diamonds', 2
|
||||||
|
FROM DUAL
|
||||||
|
WHERE (SELECT COUNT(*) FROM `website_daily_rewards`) = 2
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `website_daily_rewards` WHERE `day` = 5);
|
||||||
|
|
||||||
|
-- Referral + daily reward configuration defaults (never overwrite an existing
|
||||||
|
-- value — operators tune these keys in the admin panel).
|
||||||
|
INSERT INTO `website_settings` (`key`, `value`, `comment`)
|
||||||
|
SELECT 'referrals_block_same_ip', '1', 'Block referral attribution when the new account shares the inviter IP'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `website_settings` WHERE `key` = 'referrals_block_same_ip');
|
||||||
|
INSERT INTO `website_settings` (`key`, `value`, `comment`)
|
||||||
|
SELECT 'daily_reward_enabled', '1', 'Enable the daily login reward claims on /me'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `website_settings` WHERE `key` = 'daily_reward_enabled');
|
||||||
|
|
||||||
|
-- New ACL slugs backing the /admin/referrals + /admin/daily-rewards modules.
|
||||||
|
INSERT INTO `acl_permissions` (`slug`, `title`) VALUES
|
||||||
|
('admin.referrals.view', 'View referrals'),
|
||||||
|
('admin.referrals.edit', 'Edit referrals'),
|
||||||
|
('admin.dailyrewards.view', 'View daily login rewards'),
|
||||||
|
('admin.dailyrewards.edit', 'Edit daily login rewards')
|
||||||
|
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
|
||||||
|
|
||||||
|
-- Grant the new slugs to the same roles that already open the admin panel.
|
||||||
|
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM `acl_roles` ar
|
||||||
|
JOIN `acl_permissions` ap ON ap.slug IN (
|
||||||
|
'admin.referrals.view', 'admin.referrals.edit',
|
||||||
|
'admin.dailyrewards.view', 'admin.dailyrewards.edit'
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `acl_model_permissions` amp
|
||||||
|
JOIN `acl_permissions` apdash ON apdash.id = amp.permission_id
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND apdash.slug = 'admin.dashboard'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `acl_model_permissions` amp2
|
||||||
|
WHERE amp2.model_type = 'Role'
|
||||||
|
AND amp2.model_id = ar.id
|
||||||
|
AND amp2.permission_id = ap.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Safety net matching the 0018 seed: ranks >= 6 view, ranks >= 7 edit.
|
||||||
|
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM `permission_ranks` pr
|
||||||
|
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||||
|
JOIN `acl_permissions` ap ON ap.slug IN ('admin.referrals.view', 'admin.dailyrewards.view')
|
||||||
|
WHERE pr.id >= 6
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `acl_model_permissions` amp
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND amp.permission_id = ap.id
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM `permission_ranks` pr
|
||||||
|
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||||
|
JOIN `acl_permissions` ap ON ap.slug IN ('admin.referrals.edit', 'admin.dailyrewards.edit')
|
||||||
|
WHERE pr.id >= 7
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `acl_model_permissions` amp
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND amp.permission_id = ap.id
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Repair the escalation introduced by 0018's rule 1 ("has admin.dashboard gets
|
||||||
|
-- ALL admin.*"). Migrating 0011 grants admin.dashboard to every rank >= 6 so
|
||||||
|
-- that the sidebar opens, which meant rank 6 silently acquired
|
||||||
|
-- admin.permissions.manage, admin.rcon.execute, admin.settings.edit,
|
||||||
|
-- admin.users.edit, admin.users.reset_password, admin.room.delete, ...
|
||||||
|
--
|
||||||
|
-- Rule 1 is narrowed to `admin.%.view` (read-only, all the sidebar needs) in
|
||||||
|
-- both the migration set and the runtime repair action. This migration undoes
|
||||||
|
-- the over-grant on databases that already ran 0018: every role below the top
|
||||||
|
-- rank keeps dashboard + *.view and loses every other admin.* grant. Ranks
|
||||||
|
-- that legitimately hold tools keep them, because rule 3 only targets
|
||||||
|
-- rank >= 7 and those roles are not touched here.
|
||||||
|
--
|
||||||
|
-- Note on the rank extraction: `acl_roles.slug` looks like `rank_7`, and
|
||||||
|
-- MySQL's SUBSTRING is 1-based, so the digits start at position 6 — right
|
||||||
|
-- after the 5-character `rank_`. Reading from position 7 truncates the first
|
||||||
|
-- digit, which turns rank_10 into 0 and rank_7 into an empty string, i.e. both
|
||||||
|
-- would compare as < 7 and lose grants this migration is supposed to preserve.
|
||||||
|
-- The REGEXP guard below guarantees the remainder really is all digits.
|
||||||
|
|
||||||
|
DELETE `amp`
|
||||||
|
FROM `acl_model_permissions` `amp`
|
||||||
|
JOIN `acl_roles` `ar`
|
||||||
|
ON `ar`.`id` = `amp`.`model_id`
|
||||||
|
AND `amp`.`model_type` = 'Role'
|
||||||
|
JOIN `acl_permissions` `ap`
|
||||||
|
ON `ap`.`id` = `amp`.`permission_id`
|
||||||
|
WHERE `ap`.`slug` LIKE 'admin.%'
|
||||||
|
AND `ap`.`slug` NOT LIKE '%.view'
|
||||||
|
AND `ar`.`slug` REGEXP '^rank_[0-9]+$'
|
||||||
|
AND CAST(SUBSTRING(`ar`.`slug`, 6) AS UNSIGNED) < 7;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 0035_users_mail_index.sql
|
||||||
|
-- Index on users.mail.
|
||||||
|
--
|
||||||
|
-- The authentication paths all look an account up by mail: password reset,
|
||||||
|
-- e-mail verification, duplicate-address detection and the verify/resend
|
||||||
|
-- cooldown all resolve a single user from a submitted address. Without an index
|
||||||
|
-- each of those is a full table scan of `users`, which grows with every
|
||||||
|
-- registration.
|
||||||
|
--
|
||||||
|
-- Deliberately NOT unique. Legacy rows predate the duplicate-address handling
|
||||||
|
-- and can legitimately contain the same address more than once, so a unique
|
||||||
|
-- index would fail to apply on an existing database. The lookup is made
|
||||||
|
-- deterministic by ordering on `id` (see requestReset / the verify page), which
|
||||||
|
-- is stable without the index and correct with it.
|
||||||
|
--
|
||||||
|
-- The column is VARCHAR(500), which exceeds the 767-byte InnoDB prefix limit on
|
||||||
|
-- older row formats, hence an explicit 191-character prefix: enough to make the
|
||||||
|
-- lookup selective and still indexable everywhere.
|
||||||
|
CREATE INDEX IF NOT EXISTS `users_mail_index` ON `users` (`mail`(191));
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError } from "./helpers";
|
||||||
|
|
||||||
|
// Unauthenticated requests to private routes must bounce to the login form —
|
||||||
|
// never render private content and never 500. Two guard paths exist: the edge
|
||||||
|
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
|
||||||
|
// member pages and the mod console redirect from their layouts with a bare
|
||||||
|
// `/login`.
|
||||||
|
|
||||||
|
// Routes where the proxy preserves the requested destination.
|
||||||
|
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
|
||||||
|
// Routes guarded by a page/layout `redirect("/login")` with no destination.
|
||||||
|
const LAYOUT_ROUTES = [
|
||||||
|
"/settings",
|
||||||
|
"/me",
|
||||||
|
"/messages",
|
||||||
|
"/notifications",
|
||||||
|
"/friends",
|
||||||
|
"/redeem",
|
||||||
|
"/mod",
|
||||||
|
"/client",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PROXY_ROUTES) {
|
||||||
|
test(`${route} redirects to login and keeps the return path`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
const from = new URL(page.url()).searchParams.get("from");
|
||||||
|
expect(from, "login lost the return destination").toBeTruthy();
|
||||||
|
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of LAYOUT_ROUTES) {
|
||||||
|
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared assertions for the read-only public e2e suite. These specs run against
|
||||||
|
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
|
||||||
|
* guard the "everything loads, nothing is broken" contract that no unit test
|
||||||
|
* can see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fails if Next rendered a runtime/server error boundary into the page. */
|
||||||
|
export async function expectNoApplicationError(page: Page) {
|
||||||
|
await expect(page.locator("body")).not.toContainText("Application error");
|
||||||
|
await expect(page.locator("body")).not.toContainText("Internal Server Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every <img> that finished loading must have decoded pixels. A failed request
|
||||||
|
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
|
||||||
|
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
|
||||||
|
* that are still pending (`complete === false`) are ignored.
|
||||||
|
*/
|
||||||
|
export async function expectNoBrokenImages(page: Page) {
|
||||||
|
const broken = await page.evaluate(() => {
|
||||||
|
return Array.from(document.images)
|
||||||
|
.filter((img) => {
|
||||||
|
const src = img.currentSrc || img.src;
|
||||||
|
if (!src || src.startsWith("data:")) return false;
|
||||||
|
return img.complete && img.naturalWidth === 0;
|
||||||
|
})
|
||||||
|
.map((img) => img.currentSrc || img.src);
|
||||||
|
});
|
||||||
|
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No horizontal scrollbar — the classic mobile layout regression. */
|
||||||
|
export async function expectNoHorizontalOverflow(page: Page) {
|
||||||
|
const overflowing = await page.evaluate(() => {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
return doc.scrollWidth > window.innerWidth + 1;
|
||||||
|
});
|
||||||
|
expect(overflowing, "page scrolls horizontally").toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
|
||||||
|
* chance to start before the broken-image check runs.
|
||||||
|
*/
|
||||||
|
export async function scrollThroughPage(page: Page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const step = window.innerHeight;
|
||||||
|
for (let y = 0; y < document.body.scrollHeight; y += step) {
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skips a spec when the CMS answered with a maintenance redirect. */
|
||||||
|
export function isMaintenanceRedirect(url: string) {
|
||||||
|
return /\/maintenance(?:$|[/?])/.test(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
|
||||||
|
|
||||||
|
// Endpoints exercised here are identical whether the CMS is reached directly
|
||||||
|
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
|
||||||
|
// in the cross-browser job. Topology-specific checks live in
|
||||||
|
// `cms-imaging.spec.ts` (direct only) and `public-routing.spec.ts` (edge only).
|
||||||
|
|
||||||
|
const IMAGE_SOURCES = new Set([
|
||||||
|
"primary",
|
||||||
|
"fallback",
|
||||||
|
"cache",
|
||||||
|
"cache-stale",
|
||||||
|
"unavailable",
|
||||||
|
]);
|
||||||
|
|
||||||
|
test("renders an animated APNG for a figure with an effect", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
avatarPath({ img_format: "apng", effect: "14" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
// `unavailable` means the placeholder was served, i.e. no real render.
|
||||||
|
expect(response.headers()["x-imager-source"]).not.toBe("unavailable");
|
||||||
|
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.length).toBeGreaterThan(1000);
|
||||||
|
// APNG signature chunks: `acTL` announces the animation, `fcTL` marks frames.
|
||||||
|
expect(body.includes(Buffer.from("acTL"))).toBe(true);
|
||||||
|
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves a static PNG when no animation is requested", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(1, 4).toString("ascii")).toBe("PNG");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises a cacheable response and a known render source", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const source = response.headers()["x-imager-source"];
|
||||||
|
expect(source, "x-imager-source header").toBeTruthy();
|
||||||
|
expect(IMAGE_SOURCES.has(source as string)).toBe(true);
|
||||||
|
|
||||||
|
const cacheControl = response.headers()["cache-control"];
|
||||||
|
expect(cacheControl, "cache-control header").toContain("public");
|
||||||
|
expect(cacheControl).toMatch(/max-age=\d+/);
|
||||||
|
|
||||||
|
// Render sources other than the graceful placeholder are worth caching.
|
||||||
|
if (source !== "unavailable") {
|
||||||
|
expect(cacheControl).not.toContain("no-cache");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("answers conditional requests with 304", async ({ request }) => {
|
||||||
|
const first = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(first.status()).toBe(200);
|
||||||
|
const etag = first.headers()["etag"];
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await request.get(avatarPath({ img_format: "png" }), {
|
||||||
|
headers: { "if-none-match": etag as string },
|
||||||
|
});
|
||||||
|
expect(second.status()).toBe(304);
|
||||||
|
expect(second.headers()["etag"]).toBe(etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves the rendered avatar behind permissive CORS", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preflight requests advertise GET and revalidation", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.fetch(avatarPath(), { method: "OPTIONS" });
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
expect(response.headers()["access-control-allow-headers"]).toContain(
|
||||||
|
"If-None-Match",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("rejects malformed figures", () => {
|
||||||
|
const cases: Array<{ name: string; query: string }> = [
|
||||||
|
{ name: "missing figure", query: "" },
|
||||||
|
{ name: "empty figure", query: "?figure=" },
|
||||||
|
{ name: "path traversal", query: "?figure=../bad" },
|
||||||
|
{ name: "separator injection", query: "?figure=hd-180-1/../x" },
|
||||||
|
{
|
||||||
|
name: "over-long figure",
|
||||||
|
query: `?figure=hd-180-${"x".repeat(600)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many parts",
|
||||||
|
query: `?figure=hd-180${".a-1".repeat(30)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, query } of cases) {
|
||||||
|
test(name, async ({ request }) => {
|
||||||
|
const response = await request.get(`/api/imaging/avatar${query}`);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses a burst of concurrent requests for one figure", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Vary the key so this can hit a cold (or at least distinct) render while a
|
||||||
|
// page-load style burst is in flight. Every response must still be a real
|
||||||
|
// image — single-flight must not turn contention into 5xx/placeholders.
|
||||||
|
const path = avatarPath({
|
||||||
|
img_format: "png",
|
||||||
|
direction: "6",
|
||||||
|
head_direction: "1",
|
||||||
|
});
|
||||||
|
const responses = await Promise.all(
|
||||||
|
Array.from({ length: 6 }, () => request.get(path)),
|
||||||
|
);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge endpoint serves an image and revalidates", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const first = await request.get("/api/imaging/badge?code=ADM");
|
||||||
|
expect(first.status()).toBe(200);
|
||||||
|
expect(first.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(first.headers()["access-control-allow-origin"]).toBe("*");
|
||||||
|
const etag = first.headers()["etag"];
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await request.get("/api/imaging/badge?code=ADM", {
|
||||||
|
headers: { "if-none-match": etag as string },
|
||||||
|
});
|
||||||
|
expect(second.status()).toBe(304);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge endpoint can resolve from a local source", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
"/api/imaging/badge?code=ADM&source=local",
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(response.headers()["x-imager-source"]).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("rejects malformed badge codes", () => {
|
||||||
|
const cases = [
|
||||||
|
{ name: "missing code", query: "" },
|
||||||
|
{ name: "empty code", query: "?code=" },
|
||||||
|
{ name: "path traversal", query: "?code=../x" },
|
||||||
|
{ name: "too long", query: `?code=${"A".repeat(51)}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, query } of cases) {
|
||||||
|
test(name, async ({ request }) => {
|
||||||
|
const response = await request.get(`/api/imaging/badge${query}`);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge preflight advertises GET", async ({ request }) => {
|
||||||
|
const response = await request.fetch("/api/imaging/badge?code=ADM", {
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
|
||||||
|
* the avatar the imager produces and that it actually animates. The endpoint
|
||||||
|
* spec covers the HTTP contract; this one covers the client-side decode, where
|
||||||
|
* an unsupported image format would surface as a blank/broken avatar.
|
||||||
|
*
|
||||||
|
* Navigates straight to the image URL (a top-level navigation, which CSP
|
||||||
|
* `upgrade-insecure-requests` does not rewrite) rather than embedding it in a
|
||||||
|
* page: over a plain-HTTP host the upgrade directive would otherwise send the
|
||||||
|
* subresource request to https and break the load, without exercising anything
|
||||||
|
* about the image itself.
|
||||||
|
*
|
||||||
|
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
|
||||||
|
*/
|
||||||
|
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
|
||||||
|
const STATIC = avatarPath({ img_format: "png" });
|
||||||
|
|
||||||
|
test("the avatar decodes and animates in the browser", async ({ page }) => {
|
||||||
|
const response = await page.goto(ANIMATED);
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const image = page.locator("img");
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
const decoded = await image.evaluate((element: HTMLImageElement) => ({
|
||||||
|
width: element.naturalWidth,
|
||||||
|
height: element.naturalHeight,
|
||||||
|
}));
|
||||||
|
expect(decoded.width).toBeGreaterThan(0);
|
||||||
|
expect(decoded.height).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// An animated APNG keeps producing new frames; a static/undecodable one
|
||||||
|
// yields the same pixels every time. Sampling screenshots over a second is
|
||||||
|
// enough to tell them apart without depending on exact frame timing.
|
||||||
|
const frames = new Set<string>();
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
frames.add((await image.screenshot()).toString("base64"));
|
||||||
|
await page.waitForTimeout(180);
|
||||||
|
}
|
||||||
|
expect(frames.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the static render matches its committed visual baseline", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Pins the exact pixels of a non-animated render so a renderer/asset drift
|
||||||
|
// (wrong palette, missing part, changed sizing) is caught even when the
|
||||||
|
// image still decodes. Baselines are per engine and committed under
|
||||||
|
// e2e/imaging/__screenshots__/.
|
||||||
|
const response = await page.goto(STATIC);
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
|
||||||
|
|
||||||
|
// These assertions only hold when the CMS itself answers `/imaging`. In
|
||||||
|
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
|
||||||
|
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
|
||||||
|
test.skip(
|
||||||
|
IS_EDGE,
|
||||||
|
"The CMS /imaging route is shadowed by the edge proxy in production",
|
||||||
|
);
|
||||||
|
|
||||||
|
const imagerPath = (params: Record<string, string> = {}) =>
|
||||||
|
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
|
||||||
|
|
||||||
|
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
|
||||||
|
|
||||||
|
test("serves the default render as a PNG", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath());
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
|
||||||
|
expect(looksLikeImage(body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcodes to WebP on request", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath({ format: "webp" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/webp");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||||
|
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcodes to AVIF on request", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath({ format: "avif" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/avif");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves animated APNG renders untranscoded", async ({ request }) => {
|
||||||
|
// The renderer emits effected avatars as APNG; libvips here reads only the
|
||||||
|
// first frame, so the animated original must be served as-is even when a
|
||||||
|
// transcode is requested — otherwise the avatar would stop animating.
|
||||||
|
const response = await request.get(
|
||||||
|
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.includes(Buffer.from("acTL"))).toBe(true);
|
||||||
|
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises long-lived edge caching", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath());
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl).toContain("public");
|
||||||
|
expect(cacheControl).toContain("s-maxage");
|
||||||
|
expect(cacheControl).toContain("stale-while-revalidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a malformed figure with 400", async ({ request }) => {
|
||||||
|
const response = await request.get(
|
||||||
|
`/imaging?figure=${encodeURIComponent("../bad")}`,
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the imager preflight advertises GET", async ({ request }) => {
|
||||||
|
const response = await request.fetch("/imaging", { method: "OPTIONS" });
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Where the imaging specs are pointed.
|
||||||
|
*
|
||||||
|
* - Deploy gate (`playwright.config.ts`): hits the CMS directly, so the CMS's
|
||||||
|
* own `/imaging` route answers and its transcoding variants are reachable.
|
||||||
|
* - Cross-browser job (`playwright.imaging.config.ts` against the public
|
||||||
|
* origin): the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
|
||||||
|
* imager container, so the CMS route is shadowed. Only the endpoints that are
|
||||||
|
* identical in both topologies may be asserted unconditionally.
|
||||||
|
*
|
||||||
|
* Set `IMAGING_EDGE=1` when the base URL is behind the edge proxy.
|
||||||
|
*/
|
||||||
|
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
|
||||||
|
|
||||||
|
// The exact figure that regressed in production: the game client appends a
|
||||||
|
// trailing `.<n>` segment that the renderer ignores, but the previous
|
||||||
|
// validation regex rejected with a 400.
|
||||||
|
export const FIGURE =
|
||||||
|
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
|
||||||
|
|
||||||
|
export const avatarPath = (params: Record<string, string> = {}) =>
|
||||||
|
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||||
|
|
||||||
|
/** Public imager endpoint served through the edge proxy (imager container). */
|
||||||
|
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
|
||||||
|
`/imaging/avatarimage?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||||
|
|
||||||
|
/** Counts non-overlapping occurrences of `needle` inside `haystack`. */
|
||||||
|
export function countBytes(haystack: Buffer, needle: Buffer): number {
|
||||||
|
let count = 0;
|
||||||
|
for (
|
||||||
|
let index = haystack.indexOf(needle);
|
||||||
|
index !== -1;
|
||||||
|
index = haystack.indexOf(needle, index + needle.length)
|
||||||
|
) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG/JPEG/GIF/WebP/AVIF signatures, so a test can prove a body is an image. */
|
||||||
|
export function looksLikeImage(body: Buffer): boolean {
|
||||||
|
if (body.length < 12) return false;
|
||||||
|
if (body.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")))
|
||||||
|
return true; // PNG / APNG
|
||||||
|
if (body.subarray(0, 3).toString("ascii") === "GIF") return true;
|
||||||
|
if (body.subarray(0, 2).toString("hex") === "ffd8") return true; // JPEG
|
||||||
|
if (body.subarray(0, 4).toString("ascii") === "RIFF") return true; // WebP
|
||||||
|
if (body.subarray(4, 12).toString("ascii") === "ftypavif") return true; // AVIF
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
|
||||||
|
// correctness; this one catches a regression that keeps the image correct but
|
||||||
|
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
|
||||||
|
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
|
||||||
|
// flag a real regression, not a cold cache or a slow CI network.
|
||||||
|
const STATIC_MAX_BYTES = 64 * 1024;
|
||||||
|
const ANIMATED_MAX_BYTES = 512 * 1024;
|
||||||
|
const WARM_MAX_MS = 3000;
|
||||||
|
|
||||||
|
test("the static avatar stays inside its byte budget", async ({ request }) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the animated avatar stays inside its byte budget", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
avatarPath({ img_format: "apng", effect: "14" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(
|
||||||
|
ANIMATED_MAX_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a warm avatar request is served quickly", async ({ request }) => {
|
||||||
|
const path = avatarPath({ img_format: "png" });
|
||||||
|
await request.get(path); // warm the disk and edge caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(path);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(elapsed).toBeLessThan(WARM_MAX_MS);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
avatarPath,
|
||||||
|
IMAGER_PATH,
|
||||||
|
IS_EDGE,
|
||||||
|
looksLikeImage,
|
||||||
|
} from "./imaging-target";
|
||||||
|
|
||||||
|
// Regression guard for the public topology: the edge proxy must front the
|
||||||
|
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
|
||||||
|
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
|
||||||
|
// Only meaningful against the public origin, so it is skipped when the CMS is
|
||||||
|
// reached directly (set `IMAGING_EDGE=1` for the public origin).
|
||||||
|
test.skip(
|
||||||
|
!IS_EDGE,
|
||||||
|
"Public routing is only asserted against the edge-fronted origin",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||||
|
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
|
||||||
|
const response = await request.get("/api/imaging/badge?code=ADM");
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
});
|
||||||
@@ -88,7 +88,10 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
|
|||||||
await page
|
await page
|
||||||
.locator('input[autocomplete="current-password"]')
|
.locator('input[autocomplete="current-password"]')
|
||||||
.press("Enter");
|
.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/me(?:\?|$)/);
|
// The login page honours `?from=`, so an admin bounced off /admin lands
|
||||||
|
// back where they were heading instead of on /me. The step below
|
||||||
|
// navigates there explicitly anyway; this asserts the redirect target.
|
||||||
|
await expect(page).toHaveURL(/\/admin\/articles\/new(?:\?|$)/);
|
||||||
const session = await context.request
|
const session = await context.request
|
||||||
.get("/api/auth/session")
|
.get("/api/auth/session")
|
||||||
.then((response) => response.json());
|
.then((response) => response.json());
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Caching contract for the anonymous pages. This is deterministic and stays in
|
||||||
|
// the blocking deploy gate: the perf work relies on the edge/shared cache
|
||||||
|
// (`src/proxy.ts`) — public content pages advertise a short `s-maxage`, while
|
||||||
|
// session-dependent pages (login, register, settings) must stay
|
||||||
|
// `private, no-store`. Timing budgets are advisory and live in
|
||||||
|
// `e2e/quality/performance.spec.ts`.
|
||||||
|
test("anonymous content pages advertise a shared cache", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/news", "/staff", "/shop"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session-dependent pages stay private and uncached", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/login", "/register"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
|
||||||
|
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
|
||||||
|
"s-maxage",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoApplicationError,
|
||||||
|
expectNoBrokenImages,
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
isMaintenanceRedirect,
|
||||||
|
scrollThroughPage,
|
||||||
|
} from "../helpers";
|
||||||
|
|
||||||
|
// ADVISORY suite — run with `pnpm test:e2e:quality`, not part of the deploy
|
||||||
|
// gate. These checks depend on live content/upload state and user data (a
|
||||||
|
// missing upload, a legacy malformed avatar figure, a bad theme asset), so
|
||||||
|
// they are informational: they tell you the site has a real content problem,
|
||||||
|
// but they must never block a code deploy. Every assertion the CMS can
|
||||||
|
// guarantee on its own lives in the blocking specs instead.
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
"/",
|
||||||
|
"/news",
|
||||||
|
"/staff",
|
||||||
|
"/shop",
|
||||||
|
"/search",
|
||||||
|
"/leaderboard",
|
||||||
|
"/help",
|
||||||
|
"/radio",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PUBLIC_ROUTES) {
|
||||||
|
test(`${route} loads every image and does not overflow`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the register page loads its theme/avatar images", async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const response = await page.goto("/");
|
||||||
|
expect(response?.ok()).toBe(true);
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// ADVISORY suite — run with `pnpm test:e2e:quality`. Warm-page latency depends
|
||||||
|
// on the host, the edge and the shared data caches, so a slow CI hop can trip
|
||||||
|
// these budgets without anything being wrong. The cache-control *contract*
|
||||||
|
// (deterministic) lives in the blocking `e2e/performance.spec.ts`; only the
|
||||||
|
// timing budgets are advisory.
|
||||||
|
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
|
||||||
|
|
||||||
|
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
|
||||||
|
|
||||||
|
for (const route of WARM_ROUTES) {
|
||||||
|
test(`${route} responds within the warm budget`, async ({ request }) => {
|
||||||
|
await request.get(route); // warm the render + data caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(route);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(
|
||||||
|
elapsed,
|
||||||
|
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
|
||||||
|
).toBeLessThan(BUDGET_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError, isMaintenanceRedirect } from "./helpers";
|
||||||
|
|
||||||
|
// The register form is the gate for every new player, so it gets a deeper
|
||||||
|
// read-only pass than the generic public-page spec. It also pins the captcha
|
||||||
|
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
|
||||||
|
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
|
||||||
|
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
|
||||||
|
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
|
||||||
|
|
||||||
|
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
|
||||||
|
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
|
||||||
|
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
|
||||||
|
|
||||||
|
test.describe("register", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders every field a new player has to fill in", async ({ page }) => {
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="mail"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]').first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('input[name="password_confirmation"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="terms"]')).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes exactly one captcha widget with a site key", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const widgets = page.locator(CAPTCHA_SELECTOR);
|
||||||
|
|
||||||
|
if (EXPECT === "none") {
|
||||||
|
await expect(widgets).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EXPECT === "turnstile") {
|
||||||
|
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await widgets.count();
|
||||||
|
expect(
|
||||||
|
count,
|
||||||
|
`expected at most one captcha provider, found ${count}`,
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
|
||||||
|
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
|
||||||
|
expect(siteKey?.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
|
||||||
|
const script = page.locator(
|
||||||
|
'script[src*="challenges.cloudflare.com/turnstile"]',
|
||||||
|
);
|
||||||
|
await expect(script).toHaveCount(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
const file = {
|
const file = {
|
||||||
name: "fixture_chair.nitro",
|
name: "fixture_chair.hab",
|
||||||
mimeType: "application/octet-stream",
|
mimeType: "application/octet-stream",
|
||||||
buffer: Buffer.from("isolated upload fixture"),
|
buffer: Buffer.from("isolated upload fixture"),
|
||||||
};
|
};
|
||||||
@@ -23,7 +23,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
|
|||||||
expect(route.request().postData()).toContain('name="classname"');
|
expect(route.request().postData()).toContain('name="classname"');
|
||||||
expect(route.request().postData()).toContain("fixture_chair");
|
expect(route.request().postData()).toContain("fixture_chair");
|
||||||
expect(route.request().postData()).toContain(
|
expect(route.request().postData()).toContain(
|
||||||
'filename="fixture_chair.nitro"',
|
'filename="fixture_chair.hab"',
|
||||||
);
|
);
|
||||||
await route.fulfill({ json: { ok: true, attachmentId } });
|
await route.fulfill({ json: { ok: true, attachmentId } });
|
||||||
});
|
});
|
||||||
@@ -38,7 +38,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
await route.fulfill({ json: { ok: true } });
|
await route.fulfill({ json: { ok: true } });
|
||||||
});
|
});
|
||||||
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
|
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Matching original file attached", { exact: true }),
|
page.getByText("Matching original file attached", { exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -94,9 +94,7 @@ for (const scenario of [
|
|||||||
})
|
})
|
||||||
: route.abort("failed"),
|
: route.abort("failed"),
|
||||||
);
|
);
|
||||||
await page
|
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
|
||||||
.getByLabel("Choose the original .nitro file")
|
|
||||||
.setInputFiles(file);
|
|
||||||
await expect(page.getByRole("alert")).toContainText(scenario.message);
|
await expect(page.getByRole("alert")).toContainText(scenario.message);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", {
|
page.getByRole("button", {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guarantee this file exists to guard: an import that finishes outside this
|
||||||
|
* page — the job worker, another tab, another browser — puts the new categories
|
||||||
|
* on screen without a reload. Every earlier test in this suite asserted only
|
||||||
|
* that no page errors were thrown, so a regression back to "refresh the page to
|
||||||
|
* see it" would have gone unnoticed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function node(id: number, caption: string, itemCount = 0) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
caption,
|
||||||
|
parentId: -1,
|
||||||
|
depth: 0,
|
||||||
|
orderNum: id,
|
||||||
|
enabled: "1",
|
||||||
|
visible: "1",
|
||||||
|
iconImage: 0,
|
||||||
|
iconColor: 0,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
childCount: 0,
|
||||||
|
itemCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog as the server would report it; specs move it forward mid-test. */
|
||||||
|
const catalog = {
|
||||||
|
revision: "rev-1",
|
||||||
|
pages: [node(1, "Root")],
|
||||||
|
treeStatus: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mockStudio(page: Page) {
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname === "/api/admin/catalog/revision")
|
||||||
|
return route.fulfill({ json: { ok: true, revision: catalog.revision } });
|
||||||
|
if (
|
||||||
|
url.pathname === "/api/admin/catalog/tree" &&
|
||||||
|
url.searchParams.get("mode") === "full"
|
||||||
|
)
|
||||||
|
return route.fulfill({
|
||||||
|
status: catalog.treeStatus,
|
||||||
|
json: {
|
||||||
|
ok: catalog.treeStatus === 200,
|
||||||
|
pages: catalog.pages,
|
||||||
|
totals: null,
|
||||||
|
revision: catalog.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/inspect"))
|
||||||
|
return route.fulfill({ json: { items: [] } });
|
||||||
|
if (url.pathname.endsWith("/source-assets"))
|
||||||
|
return route.fulfill({ json: { items: [] } });
|
||||||
|
if (url.pathname.endsWith("/furni"))
|
||||||
|
return route.fulfill({
|
||||||
|
json: url.searchParams.has("action")
|
||||||
|
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
|
||||||
|
: {
|
||||||
|
items: [],
|
||||||
|
meta: {
|
||||||
|
currentPage: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
total: 0,
|
||||||
|
perPage: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/clone"))
|
||||||
|
return route.fulfill({ json: { sources: [] } });
|
||||||
|
if (url.pathname.endsWith("/import-jobs"))
|
||||||
|
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Unknown fixture endpoint" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
catalog.revision = "rev-1";
|
||||||
|
catalog.pages = [node(1, "Root")];
|
||||||
|
catalog.treeStatus = 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a write announced by another tab lands in the mounted categories", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
(testInfo.project.use.viewport?.width ?? 0) < 1024,
|
||||||
|
"the category rail is collapsed on narrow viewports",
|
||||||
|
);
|
||||||
|
await mockStudio(page);
|
||||||
|
const navigations: string[] = [];
|
||||||
|
page.on("framenavigated", (frame) => {
|
||||||
|
if (frame === page.mainFrame()) navigations.push(frame.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
|
||||||
|
const navigationsAfterLoad = navigations.length;
|
||||||
|
|
||||||
|
// An import finished elsewhere: the catalog moved on, so the tree route now
|
||||||
|
// answers with the imported category.
|
||||||
|
catalog.revision = "rev-2";
|
||||||
|
catalog.pages = [...catalog.pages, node(2, "Imported Furniture", 3)];
|
||||||
|
await page.evaluate((revision) => {
|
||||||
|
const channel = new BroadcastChannel("atom-cms-catalog");
|
||||||
|
channel.postMessage({ revision });
|
||||||
|
channel.close();
|
||||||
|
}, catalog.revision);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /Imported Furniture/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
// The page was never navigated or reloaded: the tree updated in place.
|
||||||
|
expect(navigations).toHaveLength(navigationsAfterLoad);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed refresh keeps the categories and says it is stale", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
(testInfo.project.use.viewport?.width ?? 0) < 1024,
|
||||||
|
"the category rail is collapsed on narrow viewports",
|
||||||
|
);
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
|
||||||
|
|
||||||
|
catalog.treeStatus = 500;
|
||||||
|
catalog.revision = "rev-2";
|
||||||
|
await page.evaluate((revision) => {
|
||||||
|
const channel = new BroadcastChannel("atom-cms-catalog");
|
||||||
|
channel.postMessage({ revision });
|
||||||
|
channel.close();
|
||||||
|
}, catalog.revision);
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -137,7 +137,6 @@ createRoot(root).render(
|
|||||||
<OrganizeImportsLauncher />
|
<OrganizeImportsLauncher />
|
||||||
<StudioClient
|
<StudioClient
|
||||||
source={buildFurniImportSource("it")}
|
source={buildFurniImportSource("it")}
|
||||||
initialTree={[]}
|
|
||||||
defaultTranslate={false}
|
defaultTranslate={false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test("Organize Imports Dialog validation limits and modes workflow", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on("pageerror", (error) => errors.push(error.message));
|
|
||||||
|
|
||||||
await page.route("**/api/**", async (route) => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
if (url.pathname === "/api/admin/catalog/import-groups") {
|
|
||||||
return route.fulfill({
|
|
||||||
json: {
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
name: "Test Group",
|
|
||||||
caption: "Test Group",
|
|
||||||
icon: 1,
|
|
||||||
layout: "default_3x3",
|
|
||||||
total: 5,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
itemId: 1,
|
|
||||||
itemName: "chair",
|
|
||||||
catalogItemId: null,
|
|
||||||
alreadyPlaced: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/admin/catalog/pages") {
|
|
||||||
return route.fulfill({
|
|
||||||
json: {
|
|
||||||
pages: [{ id: 101, caption: "Existing Category", parentId: 0 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
route.request().method() === "POST" &&
|
|
||||||
url.pathname.includes("organize")
|
|
||||||
) {
|
|
||||||
return route.fulfill({
|
|
||||||
json: {
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
created: [
|
|
||||||
{ pageId: 102, caption: "Test Group", moved: 0, added: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 404,
|
|
||||||
json: { error: "Not found" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there's no direct harness route, we can test component behavior or test via catalog admin route if available.
|
|
||||||
// For now, let's verify error handling and limits in the mock test or navigate to catalog.
|
|
||||||
await page.goto("/admin/catalog");
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The furniture pane once declared `initial={{ opacity: 0 }}` / `animate={{
|
||||||
|
* opacity: 1 }}` through motion's minimal `motion/react-m` entry. That entry
|
||||||
|
* renders the element but never runs the animation, so the inline style stayed
|
||||||
|
* at opacity: 0: every row was in the DOM, measurable, and its image loaded,
|
||||||
|
* yet the whole list was invisible.
|
||||||
|
*
|
||||||
|
* studio.spec.ts could not catch it because playwright.ui.config.ts sets
|
||||||
|
* `reducedMotion: "reduce"`, and under reduced motion the animation is skipped
|
||||||
|
* and the element lands straight on its final value. This file opts out of that
|
||||||
|
* so a future animation swap cannot quietly hide the list again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
classname: "fixture_chair",
|
||||||
|
name: "Fixture chair",
|
||||||
|
description: "Synthetic chair",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 1,
|
||||||
|
category: "other",
|
||||||
|
alreadyImported: true,
|
||||||
|
nitroExists: true,
|
||||||
|
iconUrl: "/fixture/cover.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
classname: "fixture_table",
|
||||||
|
name: "Fixture table",
|
||||||
|
description: "Synthetic table",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 1,
|
||||||
|
category: "other",
|
||||||
|
alreadyImported: false,
|
||||||
|
nitroExists: false,
|
||||||
|
iconUrl: "/fixture/cover.svg",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("the furniture pane is painted, not merely present in the DOM", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
reducedMotion: "no-preference",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/import/furni") {
|
||||||
|
return route.fulfill({
|
||||||
|
json:
|
||||||
|
url.searchParams.get("action") === "stats"
|
||||||
|
? { totalInDb: 2, inCatalog: 1, notInCatalog: 1, missingNitro: 0 }
|
||||||
|
: {
|
||||||
|
items,
|
||||||
|
meta: { currentPage: 1, lastPage: 1, total: 2, perPage: 20 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/import/clone")
|
||||||
|
return route.fulfill({ json: { sources: [] } });
|
||||||
|
if (url.pathname === "/api/admin/studio/import-jobs")
|
||||||
|
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||||
|
if (url.pathname === "/api/admin/studio/nitro-quality")
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
report: {
|
||||||
|
scales: [32, 64].map((size) => ({
|
||||||
|
size,
|
||||||
|
state: "missing",
|
||||||
|
assets: [],
|
||||||
|
animations: [],
|
||||||
|
directions: [],
|
||||||
|
issues: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Unknown fixture endpoint" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/admin/studio-harness", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const list = page.locator('[data-testid="studio-furniture-list"]');
|
||||||
|
await expect(list).toBeVisible();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => list.locator("[data-index]").count(), { timeout: 20000 })
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
// Let any entrance animation run before sampling computed styles.
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// The rows exist. Now prove they are actually painted: no ancestor may be
|
||||||
|
// left faded out, which is precisely the failure this guards against.
|
||||||
|
const samples = await list.locator("[data-index]").evaluateAll((els) =>
|
||||||
|
els.slice(0, 5).map((el) => {
|
||||||
|
const opacities: number[] = [];
|
||||||
|
let node: Element | null = el;
|
||||||
|
while (node && opacities.length < 12) {
|
||||||
|
opacities.push(Number(getComputedStyle(node).opacity));
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
minOpacity: Math.min(...opacities),
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(samples.length).toBeGreaterThan(0);
|
||||||
|
for (const s of samples) {
|
||||||
|
expect(s.minOpacity, "an ancestor is faded out").toBeGreaterThan(0.9);
|
||||||
|
expect(s.width).toBeGreaterThan(0);
|
||||||
|
expect(s.height).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playwright treats opacity 0 as not visible, so this is the end-to-end form.
|
||||||
|
await expect(list.locator("[data-index]").first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View Fixture chair", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'epic-next-app',
|
|
||||||
script: 'node_modules/next/dist/bin/next',
|
|
||||||
args: 'start',
|
|
||||||
cwd: '/var/www/atom-nexst',
|
|
||||||
instances: 1,
|
|
||||||
autorestart: true,
|
|
||||||
watch: false,
|
|
||||||
max_memory_restart: '1G',
|
|
||||||
node_args: '--max-old-space-size=1024',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3002
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -35,7 +35,13 @@ vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
|
|||||||
vi.mock("next-intl/server", () => ({
|
vi.mock("next-intl/server", () => ({
|
||||||
getTranslations: async () => (key: string) => key,
|
getTranslations: async () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
// The data cache only exists inside a Next render, so both entry points degrade
|
||||||
|
// to the real work underneath them instead of being stubbed out.
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
revalidateTag: vi.fn(),
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
redirect: (url: string) => {
|
redirect: (url: string) => {
|
||||||
throw Error(`Unexpected integration redirect: ${url}`);
|
throw Error(`Unexpected integration redirect: ${url}`);
|
||||||
@@ -150,7 +156,14 @@ beforeAll(async () => {
|
|||||||
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
||||||
delete process.env.SKIP_ENV_VALIDATION;
|
delete process.env.SKIP_ENV_VALIDATION;
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
Object.assign(process.env, { NODE_ENV: "test" });
|
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
|
||||||
|
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
|
||||||
|
// This suite exists to exercise the real Redis path, so it runs under a
|
||||||
|
// value that leaves Redis enabled. "development" is used because it is the
|
||||||
|
// only non-production value src/env.ts accepts. Vitest's own environment is
|
||||||
|
// still configured via vitest.integration.config.ts. Object.assign is used
|
||||||
|
// because process.env.NODE_ENV is typed read-only.
|
||||||
|
Object.assign(process.env, { NODE_ENV: "development" });
|
||||||
process.env.HOTEL_NAME = "Integration";
|
process.env.HOTEL_NAME = "Integration";
|
||||||
|
|
||||||
await connection.query(
|
await connection.query(
|
||||||
@@ -374,7 +387,8 @@ describe("Redis application cache", () => {
|
|||||||
let fetches = 0;
|
let fetches = 0;
|
||||||
const fetch = async () => ({ revision: ++fetches });
|
const fetch = async () => ({ revision: ++fetches });
|
||||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||||
expect(await appRedis?.get(key)).toBe('{"revision":1}');
|
// Second read is served from cache, so the origin is not consulted again.
|
||||||
|
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||||
cache.invalidateMemory(key);
|
cache.invalidateMemory(key);
|
||||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||||
@@ -395,6 +409,9 @@ describe("Redis application cache", () => {
|
|||||||
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
||||||
"updated",
|
"updated",
|
||||||
);
|
);
|
||||||
|
// `second`'s memory copy was dropped too, but its Redis entry survives, so
|
||||||
|
// the read is served from the shared cache and never recomputes. This is
|
||||||
|
// what makes the two entries independent.
|
||||||
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
||||||
"second",
|
"second",
|
||||||
);
|
);
|
||||||
@@ -612,6 +629,9 @@ describe("real news publication, scheduling and cache delivery", () => {
|
|||||||
expect(existing.status).toBe("draft");
|
expect(existing.status).toBe("draft");
|
||||||
expect(existing.publishedAt).toBeNull();
|
expect(existing.publishedAt).toBeNull();
|
||||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
|
// A draft has no public article, so this read is a negative result that
|
||||||
|
// gets cached. Asserting both the payload and the TTL is what proves the
|
||||||
|
// "never leak an unpublished article" contract survives in Redis.
|
||||||
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||||
expect(await appRedis?.get(negativeKey)).toBe("null");
|
expect(await appRedis?.get(negativeKey)).toBe("null");
|
||||||
@@ -831,6 +851,7 @@ describe("real news publication, scheduling and cache delivery", () => {
|
|||||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||||
|
// Cached negative results are stored as the JSON encoding of null.
|
||||||
expect(await redis.get(negativeKey)).toBe("null");
|
expect(await redis.get(negativeKey)).toBe("null");
|
||||||
const publish = articleForm({
|
const publish = articleForm({
|
||||||
id: String(existing.id),
|
id: String(existing.id),
|
||||||
|
|||||||
@@ -1,7 +1,47 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This host runs with `vm.overcommit_memory=0` and no swap, so a process that
|
||||||
|
* asks for more memory than is free gets OOM-killed by the kernel immediately.
|
||||||
|
* The killer picks its victim across the WHOLE machine — an unbounded build can
|
||||||
|
* take down the database, nginx and the live release with it.
|
||||||
|
*
|
||||||
|
* `scripts/with-memory-cap.sh` runs a heavy command in its own cgroup with a
|
||||||
|
* hard `MemoryMax`, so only that build dies and the site keeps serving. Every
|
||||||
|
* script in package.json goes through it.
|
||||||
|
*
|
||||||
|
* The one hole that leaves is running the builder by hand: `npx next build`,
|
||||||
|
* `pnpm exec next build`, or an IDE/agent task invoking it directly skips the
|
||||||
|
* wrapper entirely and is unbounded. This guard closes that. `next build`
|
||||||
|
* loads the config, so refusing here stops the build before it allocates
|
||||||
|
* anything. See the header of scripts/with-memory-cap.sh.
|
||||||
|
*/
|
||||||
|
function assertMemoryCapped(phase: string): void {
|
||||||
|
if (phase !== PHASE_PRODUCTION_BUILD) return;
|
||||||
|
if (process.env.CMS_MEMORY_CAPPED === "1") return;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Refusing to run an uncapped production build.",
|
||||||
|
"",
|
||||||
|
"On this host an unbounded `next build` gets OOM-killed by the kernel,",
|
||||||
|
"and the killer may take the database, nginx or the live release with it.",
|
||||||
|
"",
|
||||||
|
"Use the capped build instead:",
|
||||||
|
" pnpm build",
|
||||||
|
"",
|
||||||
|
"It runs the builder through scripts/with-memory-cap.sh, which puts it in",
|
||||||
|
"its own cgroup with a MemoryMax, so a runaway build fails alone.",
|
||||||
|
"",
|
||||||
|
"Already inside an isolated environment (Docker, a CI runner) where the",
|
||||||
|
"container itself is the boundary? Set CMS_MEMORY_CAPPED=1 explicitly.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getGitCommit = () => {
|
const getGitCommit = () => {
|
||||||
try {
|
try {
|
||||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||||
@@ -36,7 +76,18 @@ const nextConfig: NextConfig = {
|
|||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
compress: true,
|
compress: true,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
|
// `isomorphic-dompurify` builds a DOM through jsdom on the server. Bundled,
|
||||||
|
// it drags jsdom's `browser/default-stylesheet.css` into the server chunk,
|
||||||
|
// where the path no longer exists and page-data collection dies with ENOENT
|
||||||
|
// on any page that sanitizes HTML. Kept external, Node resolves it from
|
||||||
|
// node_modules at runtime and the standalone output traces it in.
|
||||||
|
serverExternalPackages: [
|
||||||
|
"lzma-wasm",
|
||||||
|
"sharp",
|
||||||
|
"pino",
|
||||||
|
"pino-pretty",
|
||||||
|
"isomorphic-dompurify",
|
||||||
|
],
|
||||||
|
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
@@ -163,4 +214,9 @@ const nextConfig: NextConfig = {
|
|||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
// Exported as a function so the build phase is known before the config is
|
||||||
|
// used. next-intl only accepts a plain object, so it is applied here.
|
||||||
|
export default function config(phase: string) {
|
||||||
|
assertMemoryCapped(phase);
|
||||||
|
return withNextIntl(nextConfig);
|
||||||
|
}
|
||||||
@@ -3,22 +3,24 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=26.8.2 <27"
|
"node": ">=26.10.0 <27"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]5.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
"packageManager": "pnpm@12.11.2+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm assets:editor && next dev",
|
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev",
|
||||||
"build": "pnpm assets:editor && next build",
|
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||||
|
"readme:versions": "node scripts/sync-readme-versions.mjs",
|
||||||
|
"readme:versions:check": "node scripts/sync-readme-versions.mjs --check",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"biome:lint": "biome check .",
|
"biome:lint": "biome check .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||||
"test": "vitest run --coverage.enabled=false",
|
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
|
||||||
"test:coverage": "vitest run",
|
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:introspect": "drizzle-kit introspect",
|
"db:introspect": "drizzle-kit introspect",
|
||||||
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
||||||
@@ -27,85 +29,90 @@
|
|||||||
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
|
"catalog:sync": "bash scripts/catalog-sync.sh",
|
||||||
|
"catalog:sync:cli": "node --conditions=react-server --import tsx scripts/catalog-sync.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||||
"deps:audit": "pnpm audit --audit-level=high",
|
"deps:audit": "pnpm audit --audit-level=high",
|
||||||
"analyze": "next experimental-analyze",
|
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
|
||||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||||
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test",
|
||||||
"test:news:real": "node --import tsx e2e/news-real/run.ts",
|
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
|
||||||
"test:ui": "playwright test --config playwright.ui.config.ts",
|
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
|
||||||
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||||
|
"test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts",
|
||||||
|
"test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots",
|
||||||
|
"test:e2e:quality": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.quality.config.ts",
|
||||||
"performance:report": "node scripts/performance-report.mjs",
|
"performance:report": "node scripts/performance-report.mjs",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts"
|
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.8.0",
|
"@base-ui/react": "1.9.0",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.19",
|
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.103.1",
|
"@tanstack/react-query": "5.104.3",
|
||||||
"@tanstack/react-virtual": "3.14.13",
|
"@tanstack/react-virtual": "3.14.19",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"croner": "10.0.1",
|
"croner": "10.0.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.4",
|
||||||
"hash-wasm": "4.12.0",
|
"hash-wasm": "4.12.0",
|
||||||
"ioredis": "6.0.0",
|
"ioredis": "6.0.0",
|
||||||
"isomorphic-dompurify": "^4.2.0",
|
"isomorphic-dompurify": "^4.5.0",
|
||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.2",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.57.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "13.4.0",
|
"motion": "14.2.0",
|
||||||
"music-metadata": "11.15.0",
|
"music-metadata": "12.2.1",
|
||||||
"mysql2": "3.24.4",
|
"mysql2": "3.24.6",
|
||||||
"next": "16.3.5",
|
"next": "16.4.1",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.5",
|
"next-intl": "4.14.9",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.3.1",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.1",
|
||||||
"react-hook-form": "7.88.0",
|
"react-hook-form": "7.90.0",
|
||||||
"resend": "6.28.1",
|
"resend": "6.33.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.7.0",
|
"tailwind-merge": "3.7.0",
|
||||||
"tinymce": "8.9.1",
|
"tinymce": "8.10.0",
|
||||||
"zod": "4.6.5"
|
"zod": "4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/parser": "7.29.8",
|
"@babel/parser": "8.0.7",
|
||||||
"@biomejs/biome": "2.5.14",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.64.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@types/node": "26.4.1",
|
"@types/node": "26.6.5",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.2.7",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitest/coverage-v8": "5.0.1",
|
"@vitest/coverage-v8": "5.0.4",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.11",
|
||||||
"esbuild": "0.28.2",
|
"esbuild": "0.28.2",
|
||||||
"msw": "2.15.0",
|
"msw": "^3.0.3",
|
||||||
"pino-pretty": "13.1.3",
|
"pino-pretty": "13.2.0",
|
||||||
"postcss": "8.5.28",
|
"postcss": "8.5.30",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"testcontainers": "12.1.0",
|
"testcontainers": "12.2.1",
|
||||||
"tsx": "4.23.13",
|
"tsx": "4.23.15",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.4",
|
||||||
"vitest": "5.0.1"
|
"vitest": "5.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
testIgnore: ["**/ui/**", "**/news-real/**"],
|
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 1,
|
retries: 1,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
reporter: [["list"], ["html", { open: "never" }]],
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
|
expect: {
|
||||||
|
toHaveScreenshot: {
|
||||||
|
animations: "disabled",
|
||||||
|
caret: "hide",
|
||||||
|
maxDiffPixelRatio: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Cross-browser imaging checks. Unlike the main e2e config (chromium only,
|
||||||
|
// used as the deploy gate), this runs the same specs across every bundled
|
||||||
|
// engine so an avatar format that only one browser can decode is caught.
|
||||||
|
//
|
||||||
|
// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local
|
||||||
|
// host port). Requires all three engines: `pnpm exec playwright install
|
||||||
|
// chromium firefox webkit`.
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/imaging",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
expect: {
|
||||||
|
timeout: 15000,
|
||||||
|
toHaveScreenshot: {
|
||||||
|
animations: "disabled",
|
||||||
|
caret: "hide",
|
||||||
|
maxDiffPixelRatio: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/imaging", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/imaging",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||||
|
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Advisory quality suite. Unlike the main e2e config (the blocking deploy
|
||||||
|
// gate), this checks things that depend on live content and data — broken
|
||||||
|
// uploads, legacy malformed avatar figures, warm-page latency. Those are real
|
||||||
|
// signals but they are not something a code change can guarantee, so they must
|
||||||
|
// never block a deploy. Point it at a running CMS with PLAYWRIGHT_BASE_URL
|
||||||
|
// (defaults to the local host port).
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/quality",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
expect: { timeout: 15000 },
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/quality", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/quality",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
'@swc/core': true
|
||||||
|
cpu-features: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
sharp: true
|
msw: true
|
||||||
"@parcel/watcher": true
|
protobufjs: true
|
||||||
"@swc/core": true
|
ssh2: true
|
||||||
msw: false
|
|
||||||
cpu-features: false
|
|
||||||
protobufjs: false
|
|
||||||
ssh2: false
|
|
||||||
|
|
||||||
minimumReleaseAge: 60
|
minimumReleaseAge: 60
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
# drizzle-kit still uses an obsolete development-server dependency.
|
glob: '^11.0.0'
|
||||||
"@esbuild-kit/core-utils>esbuild": "^0.25.9"
|
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
||||||
"@types/react": "19.2.18"
|
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
||||||
"@types/react-dom": "19.2.7"
|
source-map-js: '1.2.2'
|
||||||
|
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
|
||||||
|
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
|
||||||
|
# exactly and has no upstream bump. The API subset it uses is unchanged in
|
||||||
|
# v7, so force the patched major.
|
||||||
|
postcss-selector-parser: '^7.1.6'
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
const CACHE = "atom-v3";
|
// Retired service worker — this file no longer serves anything.
|
||||||
const API_CACHE = "atom-api-v3";
|
//
|
||||||
|
// The app used to register a worker that cached /_next/static/ and /assets/
|
||||||
|
// cache-first under a cache name with no build id. A release could not
|
||||||
|
// invalidate it, so browsers replayed the previous release's chunks against
|
||||||
|
// the new HTML and React never hydrated, which is what left the Catalog
|
||||||
|
// Studio on a blank white page. It also bought nothing: nginx already sends
|
||||||
|
// those two prefixes as `max-age=31536000, immutable` and their filenames are
|
||||||
|
// content-hashed, so the HTTP cache already handles them correctly.
|
||||||
|
//
|
||||||
|
// This file must stay at /sw.js and keep its install/activate handlers.
|
||||||
|
// Simply deleting it would leave every existing registration alive and in
|
||||||
|
// control of the page, with the old caching still running. Instead this
|
||||||
|
// worker does the opposite of what it used to: it deletes every cache and
|
||||||
|
// unregisters itself, then reloads open clients so they stop being
|
||||||
|
// controlled by it.
|
||||||
|
//
|
||||||
|
// Nothing registers it any more (see src/app/layout.tsx), so this only ever
|
||||||
|
// runs for browsers that already have a worker installed. Once a visitor
|
||||||
|
// loads the site again it uninstalls itself and never comes back.
|
||||||
|
//
|
||||||
|
// The manifest is unrelated and untouched — it is generated by
|
||||||
|
// src/app/manifest.ts and keeps the site installable without a worker.
|
||||||
|
|
||||||
self.addEventListener("install", () => self.skipWaiting());
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
|
||||||
@@ -7,61 +28,13 @@ self.addEventListener("activate", (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches
|
||||||
.keys()
|
.keys()
|
||||||
.then((keys) =>
|
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
|
||||||
Promise.all(
|
.then(() => self.registration.unregister())
|
||||||
keys
|
.then(() =>
|
||||||
.filter((k) => k !== CACHE && k !== API_CACHE)
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
|
||||||
.map((k) => caches.delete(k)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.then(() => self.clients.claim()),
|
.then((clients) => {
|
||||||
|
for (const client of clients) client.navigate(client.url);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
|
||||||
const req = event.request;
|
|
||||||
if (req.method !== "GET") return;
|
|
||||||
const url = new URL(req.url);
|
|
||||||
if (url.origin !== self.location.origin) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.pathname.startsWith("/assets/") ||
|
|
||||||
url.pathname.startsWith("/_next/static/")
|
|
||||||
) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(CACHE).then((cache) =>
|
|
||||||
cache.match(req).then(
|
|
||||||
(hit) =>
|
|
||||||
hit ||
|
|
||||||
fetch(req).then((res) => {
|
|
||||||
if (res.ok) cache.put(req, res.clone());
|
|
||||||
return res;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.pathname.startsWith("/api/home") ||
|
|
||||||
url.pathname.startsWith("/api/online") ||
|
|
||||||
url.pathname.startsWith("/api/radio/config")
|
|
||||||
) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(API_CACHE).then((cache) =>
|
|
||||||
fetch(req)
|
|
||||||
.then((res) => {
|
|
||||||
if (res.ok) cache.put(req, res.clone());
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => cache.match(req)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.mode === "navigate") {
|
|
||||||
event.respondWith(fetch(req));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
@@ -20,13 +20,59 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
|||||||
| `perf-report.sh` | Runs performance profiling. |
|
| `perf-report.sh` | Runs performance profiling. |
|
||||||
| `setup-dev.sh` | Configures development environment. |
|
| `setup-dev.sh` | Configures development environment. |
|
||||||
| `check-updates.sh` | Checks for package and image updates. |
|
| `check-updates.sh` | Checks for package and image updates. |
|
||||||
|
| `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. |
|
||||||
| `maintenance.sh` | Toggles maintenance mode flag. |
|
| `maintenance.sh` | Toggles maintenance mode flag. |
|
||||||
| `doctor.sh` | Runs a system diagnostic report. |
|
| `doctor.sh` | Runs a system diagnostic report. |
|
||||||
| `setup-cron.sh` | Configures automated cron jobs. |
|
| `setup-cron.sh` | Configures automated cron jobs. |
|
||||||
| `alert.sh` | Sends notifications to a configured webhook. |
|
| `alert.sh` | Sends notifications to a configured webhook. |
|
||||||
|
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Run any script directly, or use the dashboard:
|
Run any script directly, or use the dashboard:
|
||||||
```bash
|
```bash
|
||||||
./scripts/dashboard.sh
|
./scripts/dashboard.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Keeping the README versions in sync
|
||||||
|
|
||||||
|
Bumping a dependency (or the pinned Node.js version) leaves `README.md` stale.
|
||||||
|
Rather than editing it by hand, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm readme:versions # rewrite README.md to match package.json / .nvmrc
|
||||||
|
pnpm readme:versions:check # verify only; exits 1 when out of date (used in CI)
|
||||||
|
```
|
||||||
|
|
||||||
|
It manages the `pnpm` and `Node.js` rows of the System Requirements table and
|
||||||
|
the "Core stack" line. The CI `check` job runs the `--check` variant, so a
|
||||||
|
version change cannot reach `main` with a stale README — the pipeline fails
|
||||||
|
until you run `pnpm readme:versions`.
|
||||||
|
|
||||||
|
### Updating the catalog (`catalog-sync.sh`)
|
||||||
|
|
||||||
|
Compares this hotel's catalog against a clone source (Habbo and the retros) and
|
||||||
|
fixes what is broken: missing furniture icons, missing `.nitro` bundles,
|
||||||
|
`FurnitureData.json` drift, orphaned/duplicate catalog rows, and items that
|
||||||
|
exist in `items_base` but have no catalog offer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm catalog:sync # interactive menu
|
||||||
|
./scripts/catalog-sync.sh --list-sources # which sites can be compared
|
||||||
|
./scripts/catalog-sync.sh --all --sources=default-habbo-com
|
||||||
|
./scripts/catalog-sync.sh --all --sql --apply-sql # catalogs changes are written
|
||||||
|
```
|
||||||
|
|
||||||
|
The menu asks which sources to compare (Habbo (COM) is the preset) and what may
|
||||||
|
be written. With no arguments it needs a terminal; with any flag it skips the
|
||||||
|
menu, so it is usable from cron.
|
||||||
|
|
||||||
|
Safety notes:
|
||||||
|
|
||||||
|
- **An audit writes nothing** — no repair flag means read-only.
|
||||||
|
- `--apply-sql` is the only flag that changes the catalog, and the generated SQL
|
||||||
|
is always written to `storage/catalog-sync/*.sql` (gitignored) so it can be
|
||||||
|
reviewed or replayed by hand.
|
||||||
|
- The engine is the same one the admin *Catalog audit* page streams
|
||||||
|
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
|
||||||
|
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
|
||||||
|
error-severity issues remain, which is what a scheduled check should use.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Update the hotel catalog from a clone source (Habbo, retros, …).
|
||||||
|
#
|
||||||
|
# Two ways to use it:
|
||||||
|
# ./scripts/catalog-sync.sh interactive menu
|
||||||
|
# ./scripts/catalog-sync.sh --all --sql same flags as the CLI, no menu
|
||||||
|
#
|
||||||
|
# The menu asks which sites to compare (Habbo is the preset) and what may be
|
||||||
|
# written. An audit is read-only; only a repair option touches the catalog, and
|
||||||
|
# applying the generated catalog SQL always needs its own choice.
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
CLI="scripts/catalog-sync.ts"
|
||||||
|
# The react-server export condition resolves `server-only` to its no-op build,
|
||||||
|
# exactly like `pnpm jobs:worker` — the audit engine imports server modules.
|
||||||
|
RUN=(node --conditions=react-server --import tsx "$CLI")
|
||||||
|
|
||||||
|
# Any flag passed straight through means "no menu", so the same entry point
|
||||||
|
# works for cron/CI where only the flag path is safe.
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
exec "${RUN[@]}" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "Not a terminal. Use the non-interactive form, e.g.:" >&2
|
||||||
|
echo " $0 --all --sources=default-habbo-com" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Catalog sync ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── Step 1: which sites to compare ───────────────────────────────────────────
|
||||||
|
# Ask the CLI for the configured sources so the menu always matches the live
|
||||||
|
# `clone_sources` setting (and its defaults) instead of a hardcoded list.
|
||||||
|
mapfile -t SOURCE_ROWS < <("${RUN[@]}" --list-sources --tsv)
|
||||||
|
if [ "${#SOURCE_ROWS[@]}" -eq 0 ]; then
|
||||||
|
echo "No clone sources are configured." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IDS=()
|
||||||
|
NAMES=()
|
||||||
|
echo "Compare this catalog against:"
|
||||||
|
for i in "${!SOURCE_ROWS[@]}"; do
|
||||||
|
IFS=$'\t' read -r SOURCE_ID SOURCE_NAME _SOURCE_URL <<<"${SOURCE_ROWS[$i]}"
|
||||||
|
IDS+=("$SOURCE_ID")
|
||||||
|
NAMES+=("$SOURCE_NAME")
|
||||||
|
printf " %2d) %-24s %s\n" "$((i + 1))" "$SOURCE_NAME" "$SOURCE_ID"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo " a) all sources above"
|
||||||
|
echo " Enter) preset: ${NAMES[0]}"
|
||||||
|
|
||||||
|
read -r -p "Sources [a, 1-${#IDS[@]}, Enter]: " SOURCE_CHOICE
|
||||||
|
|
||||||
|
SELECTED_FLAG=""
|
||||||
|
case "$SOURCE_CHOICE" in
|
||||||
|
"" | "Habbo" | "habbo" | "habbo.com")
|
||||||
|
# Habbo is the first default preset (Habbo (COM)).
|
||||||
|
SELECTED_FLAG="--sources=${IDS[0]}"
|
||||||
|
SOURCE_LABEL="${NAMES[0]}"
|
||||||
|
;;
|
||||||
|
a | A | all)
|
||||||
|
SOURCE_LABEL="all sources"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
SELECTED_IDS=()
|
||||||
|
IFS=', ' read -r -a CHOICES <<<"$SOURCE_CHOICE"
|
||||||
|
for CHOICE in "${CHOICES[@]}"; do
|
||||||
|
[ -z "$CHOICE" ] && continue
|
||||||
|
case "$CHOICE" in
|
||||||
|
*[!0-9]* | "")
|
||||||
|
echo "Invalid choice: '$CHOICE'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#IDS[@]}" ]; then
|
||||||
|
echo "Out of range: $CHOICE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECTED_IDS+=("${IDS[$((CHOICE - 1))]}")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "${#SELECTED_IDS[@]}" -eq 0 ]; then
|
||||||
|
echo "No sources selected." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECTED_FLAG="--sources=$(IFS=,; echo "${SELECTED_IDS[*]}")"
|
||||||
|
SOURCE_LABEL="${#SELECTED_IDS[@]} source(s)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── Step 2: what may be changed ──────────────────────────────────────────────
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
What should be done? (comparing with: $SOURCE_LABEL)
|
||||||
|
1) Audit only — nothing is written
|
||||||
|
2) Repair furniture icons + nitro bundles
|
||||||
|
3) 2 + FurnitureData.json + catalog structure
|
||||||
|
4) 3 + generate the catalog SQL (saved, not applied)
|
||||||
|
5) 4 + APPLY the catalog SQL to the catalog
|
||||||
|
q) Cancel
|
||||||
|
EOF
|
||||||
|
|
||||||
|
read -r -p "Action [1]: " ACTION_CHOICE
|
||||||
|
ACTION_CHOICE="${ACTION_CHOICE:-1}"
|
||||||
|
|
||||||
|
ACTIONS=(audit)
|
||||||
|
CONFIRM=""
|
||||||
|
# Empty for the audit-only branch on purpose: no repair flags are passed.
|
||||||
|
ACTION_FLAGS=()
|
||||||
|
case "$ACTION_CHOICE" in
|
||||||
|
1)
|
||||||
|
ACTIONS=(audit "[nothing written]")
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
ACTIONS=(audit "icons + nitro bundles downloaded")
|
||||||
|
ACTION_FLAGS=(--icons --nitros)
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
ACTIONS=(audit "icons + nitro bundles + FurnitureData.json + structure")
|
||||||
|
ACTION_FLAGS=(--all)
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
ACTIONS=(audit "everything + catalog SQL generated")
|
||||||
|
ACTION_FLAGS=(--all --sql)
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
ACTIONS=(audit "everything + catalog SQL APPLIED")
|
||||||
|
ACTION_FLAGS=(--all --sql --apply-sql)
|
||||||
|
CONFIRM="This writes to the catalog. Continue? [y/N]: "
|
||||||
|
;;
|
||||||
|
q | Q)
|
||||||
|
echo "Cancelled."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid action: '$ACTION_CHOICE'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf 'Running: %s against %s\n' "${ACTIONS[1]}" "$SOURCE_LABEL"
|
||||||
|
if [ -n "$CONFIRM" ]; then
|
||||||
|
read -r -p "$CONFIRM" REPLY
|
||||||
|
if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then
|
||||||
|
echo "Cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
"${RUN[@]}" $SELECTED_FLAG "${ACTION_FLAGS[@]}"
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_CATALOG_SYNC_FLAGS,
|
||||||
|
describeActions,
|
||||||
|
parseCatalogSyncArgs,
|
||||||
|
writeGeneratedSql,
|
||||||
|
} from "./catalog-sync";
|
||||||
|
|
||||||
|
describe("catalog-sync argument parsing", () => {
|
||||||
|
it("defaults to a read-only audit with no filters", () => {
|
||||||
|
expect(parseCatalogSyncArgs([])).toEqual({
|
||||||
|
...DEFAULT_CATALOG_SYNC_FLAGS,
|
||||||
|
sourceIds: [],
|
||||||
|
});
|
||||||
|
expect(describeActions(parseCatalogSyncArgs([]))).toBe("audit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts comma-separated source ids", () => {
|
||||||
|
const flags = parseCatalogSyncArgs([
|
||||||
|
"--sources=default-habbo-com,default-wibbo",
|
||||||
|
]);
|
||||||
|
expect(flags.sourceIds).toEqual(["default-habbo-com", "default-wibbo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts display names, including ones with spaces", () => {
|
||||||
|
const flags = parseCatalogSyncArgs(["--sources=Habbo (COM), Wibbo"]);
|
||||||
|
expect(flags.sourceIds).toEqual(["Habbo (COM)", "Wibbo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims and drops empty source entries", () => {
|
||||||
|
expect(parseCatalogSyncArgs(["--sources= a , ,b,"]).sourceIds).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns --all into every repair step", () => {
|
||||||
|
const flags = parseCatalogSyncArgs(["--all"]);
|
||||||
|
expect(flags.repairIcons).toBe(true);
|
||||||
|
expect(flags.repairNitros).toBe(true);
|
||||||
|
expect(flags.repairFurniData).toBe(true);
|
||||||
|
expect(flags.repairStructure).toBe(true);
|
||||||
|
expect(flags.checkFurniDataIds).toBe(true);
|
||||||
|
// Generating/applying SQL is never implied by a repair.
|
||||||
|
expect(flags.generateSql).toBe(false);
|
||||||
|
expect(flags.applySql).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats applying SQL as implying generation", () => {
|
||||||
|
expect(parseCatalogSyncArgs(["--apply-sql"]).generateSql).toBe(true);
|
||||||
|
expect(parseCatalogSyncArgs(["--organize-sql"]).generateSql).toBe(true);
|
||||||
|
expect(describeActions(parseCatalogSyncArgs(["--apply-sql"]))).toBe(
|
||||||
|
"audit + apply-sql",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the alias spellings of the repair flags", () => {
|
||||||
|
const flags = parseCatalogSyncArgs([
|
||||||
|
"--repair-icons",
|
||||||
|
"--repair-nitros",
|
||||||
|
"--repair-furnidata",
|
||||||
|
"--repair-structure",
|
||||||
|
]);
|
||||||
|
expect(flags.repairIcons).toBe(true);
|
||||||
|
expect(flags.repairNitros).toBe(true);
|
||||||
|
expect(flags.repairFurniData).toBe(true);
|
||||||
|
expect(flags.repairStructure).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a bare -- separator", () => {
|
||||||
|
expect(parseCatalogSyncArgs(["--", "--icons"]).repairIcons).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown flag", () => {
|
||||||
|
expect(() => parseCatalogSyncArgs(["--nope"])).toThrow(/Unknown flag/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects --sources without a value", () => {
|
||||||
|
expect(() => parseCatalogSyncArgs(["--sources"])).toThrow(/needs a value/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generated catalog SQL output", () => {
|
||||||
|
let cwd = "";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cwd = process.cwd();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.chdir(cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the SQL under storage/catalog-sync with a trailing newline", async () => {
|
||||||
|
let target = "";
|
||||||
|
try {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "catalog-sync-sql-"));
|
||||||
|
target = dir;
|
||||||
|
process.chdir(dir);
|
||||||
|
const file = await writeGeneratedSql("INSERT INTO `catalog_items` (1);");
|
||||||
|
expect(file.startsWith("storage/catalog-sync/")).toBe(true);
|
||||||
|
expect(file.endsWith(".sql")).toBe(true);
|
||||||
|
expect(readFileSync(join(dir, file), "utf8")).toBe(
|
||||||
|
"INSERT INTO `catalog_items` (1);\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (target) rmSync(target, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
// Command-line driver for the catalog audit/repair pipeline.
|
||||||
|
//
|
||||||
|
// The same engine the admin "Catalog audit" UI streams from (`runCatalogAudit`),
|
||||||
|
// for the terminal: audit the hotel catalog against one or more clone sources
|
||||||
|
// (Habbo and the retros), fix the furniture that is broken, and bring missing
|
||||||
|
// items into the catalog — optionally writing and applying the catalog SQL.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// pnpm catalog:sync # interactive menu (scripts/catalog-sync.sh)
|
||||||
|
// pnpm exec tsx scripts/catalog-sync.ts --list-sources
|
||||||
|
// pnpm exec tsx scripts/catalog-sync.ts --sources="Habbo (COM)" # audit only
|
||||||
|
// pnpm exec tsx scripts/catalog-sync.ts --all --sources=default-habbo-com
|
||||||
|
// pnpm exec tsx scripts/catalog-sync.ts --all --sql --apply-sql # + write/apply SQL
|
||||||
|
//
|
||||||
|
// Flags:
|
||||||
|
// --list-sources print the configured clone sources and exit
|
||||||
|
// --tsv with the above, print `id\tname\tfurnidataUrl` rows
|
||||||
|
// --sources=<a,b> compare against these sources (id or name, comma-separated)
|
||||||
|
// --icons re-download missing furniture icons from the sources
|
||||||
|
// --nitros re-download missing .nitro / swf bundles
|
||||||
|
// --furnidata repair FurnitureData.json (add missing, drop duplicates)
|
||||||
|
// --structure repair orphaned / duplicate catalog rows
|
||||||
|
// --check-ids check FurnitureData.json for spriteId conflicts
|
||||||
|
// --sql generate the catalog SQL for items missing an offer
|
||||||
|
// --apply-sql apply the generated SQL (implies --sql)
|
||||||
|
// --organize-sql order the generated SQL by catalog page (implies --sql)
|
||||||
|
// --all --icons --nitros --furnidata --structure --check-ids
|
||||||
|
// --json print one machine-readable JSON report
|
||||||
|
// --fail-on-errors exit 3 when error-severity issues remain
|
||||||
|
//
|
||||||
|
// Audit is read-only by default: nothing is written until one of the repair
|
||||||
|
// flags is passed, and `--apply-sql` is the only flag that changes the catalog.
|
||||||
|
|
||||||
|
import "./load-env";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { AuditEvent, AuditSummary } from "@/lib/services/catalog-audit";
|
||||||
|
|
||||||
|
/** Where generated catalog SQL is written (`storage/` is gitignored). */
|
||||||
|
function sqlOutputDir(): string {
|
||||||
|
return join(process.cwd(), "storage", "catalog-sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceRef = { id: string; name: string; furnidataUrl: string };
|
||||||
|
|
||||||
|
export type AuditIssue = NonNullable<AuditEvent["issues"]>[number];
|
||||||
|
export type MissingFromSource = NonNullable<
|
||||||
|
AuditEvent["missingFromSources"]
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
export interface AuditReport {
|
||||||
|
sources: SourceRef[];
|
||||||
|
summary?: AuditSummary;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
missingFromSources: MissingFromSource[];
|
||||||
|
sqlFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogSyncFlags {
|
||||||
|
listSources: boolean;
|
||||||
|
tsv: boolean;
|
||||||
|
sourceIds: string[];
|
||||||
|
repairIcons: boolean;
|
||||||
|
repairNitros: boolean;
|
||||||
|
repairFurniData: boolean;
|
||||||
|
repairStructure: boolean;
|
||||||
|
checkFurniDataIds: boolean;
|
||||||
|
generateSql: boolean;
|
||||||
|
applySql: boolean;
|
||||||
|
organizeSql: boolean;
|
||||||
|
json: boolean;
|
||||||
|
failOnErrors: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CATALOG_SYNC_FLAGS: Omit<CatalogSyncFlags, "sourceIds"> = {
|
||||||
|
listSources: false,
|
||||||
|
tsv: false,
|
||||||
|
repairIcons: false,
|
||||||
|
repairNitros: false,
|
||||||
|
repairFurniData: false,
|
||||||
|
repairStructure: false,
|
||||||
|
checkFurniDataIds: false,
|
||||||
|
generateSql: false,
|
||||||
|
applySql: false,
|
||||||
|
organizeSql: false,
|
||||||
|
json: false,
|
||||||
|
failOnErrors: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOOLEAN_FLAGS: Record<string, keyof CatalogSyncFlags> = {
|
||||||
|
"--list-sources": "listSources",
|
||||||
|
"--tsv": "tsv",
|
||||||
|
"--icons": "repairIcons",
|
||||||
|
"--repair-icons": "repairIcons",
|
||||||
|
"--nitros": "repairNitros",
|
||||||
|
"--repair-nitros": "repairNitros",
|
||||||
|
"--furnidata": "repairFurniData",
|
||||||
|
"--repair-furnidata": "repairFurniData",
|
||||||
|
"--structure": "repairStructure",
|
||||||
|
"--repair-structure": "repairStructure",
|
||||||
|
"--check-ids": "checkFurniDataIds",
|
||||||
|
"--sql": "generateSql",
|
||||||
|
"--apply-sql": "applySql",
|
||||||
|
"--organize-sql": "organizeSql",
|
||||||
|
"--json": "json",
|
||||||
|
"--fail-on-errors": "failOnErrors",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
|
||||||
|
const flags: CatalogSyncFlags = {
|
||||||
|
...DEFAULT_CATALOG_SYNC_FLAGS,
|
||||||
|
sourceIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const arg of raw) {
|
||||||
|
if (arg === "--") continue;
|
||||||
|
|
||||||
|
if (arg === "--all") {
|
||||||
|
flags.repairIcons = true;
|
||||||
|
flags.repairNitros = true;
|
||||||
|
flags.repairFurniData = true;
|
||||||
|
flags.repairStructure = true;
|
||||||
|
flags.checkFurniDataIds = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = arg.indexOf("=");
|
||||||
|
const name = separator >= 0 ? arg.slice(0, separator) : arg;
|
||||||
|
|
||||||
|
const booleanFlag = BOOLEAN_FLAGS[name];
|
||||||
|
if (booleanFlag) {
|
||||||
|
flags[booleanFlag] = true as never;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "--sources" || name === "--source") {
|
||||||
|
if (separator < 0)
|
||||||
|
throw new Error(
|
||||||
|
`${name} needs a value, e.g. --sources=default-habbo-com`,
|
||||||
|
);
|
||||||
|
const value = arg.slice(separator + 1);
|
||||||
|
for (const part of value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean))
|
||||||
|
flags.sourceIds.push(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unknown flag: ${arg} (run with --help for the supported flags)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applying SQL implies generating it, and organising it implies generating.
|
||||||
|
if (flags.applySql) flags.generateSql = true;
|
||||||
|
if (flags.organizeSql) flags.generateSql = true;
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable list of what the run will do. */
|
||||||
|
export function describeActions(flags: CatalogSyncFlags): string {
|
||||||
|
const actions = ["audit"];
|
||||||
|
if (flags.repairIcons) actions.push("icons");
|
||||||
|
if (flags.repairNitros) actions.push("nitros");
|
||||||
|
if (flags.repairFurniData) actions.push("furnidata");
|
||||||
|
if (flags.repairStructure) actions.push("structure");
|
||||||
|
if (flags.checkFurniDataIds) actions.push("id-check");
|
||||||
|
if (flags.applySql) actions.push("apply-sql");
|
||||||
|
else if (flags.generateSql) actions.push("generate-sql");
|
||||||
|
if (flags.organizeSql) actions.push("organised");
|
||||||
|
return actions.join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the file is being executed, not imported by a test. */
|
||||||
|
function isEntryPoint(): boolean {
|
||||||
|
const entry = process.argv[1];
|
||||||
|
return (
|
||||||
|
Boolean(entry) && import.meta.url === pathToFileURL(String(entry)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSourceCatalogue(): Promise<SourceRef[]> {
|
||||||
|
const { listSources } = await import("@/lib/services/clone-sources");
|
||||||
|
const sources = await listSources();
|
||||||
|
return sources
|
||||||
|
.filter((source) => source.furnidataUrl)
|
||||||
|
.map((source) => ({
|
||||||
|
id: source.id,
|
||||||
|
name: source.name,
|
||||||
|
furnidataUrl: source.furnidataUrl,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commandListSources(flags: CatalogSyncFlags): Promise<number> {
|
||||||
|
const sources = await loadSourceCatalogue();
|
||||||
|
|
||||||
|
if (flags.tsv) {
|
||||||
|
for (const source of sources)
|
||||||
|
console.log([source.id, source.name, source.furnidataUrl].join("\t"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Configured clone sources (${sources.length}):`);
|
||||||
|
for (const source of sources)
|
||||||
|
console.log(` ${source.id}\t${source.name}\t${source.furnidataUrl}`);
|
||||||
|
console.log(
|
||||||
|
"\nCompare with: --sources=<id> (comma-separated; display names work too)",
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the generated SQL so it can be reviewed or replayed by hand. */
|
||||||
|
export async function writeGeneratedSql(sql: string): Promise<string> {
|
||||||
|
await mkdir(sqlOutputDir(), { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const file = join(sqlOutputDir(), `catalog-${stamp}.sql`);
|
||||||
|
await writeFile(file, sql.endsWith("\n") ? sql : `${sql}\n`, "utf8");
|
||||||
|
return file.replace(`${process.cwd()}/`, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
|
||||||
|
const catalogue = await loadSourceCatalogue();
|
||||||
|
|
||||||
|
// Resolve the selection up-front so a typo fails before anything is written.
|
||||||
|
const known = new Set<string>();
|
||||||
|
for (const source of catalogue) {
|
||||||
|
known.add(source.id);
|
||||||
|
known.add(source.name);
|
||||||
|
}
|
||||||
|
const unknown = flags.sourceIds.filter((id) => !known.has(id));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Unknown source: ${unknown.join(", ")}\nRun --list-sources to see the configured sites.`,
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = flags.sourceIds.length
|
||||||
|
? catalogue.filter(
|
||||||
|
(source) =>
|
||||||
|
flags.sourceIds.includes(source.id) ||
|
||||||
|
flags.sourceIds.includes(source.name),
|
||||||
|
)
|
||||||
|
: catalogue;
|
||||||
|
|
||||||
|
const { runCatalogAudit } = await import("@/lib/services/catalog-audit");
|
||||||
|
|
||||||
|
const report: AuditReport = {
|
||||||
|
sources: selected,
|
||||||
|
issues: [],
|
||||||
|
missingFromSources: [],
|
||||||
|
};
|
||||||
|
const quiet = flags.json;
|
||||||
|
const log = (line: string) => {
|
||||||
|
if (!quiet) console.log(line);
|
||||||
|
};
|
||||||
|
const started = Date.now();
|
||||||
|
let pendingSql: string | undefined;
|
||||||
|
|
||||||
|
log(
|
||||||
|
`Catalog sync — ${selected.length} source(s), ${describeActions(flags)}` +
|
||||||
|
(flags.applySql ? " [SQL WILL BE APPLIED]" : ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runCatalogAudit(
|
||||||
|
(event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "progress":
|
||||||
|
log(`· ${event.message ?? ""}`);
|
||||||
|
break;
|
||||||
|
case "items_loaded":
|
||||||
|
log(`· items_base: ${event.total} rows`);
|
||||||
|
break;
|
||||||
|
case "catalog_loaded":
|
||||||
|
log(`· catalog_items: ${event.total} rows`);
|
||||||
|
break;
|
||||||
|
case "sql_ready":
|
||||||
|
log(`· catalog SQL generated: ${event.sqlCount ?? 0} statement(s)`);
|
||||||
|
pendingSql = event.sql;
|
||||||
|
break;
|
||||||
|
case "sql_applied":
|
||||||
|
log(
|
||||||
|
`· catalog SQL applied: ${event.applied ?? 0} (failed ${event.sqlFailed ?? 0})`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "furnidata_repair_complete":
|
||||||
|
log(
|
||||||
|
`· FurnitureData.json: +${event.furniData?.added ?? 0} entries, ${event.furniData?.removedDuplicates ?? 0} duplicate(s) removed`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "structure_repair_complete":
|
||||||
|
log(
|
||||||
|
`· structure: ${event.structure?.orphanedRemoved ?? 0} orphaned removed, ${event.structure?.duplicatesMerged ?? 0} duplicate(s) merged, ${event.structure?.remappedReferences ?? 0} reference(s) remapped`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "repair_complete":
|
||||||
|
log(
|
||||||
|
`· asset repair: ${event.repair?.succeeded ?? 0} ok, ${event.repair?.failed ?? 0} failed, ${event.repair?.skipped ?? 0} skipped`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "batch_complete":
|
||||||
|
report.issues = event.issues ?? [];
|
||||||
|
report.missingFromSources = event.missingFromSources ?? [];
|
||||||
|
report.summary = event.summary;
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
console.error(`! ${event.message ?? "audit error"}`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceIds: flags.sourceIds,
|
||||||
|
repair: flags.repairIcons,
|
||||||
|
repairNitros: flags.repairNitros,
|
||||||
|
sql: flags.generateSql,
|
||||||
|
applySql: flags.applySql,
|
||||||
|
organizeSql: flags.organizeSql,
|
||||||
|
repairFurniData: flags.repairFurniData,
|
||||||
|
repairStructure: flags.repairStructure,
|
||||||
|
checkFurniDataIds: flags.checkFurniDataIds,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingSql?.trim()) report.sqlFile = await writeGeneratedSql(pendingSql);
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{ flags, elapsedMs: Date.now() - started, ...report },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
printSummary(report, started);
|
||||||
|
if (report.sqlFile) console.log(`Catalog SQL: ${report.sqlFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = report.issues.filter((issue) => issue.severity === "error");
|
||||||
|
if (flags.failOnErrors && errors.length > 0) {
|
||||||
|
if (!quiet) console.error(`${errors.length} error(s) remain unresolved.`);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(report: AuditReport, started: number): void {
|
||||||
|
const summary = report.summary;
|
||||||
|
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||||
|
|
||||||
|
console.log(`\nDone in ${seconds}s`);
|
||||||
|
if (!summary) {
|
||||||
|
console.log("No summary was produced (the audit stopped early).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` items_base ${summary.totalItemsBase}`);
|
||||||
|
console.log(` catalog_items ${summary.totalCatalogItems}`);
|
||||||
|
console.log(` missing catalog entry ${summary.missingCatalog}`);
|
||||||
|
console.log(` orphaned catalog rows ${summary.orphanedCatalog}`);
|
||||||
|
console.log(` duplicate classnames ${summary.duplicates}`);
|
||||||
|
console.log(
|
||||||
|
` missing icons ${summary.missingIcon} (repaired ${summary.iconsRepaired}, still missing ${summary.iconsStillMissing})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` missing nitro bundles ${summary.missingNitro} (repaired ${summary.nitrosRepaired}, still missing ${summary.nitrosStillMissing})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` catalog SQL ${summary.catalogSqlCount} stmt, applied ${summary.catalogSqlApplied}`,
|
||||||
|
);
|
||||||
|
console.log(` furnidata added ${summary.furniDataAdded}`);
|
||||||
|
console.log(
|
||||||
|
` not restorable ${summary.unrepairableCount} (no icon/bundle on any compared source)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (report.missingFromSources.length > 0) {
|
||||||
|
const bySource = new Map<string, number>();
|
||||||
|
for (const entry of report.missingFromSources)
|
||||||
|
bySource.set(entry.sourceName, (bySource.get(entry.sourceName) ?? 0) + 1);
|
||||||
|
console.log(
|
||||||
|
`\n on compared sources but not in this hotel: ${report.missingFromSources.length}`,
|
||||||
|
);
|
||||||
|
for (const [name, count] of bySource)
|
||||||
|
console.log(` ${name.padEnd(24)} ${count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = report.issues.filter((issue) => issue.severity === "error");
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log(`\n unresolved errors: ${errors.length}`);
|
||||||
|
for (const issue of errors.slice(0, 20))
|
||||||
|
console.log(` ${issue.classname ?? issue.type}: ${issue.message}`);
|
||||||
|
if (errors.length > 20) console.log(` … and ${errors.length - 20} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const flags = parseCatalogSyncArgs(process.argv.slice(2));
|
||||||
|
try {
|
||||||
|
if (flags.listSources) return await commandListSources(flags);
|
||||||
|
return await commandRun(flags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEntryPoint()) {
|
||||||
|
main().then(
|
||||||
|
(code) => {
|
||||||
|
process.exit(code);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Keep the Cloudflare IP ranges in sync for BOTH proxy layers:
|
||||||
|
# 1) deployment/proxy/cloudflare-ips.conf (nginx: geo + set_real_ip_from)
|
||||||
|
# 2) entryPoints.websecure.forwardedHeaders.trustedIPs in /docker/proxyserver/traefik.yml
|
||||||
|
#
|
||||||
|
# The repo file is the source of truth for nginx (installed by nginx-sync.sh);
|
||||||
|
# Traefik's static config lives outside the repo and is regenerated in place.
|
||||||
|
# Traefik only picks it up after a container restart (static config), which
|
||||||
|
# --install performs automatically when the list actually changed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/cf-ips-sync.sh # regen repo + traefik files if ranges changed
|
||||||
|
# scripts/cf-ips-sync.sh --check # report what would change (exit 1 if any)
|
||||||
|
# sudo scripts/cf-ips-sync.sh --install # + run nginx-sync.sh and restart Traefik
|
||||||
|
#
|
||||||
|
# The Traefik bridge subnet is auto-detected (env TRUSTED_SUBNET overrides),
|
||||||
|
# because nginx trusts it as a TLS-terminating peer.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROXY_DIR="$SCRIPT_DIR/../deployment/proxy"
|
||||||
|
TARGET="$PROXY_DIR/cloudflare-ips.conf"
|
||||||
|
TRAEFIK_CONFIG="${TRAEFIK_CONFIG:-/docker/proxyserver/traefik.yml}"
|
||||||
|
IPV4_URL="https://www.cloudflare.com/ips-v4"
|
||||||
|
IPV6_URL="https://www.cloudflare.com/ips-v6"
|
||||||
|
MODE="status"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--check) MODE="check" ;;
|
||||||
|
--install) MODE="install" ;;
|
||||||
|
--no-traefik) TRAEFIK_CONFIG="" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
TRUSTED_SUBNET="${TRUSTED_SUBNET:-}"
|
||||||
|
if [[ -z "$TRUSTED_SUBNET" ]]; then
|
||||||
|
TRUSTED_SUBNET="$(docker network inspect proxyserver_traefik-proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$TRUSTED_SUBNET" ]]; then
|
||||||
|
TRUSTED_SUBNET="172.22.0.0/16"
|
||||||
|
echo "warning: could not auto-detect Traefik bridge subnet, using $TRUSTED_SUBNET" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
ipv4="$(mktemp)"
|
||||||
|
ipv6="$(mktemp)"
|
||||||
|
trap 'rm -f "$ipv4" "$ipv6"' EXIT
|
||||||
|
|
||||||
|
if ! curl -sf "$IPV4_URL" -o "$ipv4" || ! curl -sf "$IPV6_URL" -o "$ipv6"; then
|
||||||
|
echo "error: could not fetch Cloudflare ranges" >&2
|
||||||
|
if [[ -f "$TARGET" ]]; then
|
||||||
|
echo "keeping existing $TARGET (ranges not refreshed)" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
gen_nginx_conf() {
|
||||||
|
{
|
||||||
|
printf '%s\n' "# Trusted edge networks + live Cloudflare CDN ranges."
|
||||||
|
printf '%s\n' "# Managed/regenerated by scripts/cf-ips-sync.sh - do not hand-edit the ranges."
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "# Topology: Cloudflare -> Traefik (:443, docker bridge proxy_traefik-proxy) ->"
|
||||||
|
printf '%s\n' "# nginx (:9443) -> CMS. nginx ALSO receives direct connections on :9443 from"
|
||||||
|
printf '%s\n' "# Cloudflare edges and from the game client (ws.epicnabbo.nl is not proxied)."
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "# nginx only trusts the peers listed here as a source of \$remote_addr"
|
||||||
|
printf '%s\n' "# (via CF-Connecting-IP). Anyone else presenting a CF-Connecting-IP or"
|
||||||
|
printf '%s\n' "# CF-ray header is spoofing and is rejected in nginx-cms.conf."
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "# 1 = peer is a trusted edge or internal network (keyed on the raw peer,"
|
||||||
|
printf '%s\n' "# unaffected by real_ip rewrites)."
|
||||||
|
printf '%s\n' "geo \$realip_remote_addr \$cms_trusted_edge {"
|
||||||
|
printf '%s\n' " default 0;"
|
||||||
|
printf '%s\n' " 127.0.0.0/8 1; # localhost (health checks, admin)"
|
||||||
|
printf '%s\n' " ::1 1; # localhost v6"
|
||||||
|
printf '%s\n' " $TRUSTED_SUBNET 1; # Traefik (proxyserver_traefik-proxy)"
|
||||||
|
printf '%s\n' " # --- Cloudflare IPv4 ranges (live from cloudflare.com/ips-v4) ---"
|
||||||
|
awk '{print " "$0" 1;"}' "$ipv4"
|
||||||
|
printf '%s\n' " # --- Cloudflare IPv6 ranges (live from cloudflare.com/ips-v6) ---"
|
||||||
|
awk '{print " "$0" 1;"}' "$ipv6"
|
||||||
|
printf '%s\n' "}"
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "# 1 when an UNTRUSTED peer still presents a CF-Connecting-IP header: that is a"
|
||||||
|
printf '%s\n' "# spoof attempt (only real Cloudflare edges or Traefik may do that lawfully)."
|
||||||
|
printf '%s\n' "map \"\$cms_trusted_edge:\$http_cf_connecting_ip\" \$cms_disallow_forwarding {"
|
||||||
|
printf '%s\n' " default 0;"
|
||||||
|
printf '%s\n' " \"~^0:.+\" 1;"
|
||||||
|
printf '%s\n' "}"
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "# Rewrite \$remote_addr from CF-Connecting-IP but ONLY for the trusted peers"
|
||||||
|
printf '%s\n' "# above. Direct game clients (untrusted) keep their real peer address."
|
||||||
|
printf '%s\n' "set_real_ip_from 127.0.0.0/8;"
|
||||||
|
printf '%s\n' "set_real_ip_from ::1;"
|
||||||
|
printf '%s\n' "set_real_ip_from $TRUSTED_SUBNET;"
|
||||||
|
awk '{print "set_real_ip_from "$0";"}' "$ipv4"
|
||||||
|
awk '{print "set_real_ip_from "$0";"}' "$ipv6"
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "real_ip_header CF-Connecting-IP;"
|
||||||
|
printf '%s\n' "real_ip_recursive off;"
|
||||||
|
} > "$TARGET.tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
gen_nginx_conf
|
||||||
|
changed=0
|
||||||
|
if cmp -s "$TARGET" "$TARGET.tmp"; then
|
||||||
|
rm -f "$TARGET.tmp"
|
||||||
|
echo "= $TARGET up to date (Cloudflare ranges unchanged)"
|
||||||
|
else
|
||||||
|
changed=1
|
||||||
|
if [[ "$MODE" == "check" ]]; then
|
||||||
|
rm -f "$TARGET.tmp"
|
||||||
|
echo "- Cloudflare ranges DIFFER; $TARGET would be regenerated"
|
||||||
|
else
|
||||||
|
mv "$TARGET.tmp" "$TARGET"
|
||||||
|
echo "+ regenerated $TARGET"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
traefik_changed=0
|
||||||
|
if [[ -n "$TRAEFIK_CONFIG" ]]; then
|
||||||
|
if [[ ! -f "$TRAEFIK_CONFIG" ]]; then
|
||||||
|
echo "warning: $TRAEFIK_CONFIG not found, skipping Traefik sync" >&2
|
||||||
|
else
|
||||||
|
new_traefik="$(CF_V4="$ipv4" CF_V6="$ipv6" python3 - "$TRAEFIK_CONFIG" <<'PY'
|
||||||
|
import os, sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
v4 = sorted(x.rstrip("\n") for x in open(os.environ["CF_V4"]) if x.strip())
|
||||||
|
v6 = sorted(x.rstrip("\n") for x in open(os.environ["CF_V6"]) if x.strip())
|
||||||
|
lines = open(path).read().split("\n")
|
||||||
|
out, i, n = [], 0, len(lines)
|
||||||
|
while i < n:
|
||||||
|
line = lines[i]
|
||||||
|
if line.startswith(" trustedIPs:"):
|
||||||
|
out.append(line)
|
||||||
|
i += 1
|
||||||
|
while i < n:
|
||||||
|
s = lines[i]
|
||||||
|
if not s.strip() or s.startswith(" - ") or s.startswith(" #"):
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
out.append(" # Cloudflare IPv4 reeksen (gesynct door cf-ips-sync.sh)")
|
||||||
|
out += [" - " + c for c in v4]
|
||||||
|
out.append(" # Cloudflare IPv6 reeksen")
|
||||||
|
out += [" - " + c for c in v6]
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
i += 1
|
||||||
|
sys.stdout.write("\n".join(out))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if grep -q 'trustedIPs:' <<< "$new_traefik" \
|
||||||
|
&& grep -cq '^ - ' <<< "$new_traefik"; then
|
||||||
|
if [[ "$new_traefik" == "$(cat "$TRAEFIK_CONFIG")" ]]; then
|
||||||
|
echo "= $TRAEFIK_CONFIG up to date (Cloudflare ranges unchanged)"
|
||||||
|
else
|
||||||
|
traefik_changed=1
|
||||||
|
if [[ "$MODE" == "check" ]]; then
|
||||||
|
echo "- $TRAEFIK_CONFIG differs from live Cloudflare ranges"
|
||||||
|
else
|
||||||
|
cp -a "$TRAEFIK_CONFIG" "$TRAEFIK_CONFIG.bak-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
printf '%s\n' "$new_traefik" > "$TRAEFIK_CONFIG"
|
||||||
|
echo "+ updated $TRAEFIK_CONFIG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "error: generated Traefik config is missing its trustedIPs block; NOT writing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$MODE" == "check" ]] && exit $((changed || traefik_changed))
|
||||||
|
|
||||||
|
if [[ "$MODE" == "install" ]]; then
|
||||||
|
"$SCRIPT_DIR/nginx-sync.sh"
|
||||||
|
if [[ "$traefik_changed" -eq 1 ]]; then
|
||||||
|
if docker inspect traefik >/dev/null 2>&1; then
|
||||||
|
echo "--- restarting traefik (static config changed) ---"
|
||||||
|
docker restart traefik
|
||||||
|
else
|
||||||
|
echo "warning: traefik container not found; restart it manually" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -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[*]}"
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||||
|
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
|
||||||
|
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
|
||||||
|
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
|
||||||
|
# stuurt).
|
||||||
|
#
|
||||||
|
# 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 max-age/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 én /gamedata/: browsers krijgen de
|
||||||
|
# per-klasse max-age van nginx terug i.p.v. een jaar stale data.
|
||||||
|
#
|
||||||
|
# Het /gamedata/-deel is toegevoegd omdat de zone-TTL anders ook de iconen
|
||||||
|
# (`.png`, een standaard cachebare extensie) op een jaar zette: nginx stuurde
|
||||||
|
# 300/3600/604800, maar de browser kreeg `max-age=31536000` en een 404 werd als
|
||||||
|
# `max-age=31536000` + `cf-cache-status: HIT` vastgezet. Een ontbrekend icon dat
|
||||||
|
# later werd geïmporteerd bleef daardoor een jaar 404. Met `respect_origin` geldt
|
||||||
|
# de nginx-header, en die stuurt op een 404 juist `no-store`.
|
||||||
|
#
|
||||||
|
# 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 + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
|
||||||
|
|
||||||
|
# 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()`.
|
||||||
|
#
|
||||||
|
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
|
||||||
|
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
|
||||||
|
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
|
||||||
|
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
|
||||||
|
# tussen gebruikers.
|
||||||
|
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/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -45,11 +45,28 @@ for (const image of nodeImages) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cmpVersion = (a, b) => {
|
||||||
|
const pa = a.split(".").map((part) => Number.parseInt(part, 10));
|
||||||
|
const pb = b.split(".").map((part) => Number.parseInt(part, 10));
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `.nvmrc` is the recommended version for reproducible local development and
|
||||||
|
// the exact Docker base image (both asserted above), but the *runtime* we run
|
||||||
|
// on may be any version the package.json engines range accepts. Requiring an
|
||||||
|
// exact patch match here would fail on every Node.js patch release, even when
|
||||||
|
// the version is explicitly supported.
|
||||||
if (!process.argv.includes("--static")) {
|
if (!process.argv.includes("--static")) {
|
||||||
assert.equal(
|
const active = process.versions.node;
|
||||||
process.versions.node,
|
const upperBound = `${major + 1}.0.0`;
|
||||||
pinnedVersion,
|
assert.ok(
|
||||||
"the active Node.js runtime must match .nvmrc",
|
cmpVersion(active, pinnedVersion) >= 0 &&
|
||||||
|
cmpVersion(active, upperBound) < 0,
|
||||||
|
`the active Node.js runtime (${active}) must satisfy package.json engines.node (${packageJson.engines.node}); .nvmrc pins ${pinnedVersion} as the recommended version`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
|
||||||
|
#
|
||||||
|
# Background: on a host where both blue/green slots answer /api/health, the
|
||||||
|
# original read_active_port() counted healthy slots and only consulted the nginx
|
||||||
|
# upstream when the count was not exactly 1. With two healthy slots it fell back
|
||||||
|
# to the upstream file, but an operator `docker compose up` can leave an extra
|
||||||
|
# replica behind, after which the fallback picked slot A regardless of which slot
|
||||||
|
# was really live. The candidate then tried to start on an occupied port, and the
|
||||||
|
# health probe answered from the pre-existing container on that port instead of
|
||||||
|
# the candidate — producing 30 failed "expected release never became healthy"
|
||||||
|
# attempts against a release that was never serving.
|
||||||
|
#
|
||||||
|
# The functions are extracted from ci-deploy.sh rather than copied so this test
|
||||||
|
# cannot drift from the script it protects.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
deploy_script="$(dirname "$0")/ci-deploy.sh"
|
||||||
|
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Pull the two functions out of the real script.
|
||||||
|
extract() {
|
||||||
|
sed -n "/^$1() {/,/^}/p" "$deploy_script"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_active_port_fn="$(extract read_active_port)"
|
||||||
|
assert_port_free_fn="$(extract assert_port_free)"
|
||||||
|
answers_health_fn="$(extract answers_health)"
|
||||||
|
|
||||||
|
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
|
||||||
|
echo "could not extract functions from $deploy_script" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
slot_a_port=3002
|
||||||
|
slot_b_port=3003
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── read_active_port ──────────────────────────────────────────────────────────
|
||||||
|
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
|
||||||
|
run_read_active_port() {
|
||||||
|
local body="$1" a="$2" b="$3" tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
if [ "$body" = "none" ]; then
|
||||||
|
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
|
||||||
|
rm -f "$tmp"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$body" >"$tmp"
|
||||||
|
fi
|
||||||
|
CMS_UPSTREAM_FILE="$tmp" \
|
||||||
|
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
|
||||||
|
bash -c "
|
||||||
|
slot_a_port=$slot_a_port
|
||||||
|
slot_b_port=$slot_b_port
|
||||||
|
upstream_file=\"\$CMS_UPSTREAM_FILE\"
|
||||||
|
$read_active_port_fn
|
||||||
|
# Defined after the extracted function on purpose: answers_health is a
|
||||||
|
# collaborator here, and the test substitutes a deterministic stub for it.
|
||||||
|
answers_health() {
|
||||||
|
local p=\$1 want
|
||||||
|
case \$p in
|
||||||
|
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
|
||||||
|
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
|
||||||
|
*) want='' ;;
|
||||||
|
esac
|
||||||
|
[ \"\$want\" = yes ]
|
||||||
|
}
|
||||||
|
read_active_port
|
||||||
|
echo
|
||||||
|
" 2>/dev/null
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# nginx points at slot B and both answer -> trust the upstream file.
|
||||||
|
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||||
|
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
|
||||||
|
|
||||||
|
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
|
||||||
|
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
|
||||||
|
|
||||||
|
# The regression: both healthy, nginx points at B, but slot A is an unrelated
|
||||||
|
# leftover replica. The upstream file is the only thing that knows which slot is
|
||||||
|
# live, so it must win.
|
||||||
|
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||||
|
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
|
||||||
|
|
||||||
|
# Upstream names a dead slot: fall back to a slot that actually answers, never to
|
||||||
|
# the dead port itself.
|
||||||
|
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
|
||||||
|
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
|
||||||
|
|
||||||
|
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
|
||||||
|
# rollback path has no target.
|
||||||
|
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
|
||||||
|
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
|
||||||
|
|
||||||
|
# No upstream file at all: pick a slot that answers.
|
||||||
|
got="$(run_read_active_port none no yes)"
|
||||||
|
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
|
||||||
|
|
||||||
|
got="$(run_read_active_port none yes no)"
|
||||||
|
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
|
||||||
|
|
||||||
|
# ── assert_port_free ─────────────────────────────────────────────────────────
|
||||||
|
# Runs against real loopback ports: 3999 is intentionally unused, so the check
|
||||||
|
# must report it free.
|
||||||
|
bash -c "
|
||||||
|
$assert_port_free_fn
|
||||||
|
assert_port_free 3999 candidate >/dev/null 2>&1
|
||||||
|
" || fail "a port with no listener must be reported as free"
|
||||||
|
|
||||||
|
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
|
||||||
|
# it genuinely is free, otherwise the assertion would be meaningless.
|
||||||
|
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
|
||||||
|
if bash -c "
|
||||||
|
$assert_port_free_fn
|
||||||
|
assert_port_free 3002 candidate >/dev/null 2>&1
|
||||||
|
"; then
|
||||||
|
fail "an occupied port must be rejected, but assert_port_free returned success"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'Deploy port-selection tests passed'
|
||||||
@@ -21,6 +21,35 @@ cutover_started=0
|
|||||||
candidate_attempted=0
|
candidate_attempted=0
|
||||||
backup_created=0
|
backup_created=0
|
||||||
|
|
||||||
|
# ── Blue/green ──
|
||||||
|
# De app draait met `--net=host`, dus elke replica neemt een eigen host-poort in
|
||||||
|
# plaats van een gedeelde docker-poort. Daardoor zijn er twee vaste slots en kan
|
||||||
|
# een release naast de live release opstarten. De nginx-upstream wijst pas naar
|
||||||
|
# de nieuwe release nadat die gezond is én de browsersmoke-test heeft gewonnen.
|
||||||
|
slot_a_port=3002
|
||||||
|
slot_b_port=3003
|
||||||
|
slot_a_container="epicnext-cms-app"
|
||||||
|
slot_b_container="epicnext-cms-green"
|
||||||
|
# Overridable zodat de simulatietests een pad kunnen opgeven dat niet bestaat en
|
||||||
|
# zo het in-place pad kunnen testen, en zodat een host met een andere
|
||||||
|
# nginx-indeling niet stilvalt op een release.
|
||||||
|
upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||||
|
nginx_site="${CMS_NGINX_SITE:-/etc/nginx/sites-enabled/cms.conf}"
|
||||||
|
active_port=""
|
||||||
|
old_port=""
|
||||||
|
new_port=""
|
||||||
|
old_container=""
|
||||||
|
new_container=""
|
||||||
|
blue_green=0
|
||||||
|
|
||||||
|
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
|
||||||
|
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
|
||||||
|
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
|
||||||
|
mem_limit="4g"
|
||||||
|
mem_swap_limit="5g"
|
||||||
|
cpu_limit="2"
|
||||||
|
pids_limit="512"
|
||||||
|
|
||||||
is_current() {
|
is_current() {
|
||||||
local head
|
local head
|
||||||
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
|
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
|
||||||
@@ -38,18 +67,280 @@ check_current() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
healthy() {
|
healthy() {
|
||||||
|
local port="${1:-$slot_a_port}"
|
||||||
local attempt
|
local attempt
|
||||||
for attempt in $(seq 1 30); do
|
for attempt in $(seq 1 30); do
|
||||||
if curl -sf --max-time 5 http://127.0.0.1:3002/api/health | grep -q '"database":true'; then return 0; fi
|
if curl -sf --max-time 5 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then return 0; fi
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
|
||||||
|
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
|
||||||
|
# een al draaiend proces dat nu of nooit antwoordt.
|
||||||
|
answers_health() {
|
||||||
|
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
|
||||||
|
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
|
||||||
|
# niet stilvalt op een upgrade.
|
||||||
|
detect_blue_green() {
|
||||||
|
[ -r "$upstream_file" ] || return 1
|
||||||
|
grep -qs 'cms_app' "$nginx_site" || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Welke poort is op dit moment ÉCHT live?
|
||||||
|
#
|
||||||
|
# Volgorde van vertrouwen:
|
||||||
|
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
|
||||||
|
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
|
||||||
|
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
|
||||||
|
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
|
||||||
|
#
|
||||||
|
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
|
||||||
|
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
|
||||||
|
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
|
||||||
|
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
|
||||||
|
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
|
||||||
|
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
|
||||||
|
# container die toevallig een andere release draaide.
|
||||||
|
read_active_port() {
|
||||||
|
local port="" pointed=""
|
||||||
|
|
||||||
|
if [ -r "$upstream_file" ]; then
|
||||||
|
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$port" ] && answers_health "$port"; then
|
||||||
|
printf '%s' "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
|
||||||
|
# enige andere gezonde slot, anders is er niets om op te bouwen.
|
||||||
|
for candidate in "$slot_a_port" "$slot_b_port"; do
|
||||||
|
[ "$candidate" = "$port" ] && continue
|
||||||
|
if answers_health "$candidate"; then
|
||||||
|
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
|
||||||
|
printf '%s' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
|
||||||
|
# rollback-poging toch het vorige slot kan starten.
|
||||||
|
if [ -n "$port" ]; then
|
||||||
|
printf '%s' "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$slot_a_port"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Poort-bezetting controleren vóór het starten van de kandidaat.
|
||||||
|
#
|
||||||
|
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
|
||||||
|
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
|
||||||
|
# container op diezelfde poort beantwoordt. Dat levert een misleidende
|
||||||
|
# "expected release never became healthy" op in plaats van de echte oorzaak.
|
||||||
|
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
|
||||||
|
# hem vasthoudt.
|
||||||
|
assert_port_free() {
|
||||||
|
local port="$1" name="$2"
|
||||||
|
local holders=""
|
||||||
|
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
|
||||||
|
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
|
||||||
|
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
|
||||||
|
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
|
||||||
|
# hoeft te zien.
|
||||||
|
if type ss >/dev/null 2>&1; then
|
||||||
|
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
|
||||||
|
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
|
||||||
|
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
|
||||||
|
fi
|
||||||
|
[ -z "$holders" ] && return 0
|
||||||
|
echo "Port $port is already in use, cannot start candidate $name" >&2
|
||||||
|
printf '%s\n' "$holders" >&2
|
||||||
|
|
||||||
|
# Noem exact het container dat de poort vasthoudt.
|
||||||
|
#
|
||||||
|
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
|
||||||
|
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
|
||||||
|
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
|
||||||
|
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
|
||||||
|
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
|
||||||
|
# onschuldige containers begraven.
|
||||||
|
local squatter="squatter_pids"
|
||||||
|
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
|
||||||
|
if [ -n "$squatter_pids" ]; then
|
||||||
|
local pid cid owner=""
|
||||||
|
for pid in $squatter_pids; do
|
||||||
|
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
|
||||||
|
[ -n "$cid" ] || continue
|
||||||
|
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
|
||||||
|
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo "" >&2
|
||||||
|
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
|
||||||
|
# naam die toevallig op een van deze poorten draait — meestal een
|
||||||
|
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
|
||||||
|
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
|
||||||
|
# dus niet geraakt.
|
||||||
|
case "$owner" in
|
||||||
|
*"/$name"*|*"/$slot_b_container"*)
|
||||||
|
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
|
||||||
|
*)
|
||||||
|
cat >&2 <<EOF
|
||||||
|
This port is held by a container that is not a blue/green slot, so the deploy
|
||||||
|
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
|
||||||
|
the other slot until cutover.
|
||||||
|
|
||||||
|
Remove the stray container and re-run the deploy:
|
||||||
|
|
||||||
|
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
|
||||||
|
|
||||||
|
If it comes back after a reboot, it is started by docker-compose.yml rather
|
||||||
|
than by this script; delete or disable that service.
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ruim een compose-replica op die een blauwe/groene slot bezet.
|
||||||
|
#
|
||||||
|
# Een `docker compose up` — of de dagelijkse `scripts/docker-update.sh`, waarvan
|
||||||
|
# de CI-eigendomscontrole per slot wankelde — laat een replica met container_name
|
||||||
|
# `epicnext-cms` achter op poort 3002. Die draait nooit live: nginx wijst naar de
|
||||||
|
# poort van een slot-container die dit script zelf heeft gestart, en die staat per
|
||||||
|
# definitie aan de andere kant dan de kandidaat. Zonder deze opruimstap loopt elke
|
||||||
|
# release vast op een bezette poort totdat iemand de container met de hand
|
||||||
|
# verwijdert.
|
||||||
|
#
|
||||||
|
# Bewust smal, want een container van een ander deployment is niet van ons:
|
||||||
|
# - alleen een replica die uit precies deze checkout komt
|
||||||
|
# (com.docker.compose.project.config_files), niet een losse compose-project;
|
||||||
|
# - nooit een slot-container, want die beheert dit script zelf;
|
||||||
|
# - nooit de poort waar nginx naar wijst.
|
||||||
|
# Wat daarnaast nog op de doel-poort zit, laat assert_port_free() met zijn eigen
|
||||||
|
# foutmelding staan in plaats van stilzwijgend verdwijnen.
|
||||||
|
retire_compose_replicas() {
|
||||||
|
local live_port="$1" cid name="" config_files="" port=""
|
||||||
|
while read -r cid; do
|
||||||
|
[ -n "$cid" ] || continue
|
||||||
|
name="$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null | sed -n 's#^/##p' || true)"
|
||||||
|
case "$name" in ''|"$slot_a_container"|"$slot_b_container") continue ;; esac
|
||||||
|
config_files="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$cid" 2>/dev/null || true)"
|
||||||
|
[ "$config_files" = "$deploy_dir/docker-compose.yml" ] || continue
|
||||||
|
port="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$cid" 2>/dev/null | sed -n 's#^PORT=##p' | head -1 || true)"
|
||||||
|
[ -n "$port" ] && [ "$port" != "$live_port" ] || continue
|
||||||
|
echo "Removing compose replica $name on port $port: it squats a blue/green slot and is not the live release (nginx serves $live_port)"
|
||||||
|
docker rm -f "$name" || return 1
|
||||||
|
done < <(docker ps --filter "label=com.docker.compose.project.config_files=$deploy_dir/docker-compose.yml" --format '{{.ID}}')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
|
||||||
|
#
|
||||||
|
# De nieuwe inhoud wordt eerst echt weggeschreven en dán getest: `nginx -t` leest
|
||||||
|
# het include-bestand van schijf, dus alleen achteraf testen zou de oude, werkende
|
||||||
|
# configuratie blijven valideren. Faalt de test, dan gaat het origineel onmiddellijk
|
||||||
|
# terug en raakt de live release niets.
|
||||||
|
switch_upstream() {
|
||||||
|
local port="$1"
|
||||||
|
local backup="${upstream_file}.deploy.bak"
|
||||||
|
if ! cp "$upstream_file" "$backup" 2>/dev/null; then
|
||||||
|
echo "Cannot back up $upstream_file; refusing to cut over" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "# Geschreven door scripts/ci-deploy.sh op $(date -u +%FT%TZ) voor release $sha."
|
||||||
|
echo "# Niet met de hand bewerken: de volgende deploy overschrijft dit bestand."
|
||||||
|
echo "server 127.0.0.1:${port} max_fails=2 fail_timeout=10s;"
|
||||||
|
} >"$upstream_file"
|
||||||
|
if ! nginx -t >/dev/null 2>&1; then
|
||||||
|
echo "nginx rejected the new upstream; restoring the previous one" >&2
|
||||||
|
mv "$backup" "$upstream_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! nginx -s reload; then
|
||||||
|
echo "nginx reload failed; restoring the previous upstream" >&2
|
||||||
|
mv "$backup" "$upstream_file"
|
||||||
|
nginx -s reload || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$backup"
|
||||||
|
# Even de tijd voor de graceful reload om de nieuwe worker te laten starten.
|
||||||
|
sleep 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start de kandidaat op een eigen host-poort.
|
||||||
|
#
|
||||||
|
# De resource limits staan hier bewust bij de `docker run` en niet alleen in
|
||||||
|
# docker-compose.yml: een release wordt hier gestart en gebruikt compose helemaal
|
||||||
|
# niet, dus limieten die alleen in de compose stonden zouden in productie nooit
|
||||||
|
# gelden.
|
||||||
|
start_candidate() {
|
||||||
|
local port="$1" name="$2"
|
||||||
|
assert_port_free "$port" "$name"
|
||||||
|
(
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$deploy_dir/.env"
|
||||||
|
set +a
|
||||||
|
ENV_ARGS=()
|
||||||
|
while IFS='=' read -r key _; do
|
||||||
|
case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac
|
||||||
|
ENV_ARGS+=(-e "$key")
|
||||||
|
done < "$deploy_dir/.env"
|
||||||
|
docker run -d --name "$name" --restart always --net=host \
|
||||||
|
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
||||||
|
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
|
||||||
|
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
||||||
|
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||||
|
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||||
|
-v "$deploy_dir/storage:/app/storage" \
|
||||||
|
-v /var/www/Gamedata:/var/www/Gamedata \
|
||||||
|
"$image"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
finish() {
|
finish() {
|
||||||
local status=$?
|
local status=$?
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
|
if [ "$status" -ne 0 ] && [ "$blue_green" -eq 1 ]; then
|
||||||
|
# Er zijn twee soorten falen, en het verschil bepaalt hoeveel werk terug moet.
|
||||||
|
if [ "$cutover_started" -eq 0 ]; then
|
||||||
|
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
|
||||||
|
echo "Deployment failed before cutover; the live release was never stopped" >&2
|
||||||
|
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
|
||||||
|
docker logs "$new_container" --tail 50 >&2 || true
|
||||||
|
docker rm -f "$new_container" || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
|
||||||
|
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
|
||||||
|
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
|
||||||
|
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||||
|
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
|
||||||
|
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
|
||||||
|
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
|
||||||
|
if healthy "$old_port"; then
|
||||||
|
echo "Rollback verified on port $old_port"
|
||||||
|
else
|
||||||
|
echo "ERROR: previous container did not return to a healthy state" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ERROR: no previous container to roll back to" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
|
||||||
|
# In-place pad (geen blue/green-upstream): het oude scriptgedrag.
|
||||||
echo "Deployment failed; restoring previous container" >&2
|
echo "Deployment failed; restoring previous container" >&2
|
||||||
docker logs epicnext-cms-app --tail 50 >&2 || true
|
docker logs epicnext-cms-app --tail 50 >&2 || true
|
||||||
if command -v ss >/dev/null 2>&1; then ss -ltnp 'sport = :3002' >&2 || true; fi
|
if command -v ss >/dev/null 2>&1; then ss -ltnp 'sport = :3002' >&2 || true; fi
|
||||||
@@ -91,12 +382,43 @@ fi
|
|||||||
for managed_name in epicnext-cms epicnext-cms-app; do
|
for managed_name in epicnext-cms epicnext-cms-app; do
|
||||||
docker inspect --format '{{.Name}} running={{.State.Running}} pid={{.State.Pid}} image={{.Image}}' "$managed_name" 2>/dev/null || true
|
docker inspect --format '{{.Name}} running={{.State.Running}} pid={{.State.Pid}} image={{.Image}}' "$managed_name" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
cp "$deploy_dir/.env" .env
|
[ "$deploy_dir/.env" -ef .env ] || cp "$deploy_dir/.env" .env
|
||||||
|
|
||||||
|
# De migraties draaien op de host tegen DATABASE_URL, niet in de container. Die
|
||||||
|
# waarde staat alleen in $deploy_dir/.env, dus controleer hem hier: anders
|
||||||
|
# bouwen we eerst een image en doorlopen we de browsergate voordat `db:migrate`
|
||||||
|
# op een lege DATABASE_URL stukloopt. Dat is een halve release voor niets.
|
||||||
|
if ! grep -qs '^DATABASE_URL=' .env; then
|
||||||
|
echo "Error: DATABASE_URL ontbreekt in $deploy_dir/.env" >&2
|
||||||
|
echo " Zet daar een DATABASE_URL (mysql://user:pass@host:3306/db) en draai opnieuw." >&2
|
||||||
|
echo " De live release is niet aangeraakt; deze release is niet uitgerold." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# De image krijgt het label van $sha, en `verify-deployed-release.mjs` controleert
|
||||||
|
# later alleen díe label. Zonder deze check bouwt een vuile werkboom dus een image
|
||||||
|
# die zegt release $sha te zijn terwijl er ongecommitte code in zit — precies het
|
||||||
|
# scenario "rebuilden levert geen nieuwe code". `docker-update.sh` deed dit al.
|
||||||
|
# `--untracked-files=normal` laat gitignored artefacten (.next, coverage,
|
||||||
|
# build-reports) buiten beschouwing; die worden toch niet meegebouwd.
|
||||||
|
if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
|
||||||
|
echo "Error: de werkboom is niet schoon, dus de image zou een verkeerd release-label krijgen." >&2
|
||||||
|
echo " Commit of stash de wijzigingen en draai opnieuw." >&2
|
||||||
|
echo " De live release is niet aangeraakt." >&2
|
||||||
|
git status --short >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm exec playwright install chromium
|
# All three engines are needed: the chromium gate below plus the cross-browser
|
||||||
|
# imaging gate just before cutover. Idempotent — already-installed engines in
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
|
||||||
|
pnpm exec playwright install chromium firefox webkit
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||||
|
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||||
|
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
check_current
|
check_current
|
||||||
# Read reports from the already-built image; do not start an extra application.
|
# Read reports from the already-built image; do not start an extra application.
|
||||||
@@ -121,74 +443,168 @@ check_current
|
|||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
check_current
|
check_current
|
||||||
|
|
||||||
# Prefer the active CI container, or the active legacy compose container.
|
# ── Blue/green: bepaal wie live is en waar de kandidaat mag starten ──
|
||||||
for name in epicnext-cms epicnext-cms-app; do
|
if detect_blue_green; then
|
||||||
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
|
blue_green=1
|
||||||
if [ -z "$previous_name" ]; then previous_name="$name"; else secondary_name="$name"; fi
|
active_port="$(read_active_port)"
|
||||||
|
if [ "$active_port" = "$slot_b_port" ]; then
|
||||||
|
old_port=$slot_b_port; new_port=$slot_a_port
|
||||||
|
old_container=$slot_b_container; new_container=$slot_a_container
|
||||||
|
else
|
||||||
|
old_port=$slot_a_port; new_port=$slot_b_port
|
||||||
|
old_container=$slot_a_container; new_container=$slot_b_container
|
||||||
fi
|
fi
|
||||||
done
|
echo "Live release keeps serving port $old_port; candidate starts on port $new_port"
|
||||||
if [ -z "$previous_name" ]; then
|
# Rollback-evidence vastleggen voordat er iets wordt veranderd.
|
||||||
for name in epicnext-cms-app epicnext-cms; do
|
if docker inspect "$old_container" >/dev/null 2>&1; then
|
||||||
if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi
|
previous_name="$old_container"
|
||||||
|
previous_image="$(docker inspect --format '{{.Image}}' "$old_container")"
|
||||||
|
docker tag "$previous_image" epicnext-cms:previous
|
||||||
|
else
|
||||||
|
# Eerste release op een verse blue/green-opstelling: er is nog niets live.
|
||||||
|
previous_name=""
|
||||||
|
old_container=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
blue_green=0
|
||||||
|
# Prefer the active CI container, or the active legacy compose container.
|
||||||
|
for name in epicnext-cms epicnext-cms-app; do
|
||||||
|
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
|
||||||
|
if [ -z "$previous_name" ]; then previous_name="$name"; else secondary_name="$name"; fi
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
if [ -z "$previous_name" ]; then
|
||||||
|
for name in epicnext-cms-app epicnext-cms; do
|
||||||
|
if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -n "$previous_name" ]; then
|
||||||
|
previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")"
|
||||||
|
docker tag "$previous_image" epicnext-cms:previous
|
||||||
|
fi
|
||||||
|
# Remove a stopped leftover CI container when the compose container is active.
|
||||||
|
if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
|
||||||
|
docker rm epicnext-cms-app
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if docker inspect "$backup_name" >/dev/null 2>&1; then
|
if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||||
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
|
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -n "$previous_name" ]; then
|
|
||||||
previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")"
|
|
||||||
docker tag "$previous_image" epicnext-cms:previous
|
|
||||||
fi
|
|
||||||
# Remove a stopped leftover CI container when the compose container is active.
|
|
||||||
if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
|
|
||||||
docker rm epicnext-cms-app
|
|
||||||
fi
|
|
||||||
|
|
||||||
cutover_started=1
|
# The application writes everything under storage/ as uid 33, but storage is a
|
||||||
# Both legacy Compose and CI containers can exist after earlier failed updates.
|
# host bind so the image's own ownership is irrelevant. Any path that is not
|
||||||
# Preserve each before releasing the shared host port; never kill an arbitrary PID.
|
# uid 33 makes the write fail with EACCES, and because most of these writes are
|
||||||
if [ -n "$secondary_name" ]; then
|
# inside a try/catch the failure is silent: the avatar cache just never fills
|
||||||
docker stop "$secondary_name"
|
# (each avatar becomes a fresh live render) and the catalog export reports
|
||||||
docker rename "$secondary_name" "$secondary_backup"
|
# "delivery failed" while the emulator never receives the update. The old code
|
||||||
secondary_backup_created=1
|
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
|
||||||
|
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
|
||||||
|
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
|
||||||
|
nitro-cleanup config-backups nitro-scale32-backups; do
|
||||||
|
target="$deploy_dir/storage/$owned_dir"
|
||||||
|
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
|
||||||
|
[ -d "$target" ] || continue
|
||||||
|
chown -R 33:33 "$target" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
# The avatar/badge cache needs its leaf directories to exist before first use;
|
||||||
|
# the cache misses (and re-renders live) rather than erroring when they do not.
|
||||||
|
for cache_dir in avatars badges; do
|
||||||
|
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
|
||||||
|
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$blue_green" -eq 1 ]; then
|
||||||
|
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
|
||||||
|
# want nginx wijst nog naar old_port. Een restje van een mislukte eerdere
|
||||||
|
# deploy mag de nieuwe release niet blokkeren.
|
||||||
|
if docker inspect "$new_container" >/dev/null 2>&1; then
|
||||||
|
docker rm -f "$new_container"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Een compose-replica die ooit is achtergebleven zit hier nog op de
|
||||||
|
# doel-poort. Hij draait niet live en wordt dus opgeruimd, zodat de release
|
||||||
|
# niet op een bezette poort stukloopt.
|
||||||
|
retire_compose_replicas "$old_port"
|
||||||
|
|
||||||
|
# 3. Start de kandidaat ernaast. De live release draait ononderbroken door.
|
||||||
|
candidate_attempted=1
|
||||||
|
start_candidate "$new_port" "$new_container"
|
||||||
|
|
||||||
|
# 4. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
|
||||||
|
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
|
||||||
|
# cutover de productie breken in plaats van ervoor.
|
||||||
|
healthy "$new_port"
|
||||||
|
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||||
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||||
|
|
||||||
|
# 4b. Cross-browser imaging gate. The chromium suite above proves the
|
||||||
|
# candidate serves the right bytes; this proves every bundled engine can
|
||||||
|
# actually decode the render. The webkit path is the one that historically
|
||||||
|
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
|
||||||
|
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
|
||||||
|
# a release out in an emergency.
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||||
|
cutover_started=1
|
||||||
|
switch_upstream "$new_port"
|
||||||
|
echo "Cut over to port $new_port; retiring port $old_port"
|
||||||
|
|
||||||
|
# 6. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
|
||||||
|
# waarop er geen enkele container draait.
|
||||||
|
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
|
||||||
|
docker stop "$old_container"
|
||||||
|
docker rename "$old_container" "$backup_name"
|
||||||
|
backup_created=1
|
||||||
|
fi
|
||||||
|
verified_image="$(docker inspect --format '{{.Image}}' "$new_container")"
|
||||||
|
else
|
||||||
|
# In-place pad, alleen voor hosts zonder blue/green-upstream.
|
||||||
|
cutover_started=1
|
||||||
|
# Both legacy Compose and CI containers can exist after earlier failed updates.
|
||||||
|
# Preserve each before releasing the shared host port; never kill an arbitrary PID.
|
||||||
|
if [ -n "$secondary_name" ]; then
|
||||||
|
docker stop "$secondary_name"
|
||||||
|
docker rename "$secondary_name" "$secondary_backup"
|
||||||
|
secondary_backup_created=1
|
||||||
|
fi
|
||||||
|
if [ -n "$previous_name" ]; then
|
||||||
|
docker stop "$previous_name"
|
||||||
|
docker rename "$previous_name" "$backup_name"
|
||||||
|
backup_created=1
|
||||||
|
fi
|
||||||
|
candidate_attempted=1
|
||||||
|
start_candidate 3002 epicnext-cms-app
|
||||||
|
healthy 3002
|
||||||
|
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||||
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
|
||||||
|
fi
|
||||||
|
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
||||||
fi
|
fi
|
||||||
if [ -n "$previous_name" ]; then
|
|
||||||
docker stop "$previous_name"
|
|
||||||
docker rename "$previous_name" "$backup_name"
|
|
||||||
backup_created=1
|
|
||||||
fi
|
|
||||||
candidate_attempted=1
|
|
||||||
(
|
|
||||||
set -a
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
. "$deploy_dir/.env"
|
|
||||||
set +a
|
|
||||||
ENV_ARGS=()
|
|
||||||
while IFS='=' read -r key _; do
|
|
||||||
case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac
|
|
||||||
ENV_ARGS+=(-e "$key")
|
|
||||||
done < "$deploy_dir/.env"
|
|
||||||
docker run -d --name epicnext-cms-app --restart always --net=host \
|
|
||||||
"${ENV_ARGS[@]}" -e PORT=3002 -e HOSTNAME=0.0.0.0 \
|
|
||||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
|
||||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
|
||||||
-v "$deploy_dir/storage:/app/storage" \
|
|
||||||
-v /var/www/Gamedata:/var/www/Gamedata \
|
|
||||||
"$image"
|
|
||||||
)
|
|
||||||
healthy
|
|
||||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
|
||||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
|
||||||
# Publish the latest alias only after HTTP and browser checks pass.
|
|
||||||
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
|
||||||
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
||||||
docker tag "$verified_image" epicnext-cms:latest
|
docker tag "$verified_image" epicnext-cms:latest
|
||||||
cutover_started=0
|
cutover_started=0
|
||||||
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
||||||
if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi
|
if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -39,6 +39,6 @@ pnpm exec playwright install chromium
|
|||||||
export NEWS_E2E_IMAGE="$image"
|
export NEWS_E2E_IMAGE="$image"
|
||||||
export NEWS_E2E_RELEASE="$sha"
|
export NEWS_E2E_RELEASE="$sha"
|
||||||
build_attempted=1
|
build_attempted=1
|
||||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain \
|
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pre-compress large gamedata JSON files to .gz so nginx `gzip_static`
|
|
||||||
* serves them via sendfile instead of re-compressing up to 48 MB on every
|
|
||||||
* request (see /etc/nginx/nginx.conf: `gzip_static on`).
|
|
||||||
*
|
|
||||||
* Idempotent: a file is only re-compressed when its source mtime is newer
|
|
||||||
* than the existing .gz (e.g. after a Studio catalog export rewrites it).
|
|
||||||
*
|
|
||||||
* Usage: node scripts/compress-gamedata.mjs
|
|
||||||
* Env: GAMEDATA_ROOT (default /var/www/Gamedata)
|
|
||||||
* GAMEDATA_GZIP_MIN_BYTES (default 1048576)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
|
||||||
import { cpus } from "node:os";
|
|
||||||
import { join, relative } from "node:path";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import { createGzip } from "node:zlib";
|
|
||||||
|
|
||||||
const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata";
|
|
||||||
const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024);
|
|
||||||
const GZIP_LEVEL = 9;
|
|
||||||
const TARGET_DIRS = ["config", "bundled/config"];
|
|
||||||
const CONCURRENCY = Math.max(1, Math.min(4, cpus().length));
|
|
||||||
|
|
||||||
function isCompressible(name) {
|
|
||||||
return (
|
|
||||||
name.endsWith(".json") &&
|
|
||||||
!name.endsWith(".gz") &&
|
|
||||||
!name.endsWith(".min.json")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectCandidates() {
|
|
||||||
const files = [];
|
|
||||||
for (const dir of TARGET_DIRS) {
|
|
||||||
const root = join(GAMEDATA_ROOT, dir);
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = await fs.readdir(root, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isFile() || !isCompressible(entry.name)) continue;
|
|
||||||
const full = join(root, entry.name);
|
|
||||||
const stat = await fs.stat(full);
|
|
||||||
if (stat.size < MIN_BYTES) continue;
|
|
||||||
files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function needsCompression({ src, mtimeMs }) {
|
|
||||||
const gz = `${src}.gz`;
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(gz);
|
|
||||||
return stat.mtimeMs < mtimeMs;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressOne({ src }) {
|
|
||||||
const gz = `${src}.gz`;
|
|
||||||
const tmp = `${gz}.tmp-${process.pid}`;
|
|
||||||
await pipeline(
|
|
||||||
createReadStream(src),
|
|
||||||
createGzip({ level: GZIP_LEVEL }),
|
|
||||||
createWriteStream(tmp),
|
|
||||||
);
|
|
||||||
await fs.rename(tmp, gz);
|
|
||||||
try {
|
|
||||||
await fs.chmod(gz, 0o644);
|
|
||||||
} catch {
|
|
||||||
// Best-effort; ownership is up to the caller.
|
|
||||||
}
|
|
||||||
return gz;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const candidates = await collectCandidates();
|
|
||||||
const work = [];
|
|
||||||
const skipped = [];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (await needsCompression(candidate)) {
|
|
||||||
work.push(candidate);
|
|
||||||
} else {
|
|
||||||
skipped.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
let idx = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (idx < work.length) {
|
|
||||||
const item = work[idx++];
|
|
||||||
try {
|
|
||||||
const gz = await compressOne(item);
|
|
||||||
const stat = await fs.stat(gz);
|
|
||||||
results.push(
|
|
||||||
`compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
results.push(
|
|
||||||
`FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workers = Array.from(
|
|
||||||
{ length: Math.min(CONCURRENCY, work.length) },
|
|
||||||
() => worker(),
|
|
||||||
);
|
|
||||||
await Promise.all(workers);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`gamedata: ${work.length} to compress, ${skipped.length} up to date`,
|
|
||||||
);
|
|
||||||
for (const line of results) console.log(`gamedata: ${line}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(
|
|
||||||
`gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -3,15 +3,21 @@
|
|||||||
#
|
#
|
||||||
# Modes:
|
# Modes:
|
||||||
# (default) — post-deploy cleanup (safe, fast):
|
# (default) — post-deploy cleanup (safe, fast):
|
||||||
# - Build cache older than 72h, capped at 4 GB max used space.
|
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
|
||||||
|
# least-recently-used entries. This cap is the actual bound.
|
||||||
# - Unreferenced images older than 7 days (keeps rollback images around).
|
# - Unreferenced images older than 7 days (keeps rollback images around).
|
||||||
# - Stopped containers older than 24h.
|
# - Stopped containers older than 24h.
|
||||||
|
# - Dangling images, which are always unreferenced.
|
||||||
|
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
|
||||||
# --force — emergency mode ("never let the disk max out"): drops everything
|
# --force — emergency mode ("never let the disk max out"): drops everything
|
||||||
# with no age windows:
|
# with no age windows:
|
||||||
# - ALL unreferenced build cache,
|
# - ALL unreferenced build cache,
|
||||||
# - ALL unreferenced images (no age grace),
|
# - ALL unreferenced images (no age grace),
|
||||||
# - ALL stopped containers.
|
# - ALL stopped containers.
|
||||||
#
|
#
|
||||||
|
# The default mode escalates to --force on its own when / drops below 8 GB free,
|
||||||
|
# so the bound holds even if this stops running on schedule.
|
||||||
|
#
|
||||||
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
||||||
# Idempotent; exits 0 when Docker is unavailable.
|
# Idempotent; exits 0 when Docker is unavailable.
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
@@ -34,15 +40,101 @@ command -v docker >/dev/null 2>&1 || {
|
|||||||
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||||
|
|
||||||
|
# A hard ceiling on the root filesystem is what actually bounds the growth, so
|
||||||
|
# the emergency path is reached on disk pressure rather than only on a timer.
|
||||||
|
# The image/container passes stay age-gated: a rollback image and a stopped
|
||||||
|
# container are cheap to keep for a week and expensive to lose.
|
||||||
|
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
|
||||||
|
# 8 GB free is comfortable for a database plus a release swap.
|
||||||
|
if (( FREE_KB < 8 * 1024 * 1024 )); then
|
||||||
|
FORCE=1
|
||||||
|
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
|
||||||
|
"$(now)" "$FREE_KB" >>"$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
if (( FORCE )); then
|
if (( FORCE )); then
|
||||||
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
||||||
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
||||||
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
||||||
else
|
else
|
||||||
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
|
# --max-used-space and --filter are mutually exclusive in buildx: passing
|
||||||
|
# both makes the cap a no-op and the cache grows without bound. The cap alone
|
||||||
|
# is the bound, and it evicts least-recently-used entries to get there.
|
||||||
|
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
|
||||||
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
||||||
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Dangling images have no tag and no container, so nothing can reference them.
|
||||||
|
# They are what repeated local builds leave behind.
|
||||||
|
docker image prune -f >>"$LOG_FILE" 2>&1 || true
|
||||||
|
|
||||||
|
# ── Interrupted git gc leftovers ─────────────────────────────────
|
||||||
|
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
|
||||||
|
# nothing reclaims it: git only clears those on the next successful gc. One such
|
||||||
|
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
|
||||||
|
# than a day are considered, so a gc running right now is never touched.
|
||||||
|
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
|
||||||
|
while IFS= read -r -d '' tmp; do
|
||||||
|
size=$(du -h "$tmp" | cut -f1)
|
||||||
|
rm -f "$tmp"
|
||||||
|
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
|
||||||
|
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
|
||||||
|
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Orphaned browser profiles ─────────────────────────────────────
|
||||||
|
# byparr launches a real Firefox per request, and each launch leaves a
|
||||||
|
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
|
||||||
|
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
|
||||||
|
# days on this host, ~1.7 GB/day.
|
||||||
|
#
|
||||||
|
# Deleting a profile out from under a running browser kills that job, so live
|
||||||
|
# ones are identified the only way that is reliable rather than by age: a
|
||||||
|
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
|
||||||
|
# pointing into the directory. Anything not referenced that way, and untouched
|
||||||
|
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
|
||||||
|
#
|
||||||
|
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
|
||||||
|
# age window that is safe for the leak is far too wide for the disk.
|
||||||
|
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||||
|
for container in ${BYPARR_CONTAINERS:-byparr}; do
|
||||||
|
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
|
||||||
|
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
|
||||||
|
|
||||||
|
removed=$(
|
||||||
|
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
|
||||||
|
set -u
|
||||||
|
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||||
|
base="${1:-/tmp}"
|
||||||
|
live_file=$(mktemp)
|
||||||
|
# Live profiles are the ones a running process still holds open.
|
||||||
|
for p in $(ps -eo pid= 2>/dev/null); do
|
||||||
|
ls -l "/proc/$p/fd" 2>/dev/null
|
||||||
|
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
|
||||||
|
|
||||||
|
count=0
|
||||||
|
for dir in "$base"/playwright_firefoxdev_profile-*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
# Never touch something a process is still using.
|
||||||
|
grep -Fxq "$dir" "$live_file" && continue
|
||||||
|
# A profile a browser is still writing to is not an orphan
|
||||||
|
# yet, even if the directory itself looks old.
|
||||||
|
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
|
||||||
|
done
|
||||||
|
rm -f "$live_file"
|
||||||
|
printf "%s" "$count"
|
||||||
|
' sh /tmp 2>/dev/null || printf '0'
|
||||||
|
)
|
||||||
|
if [[ "${removed:-0}" -gt 0 ]]; then
|
||||||
|
printf '[%s] removed %s orphaned browser profiles from %s\n' \
|
||||||
|
"$(now)" "$removed" "$container" >>"$LOG_FILE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
import { it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
detectMemoryLimitMb,
|
||||||
|
heapLimitMb,
|
||||||
|
runtimeNodeOptions,
|
||||||
|
} from "./docker-start.mjs";
|
||||||
|
|
||||||
it("imports runtime validation without starting the CMS", () => {
|
it("imports runtime validation without starting the CMS", () => {
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
@@ -15,3 +21,89 @@ it("imports runtime validation without starting the CMS", () => {
|
|||||||
);
|
);
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("heap limit", () => {
|
||||||
|
it("leaves headroom for the memory V8 does not account for", () => {
|
||||||
|
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
|
||||||
|
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
|
||||||
|
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to a floor and a ceiling", () => {
|
||||||
|
// Too small to run a Next.js server at all: floor wins.
|
||||||
|
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
|
||||||
|
// A huge or absent limit must not turn into a 100 GB heap.
|
||||||
|
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
|
||||||
|
expect(heapLimitMb(Number.NaN)).toBe(8192);
|
||||||
|
expect(heapLimitMb(0)).toBe(8192);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cgroup detection", () => {
|
||||||
|
const asReader = (contents) => (path) => {
|
||||||
|
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
|
||||||
|
return contents[path];
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads the cgroup v2 limit", () => {
|
||||||
|
expect(
|
||||||
|
detectMemoryLimitMb(
|
||||||
|
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
|
||||||
|
),
|
||||||
|
).toBe(2867);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to cgroup v1 when v2 is absent", () => {
|
||||||
|
expect(
|
||||||
|
detectMemoryLimitMb(
|
||||||
|
asReader({
|
||||||
|
"/sys/fs/cgroup/memory.max": "",
|
||||||
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(4300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unlimited cgroup as no limit at all", () => {
|
||||||
|
// cgroup v1 reports "max"; a bare sentinel means the same thing.
|
||||||
|
expect(
|
||||||
|
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
|
||||||
|
).toBe(8192);
|
||||||
|
expect(
|
||||||
|
detectMemoryLimitMb(
|
||||||
|
asReader({
|
||||||
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(8192);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when neither cgroup file is readable", () => {
|
||||||
|
expect(
|
||||||
|
detectMemoryLimitMb(() => {
|
||||||
|
throw new Error("ENOENT");
|
||||||
|
}),
|
||||||
|
).toBe(8192);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NODE_OPTIONS", () => {
|
||||||
|
it("adds the cap when none is set", () => {
|
||||||
|
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
|
||||||
|
expect(runtimeNodeOptions(undefined, 2867)).toBe(
|
||||||
|
"--max-old-space-size=2867",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unrelated options already present", () => {
|
||||||
|
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
|
||||||
|
"--no-warnings --max-old-space-size=2867",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overrides an explicit operator choice", () => {
|
||||||
|
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
|
||||||
|
"--max-old-space-size=8192",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,70 @@
|
|||||||
// Fail before listening if an installation has no valid runtime configuration.
|
// Fail before listening if an installation has no valid runtime configuration.
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
/** Fraction of the container memory limit V8 is allowed to use for its heap.
|
||||||
|
* The rest has to cover native allocations the JS heap cannot account for:
|
||||||
|
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
|
||||||
|
* RSC rendering. */
|
||||||
|
const HEAP_FRACTION = 0.7;
|
||||||
|
const MIN_HEAP_MB = 512;
|
||||||
|
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
|
||||||
|
* for 4300 MB, and a backstop at or below that would silently turn the fraction
|
||||||
|
* into a fixed number and make the two limits disagree. This exists purely so a
|
||||||
|
* nonsensical cgroup reading cannot ask for an unbounded heap. */
|
||||||
|
const MAX_HEAP_MB = 8192;
|
||||||
|
|
||||||
|
export function heapLimitMb(cgroupLimitBytes) {
|
||||||
|
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
|
||||||
|
return MAX_HEAP_MB;
|
||||||
|
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
|
||||||
|
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read this container's memory ceiling from cgroup v2, falling back to v1.
|
||||||
|
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
|
||||||
|
* container on a 24 GB host lets the heap grow past the limit and the kernel
|
||||||
|
* OOM-kills the process mid-request — which is what produced the
|
||||||
|
* `next-build (v16)` kills in the host logs. A container that GCs before it
|
||||||
|
* reaches the ceiling degrades to a slower page instead of a killed process.
|
||||||
|
*/
|
||||||
|
export function detectMemoryLimitMb(readFile = readFileSync) {
|
||||||
|
const candidates = [
|
||||||
|
"/sys/fs/cgroup/memory.max",
|
||||||
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||||
|
];
|
||||||
|
for (const path of candidates) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = readFile(path, "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
|
||||||
|
// sentinel of a very large number on some kernels.
|
||||||
|
if (raw === "max" || raw === "") continue;
|
||||||
|
const bytes = Number(raw);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) continue;
|
||||||
|
// A host-sized "limit" means no cgroup ceiling was applied.
|
||||||
|
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
|
||||||
|
return heapLimitMb(bytes);
|
||||||
|
}
|
||||||
|
return heapLimitMb(Number.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeNodeOptions(
|
||||||
|
existing = "",
|
||||||
|
heapMb = detectMemoryLimitMb(),
|
||||||
|
) {
|
||||||
|
const flag = `--max-old-space-size=${heapMb}`;
|
||||||
|
if (!existing.trim()) return flag;
|
||||||
|
// Respect an explicit operator override; only add the cap when absent.
|
||||||
|
if (existing.includes("--max-old-space-size")) return existing;
|
||||||
|
return `${existing} ${flag}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateRuntime(settings) {
|
export function validateRuntime(settings) {
|
||||||
const invalid = [];
|
const invalid = [];
|
||||||
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
|
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
|
||||||
@@ -33,7 +96,13 @@ if (
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
validateRuntime(process.env);
|
validateRuntime(process.env);
|
||||||
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
const heapMb = detectMemoryLimitMb();
|
||||||
|
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
|
||||||
|
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
|
||||||
|
const child = spawn(process.execPath, ["server.js"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, NODE_OPTIONS: nodeOptions },
|
||||||
|
});
|
||||||
for (const signal of ["SIGTERM", "SIGINT"])
|
for (const signal of ["SIGTERM", "SIGINT"])
|
||||||
process.on(signal, () => child.kill(signal));
|
process.on(signal, () => child.kill(signal));
|
||||||
child.on("error", () => {
|
child.on("error", () => {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
|||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
die() { log "ERROR: $*"; exit 1; }
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
# A run that legitimately has nothing to do (the host is owned by CI) must not
|
||||||
|
# look like a failure to cron. `die` stays reserved for real errors.
|
||||||
|
skip() { log "SKIP: $*"; exit 0; }
|
||||||
migration_image=""
|
migration_image=""
|
||||||
remote_migration_image=""
|
remote_migration_image=""
|
||||||
previous_image=""
|
previous_image=""
|
||||||
@@ -58,10 +61,27 @@ trap 'exit 130' INT
|
|||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
|
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
|
||||||
|
|
||||||
# An existing CI deployment is a different owner of the same host port.
|
# This host belongs to CI: the blue/green deploy owns both host ports (3002 and
|
||||||
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
|
# 3003) and one of the two slot containers is always the live release. Compose
|
||||||
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
|
# may only run where CI does not.
|
||||||
|
#
|
||||||
|
# Checking epicnext-cms-app alone was not enough. After a cutover to the green
|
||||||
|
# slot the blue container is stopped, renamed and deleted, so the guard stopped
|
||||||
|
# firing while the host stayed CI-managed. `docker compose up` then recreated a
|
||||||
|
# replica named epicnext-cms on port 3002 — the blue slot, exactly where the next
|
||||||
|
# candidate has to start — and every later release failed on a busy port until
|
||||||
|
# someone removed that container by hand (see logs/docker-update.cron.log).
|
||||||
|
# Therefore: both slot containers count, and so does the nginx upstream, which is
|
||||||
|
# the only thing that still marks the host as blue/green when a slot is idle.
|
||||||
|
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||||
|
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
||||||
|
skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
|
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
||||||
|
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
||||||
|
skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
||||||
|
fi
|
||||||
|
done
|
||||||
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||||
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
|
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
|
||||||
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
|
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
|
||||||
@@ -106,7 +126,7 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
|||||||
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
||||||
docker tag "$remote_migration_image" "$migration_image"
|
docker tag "$remote_migration_image" "$migration_image"
|
||||||
else
|
else
|
||||||
docker build --network=host --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||||
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||||
fi
|
fi
|
||||||
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import "./load-env";
|
import "./load-env";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||||
|
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
|
||||||
import { readFurniData } from "@/lib/services/furni-data";
|
import { readFurniData } from "@/lib/services/furni-data";
|
||||||
|
|
||||||
const ICON_DIR = path.join(
|
const ICON_DIR = path.join(
|
||||||
@@ -191,11 +192,10 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let nitroMissing = 0;
|
let nitroMissing = 0;
|
||||||
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
|
|
||||||
for (const cls of catalogClasses) {
|
for (const cls of catalogClasses) {
|
||||||
if (!nitroSet.has(`${cls}.nitro`)) nitroMissing++;
|
if (!bundleExistsInDir(NITRO_DIR, cls)) nitroMissing++;
|
||||||
}
|
}
|
||||||
console.log(`[5] catalog items without .nitro bundle: ${nitroMissing}`);
|
console.log(`[5] catalog items without a bundle on disk: ${nitroMissing}`);
|
||||||
|
|
||||||
await db.$client.end();
|
await db.$client.end();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import "./load-env";
|
||||||
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
autoDetectInteraction,
|
||||||
|
nitroAnimationStatesCount,
|
||||||
|
} from "@/lib/furni/auto-interaction";
|
||||||
|
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a plain SQL file that repairs every furniture row in `items_base`
|
||||||
|
* from the visual logic inside each `.hab` / `.nitro` bundle:
|
||||||
|
*
|
||||||
|
* width / length / stack_height ← logic.model.dimensions x/y/z
|
||||||
|
* allow_stack ← z > 0
|
||||||
|
* allow_sit / allow_lay / allow_walk ← furnidata flags (cansiton/canlayon/canstandon)
|
||||||
|
* interaction_modes_count ← real nitro animation states / mechanic
|
||||||
|
* interaction_type ← logicType + classname mechanic (only fills 'default')
|
||||||
|
*
|
||||||
|
* The dimensions are NOT in furnidata — they live in the bundle JSON at
|
||||||
|
* `logic.model.dimensions` (or top-level `dimensions` for official bundles).
|
||||||
|
* The interaction columns mirror `verifyAndFixInteractionModesCount`
|
||||||
|
* (src/lib/services/furni-import.ts:1341): real furnidata flags + .nitro states,
|
||||||
|
* and `interaction_type` is only touched when it is still `default`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm exec tsx scripts/generate-stack-height-sql.ts [options]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --dir=<path> bundle directory (default: /var/www/Gamedata/bundled/furniture)
|
||||||
|
* --furnidata=<path> local FurnitureData.json (default: /var/www/Gamedata/config/FurnitureData.json)
|
||||||
|
* --out=<path> SQL output file (default: stack-heights.sql)
|
||||||
|
* --types=<list> items_base types (default: s,i → floor + wall furniture)
|
||||||
|
* --all emit every matched row instead of only changed rows
|
||||||
|
* --no-interaction dimensions/allow_stack only, skip interaction columns
|
||||||
|
*
|
||||||
|
* Nothing in the database is modified here; you run the generated file yourself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const args = new Map<string, string | true>();
|
||||||
|
for (const raw of process.argv.slice(2)) {
|
||||||
|
const eq = raw.indexOf("=");
|
||||||
|
if (eq === -1) args.set(raw.replace(/^--/, ""), true);
|
||||||
|
else args.set(raw.slice(2, eq), raw.slice(eq + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIR = String(args.get("dir") ?? "/var/www/Gamedata/bundled/furniture");
|
||||||
|
const FURNIDATA = String(
|
||||||
|
args.get("furnidata") ?? "/var/www/Gamedata/config/FurnitureData.json",
|
||||||
|
);
|
||||||
|
const OUT = String(args.get("out") ?? "stack-heights.sql");
|
||||||
|
const TYPES = String(args.get("types") ?? "s,i")
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const EMIT_ALL = args.has("all");
|
||||||
|
const WITH_INTERACTION = !args.has("no-interaction");
|
||||||
|
|
||||||
|
const EXTENSIONS = [".hab", ".nitro"] as const;
|
||||||
|
|
||||||
|
interface Dims {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Flags {
|
||||||
|
cansiton: boolean;
|
||||||
|
canlayon: boolean;
|
||||||
|
canstandon: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BundleInfo {
|
||||||
|
dims: Dims | null;
|
||||||
|
animationStates: number | undefined;
|
||||||
|
logicType: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
item_name: string;
|
||||||
|
public_name: string;
|
||||||
|
width: number;
|
||||||
|
length: number;
|
||||||
|
stack_height: number;
|
||||||
|
allow_stack: number | string;
|
||||||
|
allow_sit: number | string;
|
||||||
|
allow_lay: number | string;
|
||||||
|
allow_walk: number | string;
|
||||||
|
interaction_type: string;
|
||||||
|
interaction_modes_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a value for a single-quoted MySQL string literal. */
|
||||||
|
function q(value: string): string {
|
||||||
|
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `enum('0','1')` values must be quoted — an unquoted 0 is read as index 0 (''). */
|
||||||
|
function qbit(value: boolean): string {
|
||||||
|
return value ? "'1'" : "'0'";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthyBit(value: number | string): boolean {
|
||||||
|
return Number(value) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull dimensions out of a parsed bundle JSON, tolerating both layouts. */
|
||||||
|
function extractDims(json: unknown): Dims | null {
|
||||||
|
const root = json as Record<string, unknown> | null;
|
||||||
|
if (!root) return null;
|
||||||
|
const logic = root.logic as Record<string, unknown> | undefined;
|
||||||
|
const model = logic?.model as Record<string, unknown> | undefined;
|
||||||
|
const candidate =
|
||||||
|
(model?.dimensions as Dims | undefined) ??
|
||||||
|
(logic?.dimensions as Dims | undefined) ??
|
||||||
|
(root.dimensions as Dims | undefined);
|
||||||
|
if (!candidate) return null;
|
||||||
|
const x = Number(candidate.x);
|
||||||
|
const y = Number(candidate.y);
|
||||||
|
const z = Number(candidate.z);
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { x, y, z };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a bundle once and cache everything we need from it. */
|
||||||
|
function readBundleInfo(path: string): BundleInfo {
|
||||||
|
try {
|
||||||
|
const json = parseNitroBundle(readFileSync(path)).json as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
return {
|
||||||
|
dims: extractDims(json),
|
||||||
|
animationStates: nitroAnimationStatesCount(json),
|
||||||
|
logicType:
|
||||||
|
typeof json.logicType === "string" ? json.logicType : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { dims: null, animationStates: undefined, logicType: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the local FurnitureData.json into a classname → flags map. Duplicate
|
||||||
|
* classnames are merged so a true flag in any entry wins (same rule as the
|
||||||
|
* app's `lookupRealFurniFlags`).
|
||||||
|
*/
|
||||||
|
function loadFurniFlags(path: string): Map<string, Flags> {
|
||||||
|
const map = new Map<string, Flags>();
|
||||||
|
if (!existsSync(path)) return map;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(readFileSync(path, "utf-8")) as Record<
|
||||||
|
string,
|
||||||
|
{ furnitype?: Array<Record<string, unknown>> }
|
||||||
|
>;
|
||||||
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
|
for (const item of json[section]?.furnitype ?? []) {
|
||||||
|
const cn = item.classname;
|
||||||
|
if (typeof cn !== "string" || !cn) continue;
|
||||||
|
const candidate: Flags = {
|
||||||
|
cansiton: !!item.cansiton,
|
||||||
|
canlayon: !!item.canlayon,
|
||||||
|
canstandon: !!item.canstandon,
|
||||||
|
};
|
||||||
|
const existing = map.get(cn);
|
||||||
|
if (!existing) {
|
||||||
|
map.set(cn, candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.cansiton = existing.cansiton || candidate.cansiton;
|
||||||
|
existing.canlayon = existing.canlayon || candidate.canlayon;
|
||||||
|
existing.canstandon = existing.canstandon || candidate.canstandon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// unreadable furnidata → interaction columns are skipped
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const dir = resolve(DIR);
|
||||||
|
const entries = new Set(readdirSync(dir));
|
||||||
|
const infoCache = new Map<string, BundleInfo>();
|
||||||
|
const flagsMap = WITH_INTERACTION
|
||||||
|
? loadFurniFlags(resolve(FURNIDATA))
|
||||||
|
: new Map<string, Flags>();
|
||||||
|
|
||||||
|
const [rows] = (await db.execute(
|
||||||
|
sql`SELECT item_name, public_name, width, length, stack_height, allow_stack,
|
||||||
|
allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count
|
||||||
|
FROM items_base
|
||||||
|
WHERE type IN (${sql.join(
|
||||||
|
TYPES.map((t) => sql`${t}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
)) as unknown as [Row[], unknown];
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[stack-sql] ${rows.length} furniture rows | ${entries.size} bundles | ${flagsMap.size} furnidata flags`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
let matched = 0;
|
||||||
|
let changed = 0;
|
||||||
|
let resolvedBase = 0;
|
||||||
|
const counts = {
|
||||||
|
dims: 0,
|
||||||
|
allowFlags: 0,
|
||||||
|
modes: 0,
|
||||||
|
interactionType: 0,
|
||||||
|
};
|
||||||
|
const missing: string[] = [];
|
||||||
|
const unparsed: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = row.item_name;
|
||||||
|
const base = name.includes("*") ? name.slice(0, name.indexOf("*")) : name;
|
||||||
|
const variants = [name, name.trim(), base, base.trim()];
|
||||||
|
|
||||||
|
let chosen: string | null = null;
|
||||||
|
for (const variant of variants) {
|
||||||
|
for (const ext of EXTENSIONS) {
|
||||||
|
const candidate = `${variant}${ext}`;
|
||||||
|
if (entries.has(candidate)) {
|
||||||
|
chosen = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen) break;
|
||||||
|
}
|
||||||
|
if (!chosen) {
|
||||||
|
missing.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!chosen.startsWith(`${name}.`)) resolvedBase++;
|
||||||
|
|
||||||
|
let info = infoCache.get(chosen);
|
||||||
|
if (!info) {
|
||||||
|
info = readBundleInfo(resolve(dir, chosen));
|
||||||
|
infoCache.set(chosen, info);
|
||||||
|
}
|
||||||
|
if (!info.dims) {
|
||||||
|
unparsed.push(`${name} (${chosen})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matched++;
|
||||||
|
|
||||||
|
const width = Math.trunc(info.dims.x);
|
||||||
|
const length = Math.trunc(info.dims.y);
|
||||||
|
const stackHeight = Number(info.dims.z.toFixed(2));
|
||||||
|
const allowStack = info.dims.z > 0;
|
||||||
|
|
||||||
|
const setParts: string[] = [];
|
||||||
|
const dimsChanged =
|
||||||
|
row.width !== width ||
|
||||||
|
row.length !== length ||
|
||||||
|
Number(row.stack_height) !== stackHeight ||
|
||||||
|
isTruthyBit(row.allow_stack) !== allowStack;
|
||||||
|
if (dimsChanged) counts.dims++;
|
||||||
|
setParts.push(
|
||||||
|
`width=${width}`,
|
||||||
|
`length=${length}`,
|
||||||
|
`stack_height=${stackHeight}`,
|
||||||
|
`allow_stack=${qbit(allowStack)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Interaction columns — only when the classname exists in furnidata
|
||||||
|
// (mirrors the app sweep: authoritative flags, never keyword guesses).
|
||||||
|
const flags =
|
||||||
|
flagsMap.get(name) ??
|
||||||
|
flagsMap.get(base) ??
|
||||||
|
flagsMap.get(name.trim()) ??
|
||||||
|
flagsMap.get(base.trim());
|
||||||
|
if (WITH_INTERACTION && flags) {
|
||||||
|
const auto = autoDetectInteraction(name, row.public_name || undefined, {
|
||||||
|
cansiton: flags.cansiton,
|
||||||
|
canlayon: flags.canlayon,
|
||||||
|
canstandon: flags.canstandon,
|
||||||
|
hasActionData: true,
|
||||||
|
animationStates: info.animationStates,
|
||||||
|
logicType: info.logicType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number(row.allow_sit) !== 2 &&
|
||||||
|
isTruthyBit(row.allow_sit) !== auto.canSit
|
||||||
|
) {
|
||||||
|
setParts.push(`allow_sit=${qbit(auto.canSit)}`);
|
||||||
|
counts.allowFlags++;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number(row.allow_lay) !== 2 &&
|
||||||
|
isTruthyBit(row.allow_lay) !== auto.canLay
|
||||||
|
) {
|
||||||
|
setParts.push(`allow_lay=${qbit(auto.canLay)}`);
|
||||||
|
counts.allowFlags++;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number(row.allow_walk) !== 2 &&
|
||||||
|
isTruthyBit(row.allow_walk) !== auto.canStand
|
||||||
|
) {
|
||||||
|
setParts.push(`allow_walk=${qbit(auto.canStand)}`);
|
||||||
|
counts.allowFlags++;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number(row.interaction_modes_count ?? 0) !== auto.interactionModesCount
|
||||||
|
) {
|
||||||
|
setParts.push(`interaction_modes_count=${auto.interactionModesCount}`);
|
||||||
|
counts.modes++;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(row.interaction_type === "default" || !row.interaction_type) &&
|
||||||
|
auto.interactionType !== "default"
|
||||||
|
) {
|
||||||
|
setParts.push(`interaction_type=${q(auto.interactionType)}`);
|
||||||
|
counts.interactionType++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChanged =
|
||||||
|
dimsChanged ||
|
||||||
|
setParts.some(
|
||||||
|
(p) =>
|
||||||
|
!p.startsWith("width=") &&
|
||||||
|
!p.startsWith("length=") &&
|
||||||
|
!p.startsWith("stack_height=") &&
|
||||||
|
!p.startsWith("allow_stack="),
|
||||||
|
);
|
||||||
|
if (isChanged) changed++;
|
||||||
|
if (!EMIT_ALL && !isChanged) continue;
|
||||||
|
|
||||||
|
updates.push(
|
||||||
|
`UPDATE items_base SET ${setParts.join(", ")} WHERE item_name=${q(name)} AND type IN (${TYPES.map(q).join(", ")});`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
"-- Auto-generated by scripts/generate-stack-height-sql.ts",
|
||||||
|
`-- Source bundles: ${dir}`,
|
||||||
|
`-- Furnidata: ${WITH_INTERACTION ? resolve(FURNIDATA) : "(disabled)"}`,
|
||||||
|
`-- Generated: ${new Date().toISOString()}`,
|
||||||
|
`-- Furniture rows: ${rows.length} | matched: ${matched} | changed: ${changed} | missing bundle: ${missing.length} | unparsable: ${unparsed.length}`,
|
||||||
|
`-- Changes: dimensions ${counts.dims} | allow_sit/lay/walk ${counts.allowFlags} | modes_count ${counts.modes} | interaction_type ${counts.interactionType}`,
|
||||||
|
`-- Mode: ${EMIT_ALL ? "all matched rows" : "changed rows only"}`,
|
||||||
|
"",
|
||||||
|
"START TRANSACTION;",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
const footer = "\n\nCOMMIT;\n";
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
resolve(OUT),
|
||||||
|
`${header}${updates.join("\n")}${footer}`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[stack-sql] matched ${matched}, changed ${changed}`);
|
||||||
|
console.log(
|
||||||
|
`[stack-sql] changes -> dims ${counts.dims}, allow flags ${counts.allowFlags}, modes ${counts.modes}, interaction_type ${counts.interactionType}`,
|
||||||
|
);
|
||||||
|
if (resolvedBase > 0)
|
||||||
|
console.log(`[stack-sql] resolved via base classname: ${resolvedBase}`);
|
||||||
|
console.log(`[stack-sql] updates written: ${updates.length}`);
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(
|
||||||
|
`[stack-sql] no bundle (${missing.length}): ${missing.slice(0, 20).join(", ")}${missing.length > 20 ? ", …" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (unparsed.length) {
|
||||||
|
console.log(
|
||||||
|
`[stack-sql] unparsable (${unparsed.length}): ${unparsed.slice(0, 20).join(", ")}${unparsed.length > 20 ? ", …" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[stack-sql] wrote ${resolve(OUT)} in ${Date.now() - t0}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Alert when the local avatar imager stops rendering, and again when it recovers.
|
||||||
|
#
|
||||||
|
# Intended to run from cron every few minutes (see `crontab -l`). Steady-state is
|
||||||
|
# silent; a state change posts to DISCORD_WEBHOOK_URL (from the environment or
|
||||||
|
# .env). While the imager stays down it re-alerts at most once per
|
||||||
|
# HEALTH_ALERT_COOLDOWN_MIN. The script always exits 0 so an outage never turns
|
||||||
|
# into a noisy cron failure; the alert itself (and this log) is the signal.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOG_FILE="${IMAGER_HEALTH_LOG:-$DIR/logs/imager-health.log}"
|
||||||
|
STATE_FILE="${IMAGER_HEALTH_STATE:-$DIR/logs/imager-health.state}"
|
||||||
|
HEALTH_URL="${IMAGER_HEALTH_URL:-http://127.0.0.1:8082/health}"
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")"
|
||||||
|
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "${!name:-}" ]; then return 0; fi
|
||||||
|
if [ -f "$DIR/.env" ]; then
|
||||||
|
local line
|
||||||
|
line="$(grep -m1 "^$name=" "$DIR/.env" | cut -d= -f2- | tr -d "'\"")" || true
|
||||||
|
if [ -n "$line" ]; then printf -v "$name" '%s' "$line"; fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env DISCORD_WEBHOOK_URL || true
|
||||||
|
COOLDOWN_MIN="${HEALTH_ALERT_COOLDOWN_MIN:-30}"
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
local msg="$1"
|
||||||
|
log "ALERT: $msg"
|
||||||
|
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||||
|
log "no DISCORD_WEBHOOK_URL configured; alert written to log only"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local payload
|
||||||
|
payload="$(python3 -c 'import json,sys;print(json.dumps({"content":sys.argv[1]}))' "$msg")"
|
||||||
|
curl -sS -m 10 -H "Content-Type: application/json" -d "$payload" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" >/dev/null 2>&1 || log "failed to post alert to Discord"
|
||||||
|
}
|
||||||
|
|
||||||
|
body="$(curl -sS -m 5 "$HEALTH_URL" 2>/dev/null || true)"
|
||||||
|
ready=0
|
||||||
|
if printf '%s' "$body" | grep -q '"ready":true'; then ready=1; fi
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
prev_state="up"
|
||||||
|
prev_at=0
|
||||||
|
if [ -f "$STATE_FILE" ]; then read -r prev_state prev_at < "$STATE_FILE" || true; fi
|
||||||
|
[ -n "${prev_state:-}" ] || prev_state=up
|
||||||
|
[ -n "${prev_at:-}" ] || prev_at=0
|
||||||
|
|
||||||
|
if [ "$ready" = 1 ]; then
|
||||||
|
if [ "$prev_state" != "up" ]; then
|
||||||
|
notify "Avatar imager RECOVERED ($HEALTH_URL reports ready)"
|
||||||
|
fi
|
||||||
|
printf 'up %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$prev_state" != "down" ]; then
|
||||||
|
notify "Avatar imager DOWN ($HEALTH_URL not ready: ${body:-unreachable})"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(( now - prev_at ))" -ge "$(( COOLDOWN_MIN * 60 ))" ]; then
|
||||||
|
notify "Avatar imager STILL DOWN after ${COOLDOWN_MIN}m ($HEALTH_URL)"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
// Must stay the first import. ESM evaluates a module's imports in source
|
||||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
// order, and `../src/features/operations/worker` reaches `@/env`, which parses
|
||||||
|
// process.env at import time. With this import further down the tree, load-env
|
||||||
|
// ran *after* the schema validation had already thrown on a missing
|
||||||
|
// DATABASE_URL, so the worker could only ever start from an environment that
|
||||||
|
// already exported the config — which is why `pnpm jobs:worker` died
|
||||||
|
// immediately and nothing supervised it.
|
||||||
import "./load-env";
|
import "./load-env";
|
||||||
|
import * as nodeFs from "node:fs";
|
||||||
|
import * as nodePath from "node:path";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
import { lt, sql } from "drizzle-orm";
|
import { lt, sql } from "drizzle-orm";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
|
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { redis } from "../src/lib/redis";
|
import { redis } from "../src/lib/redis";
|
||||||
@@ -18,7 +26,9 @@ import {
|
|||||||
diskLevel,
|
diskLevel,
|
||||||
parseDfOutput,
|
parseDfOutput,
|
||||||
} from "../src/lib/services/disk-usage";
|
} from "../src/lib/services/disk-usage";
|
||||||
|
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||||
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
||||||
|
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
|
||||||
import { rcon } from "../src/lib/services/rcon";
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
|
|
||||||
function captureWorkerError(err: unknown, context: string): void {
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
@@ -160,13 +170,69 @@ async function checkDiskUsage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the JAR to back up. `EMULATOR_JAR_PATH` may point at the file itself
|
||||||
|
* or at a directory of release JARs, because the emulator's own unit file
|
||||||
|
* launches `ls -t Polaris-*-jar-with-dependencies.jar` — a path pinned to one
|
||||||
|
* release filename goes stale on the next emulator upgrade, and a stale path
|
||||||
|
* fails as a bare ENOENT from copyFile that gives no hint what is wrong. A
|
||||||
|
* directory (or a path with a `*`) resolves to the most recently modified JAR,
|
||||||
|
* matching how the emulator actually picks its build.
|
||||||
|
*/
|
||||||
|
export function resolveEmulatorJar(
|
||||||
|
configuredPath: string,
|
||||||
|
fs: typeof import("node:fs") = nodeFs,
|
||||||
|
{ resolve }: typeof import("node:path") = nodePath,
|
||||||
|
): string | null {
|
||||||
|
const { existsSync, readdirSync, statSync } = fs;
|
||||||
|
if (configuredPath.includes("*")) {
|
||||||
|
const dir = configuredPath.slice(0, configuredPath.lastIndexOf("/") + 1);
|
||||||
|
const pattern = configuredPath.slice(dir.length);
|
||||||
|
if (!existsSync(dir)) return null;
|
||||||
|
return (
|
||||||
|
readdirSync(dir)
|
||||||
|
.filter((name: string) => name.startsWith(pattern.split("*")[0] ?? ""))
|
||||||
|
.map((name: string) => resolve(dir, name))
|
||||||
|
.filter((path: string) => existsSync(path))
|
||||||
|
.sort(
|
||||||
|
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||||
|
)[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existsSync(configuredPath) && statSync(configuredPath).isFile())
|
||||||
|
return configuredPath;
|
||||||
|
// A directory: take the newest JAR in it.
|
||||||
|
if (existsSync(configuredPath) && statSync(configuredPath).isDirectory()) {
|
||||||
|
return (
|
||||||
|
readdirSync(configuredPath)
|
||||||
|
.filter((name: string) => name.endsWith(".jar"))
|
||||||
|
.map((name: string) => resolve(configuredPath, name))
|
||||||
|
.sort(
|
||||||
|
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||||
|
)[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function backupEmulatorJar(): Promise<void> {
|
async function backupEmulatorJar(): Promise<void> {
|
||||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||||
|
|
||||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
|
const fs = await import("node:fs");
|
||||||
await import("node:fs");
|
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs;
|
||||||
const { resolve } = await import("node:path");
|
const { resolve } = await import("node:path");
|
||||||
|
|
||||||
|
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
|
||||||
|
if (!jarPath) {
|
||||||
|
// Configured but unusable: say so once, loudly, instead of every night
|
||||||
|
// logging an opaque copyFile ENOENT that reads like a permissions bug.
|
||||||
|
logger.error(
|
||||||
|
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
|
||||||
|
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||||
const backupFile = resolve(
|
const backupFile = resolve(
|
||||||
env.EMULATOR_BACKUP_DIR,
|
env.EMULATOR_BACKUP_DIR,
|
||||||
@@ -178,10 +244,11 @@ async function backupEmulatorJar(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
|
copyFileSync(jarPath, backupFile);
|
||||||
logger.info("Backed up emulator JAR", {
|
logger.info("Backed up emulator JAR", {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
backupFile,
|
backupFile,
|
||||||
|
source: jarPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
||||||
@@ -353,6 +420,27 @@ async function publishScheduledArticles(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nightly self-cleaning of old fake .nitro leftovers (skips when a scan is
|
||||||
|
* running in the studio). Requires the `items_base` truth list, so a DB outage
|
||||||
|
* shows up here as a captured error instead of deleting anything. */
|
||||||
|
async function autoCleanNitroFakes(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await scheduledAutoCleanFakeNitros(30);
|
||||||
|
if (result.skipped) {
|
||||||
|
logger.info("Skipped nightly nitro auto-clean: scan in progress", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info("Nightly nitro auto-clean finished", {
|
||||||
|
module: "jobs",
|
||||||
|
deleted: result.deleted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
captureWorkerError(err, "Nitro auto-clean failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reportWorkerHeartbeat(): Promise<void> {
|
async function reportWorkerHeartbeat(): Promise<void> {
|
||||||
if (!redis) return;
|
if (!redis) return;
|
||||||
await redis
|
await redis
|
||||||
@@ -433,6 +521,33 @@ async function main() {
|
|||||||
void reportWorkerHeartbeat();
|
void reportWorkerHeartbeat();
|
||||||
});
|
});
|
||||||
await reportWorkerHeartbeat();
|
await reportWorkerHeartbeat();
|
||||||
|
|
||||||
|
new Cron("0 2 * * *", () => {
|
||||||
|
autoCleanNitroFakes().catch((e) =>
|
||||||
|
captureWorkerError(e, "Nitro auto-clean error"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: nitro auto-clean (daily 02:00)", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Cron("*/30 * * * *", () => {
|
||||||
|
// Purge the gamedata edge tag periodically as a safety net in case a
|
||||||
|
// single import failed to emit a purge (e.g. Cloudflare disabled at the
|
||||||
|
// moment of write). Without it, a long TTL on /gamedata/ would keep the
|
||||||
|
// client stuck on old FurnitureData.json until the browser or CDN cache
|
||||||
|
// expired. No-op when Cloudflare is not configured.
|
||||||
|
import("../src/lib/edge-cache")
|
||||||
|
.then(({ EDGE_CACHE_TAGS, purgeEdgeCache }) =>
|
||||||
|
purgeEdgeCache([EDGE_CACHE_TAGS.gamedata], "jobs-safety-net"),
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
captureWorkerError(e, "Gamedata edge purge (safety net) failed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: gamedata edge purge safety net (every 30 min)", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Rename on-disk furniture bundles from `.nitro` to `.hab`.
|
||||||
|
*
|
||||||
|
* Imports have written `<classname>.hab` since the bundle-extension switch, but
|
||||||
|
* everything already on disk kept its old name. That matters at runtime: the
|
||||||
|
* client asks for `.hab`, so a catalogue whose assets are still `.nitro` shows
|
||||||
|
* furniture that renders as nothing. This script closes that gap.
|
||||||
|
*
|
||||||
|
* It is deliberately conservative:
|
||||||
|
* - refuses to run without `--dry-run` or `--yes`;
|
||||||
|
* - never overwrites an existing `.hab` — a conflict is reported, not resolved;
|
||||||
|
* - never deletes anything, so a half-finished run is recoverable by hand;
|
||||||
|
* - idempotent: a second run over migrated dirs is a no-op.
|
||||||
|
*
|
||||||
|
* Run it in a maintenance window. The rename is per-file, so a client request
|
||||||
|
* for a given `.nitro` 404s from the moment that file is renamed until the
|
||||||
|
* client asks for `.hab`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm tsx scripts/migrate-nitro-to-hab.ts --dry-run
|
||||||
|
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes
|
||||||
|
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --dir /var/www/extra/bundles
|
||||||
|
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --skip-generic
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "./load-env";
|
||||||
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
getFurniAssetWriteTargets,
|
||||||
|
getGamedataRoot,
|
||||||
|
} from "@/lib/services/furni-asset-dirs";
|
||||||
|
import { getPublicAssetRoot } from "@/lib/services/public-asset-root";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||||
|
|
||||||
|
const LEGACY_EXT = ".nitro";
|
||||||
|
const TARGET_EXT = ".hab";
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
dryRun: boolean;
|
||||||
|
yes: boolean;
|
||||||
|
skipGeneric: boolean;
|
||||||
|
dirs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Args {
|
||||||
|
const dirs: string[] = [];
|
||||||
|
let dryRun = false;
|
||||||
|
let yes = false;
|
||||||
|
let skipGeneric = false;
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === "--dry-run") dryRun = true;
|
||||||
|
else if (arg === "--yes" || arg === "-y") yes = true;
|
||||||
|
else if (arg === "--skip-generic") skipGeneric = true;
|
||||||
|
else if (arg === "--dir") {
|
||||||
|
const value = argv[++i];
|
||||||
|
if (!value) throw Error("--dir needs a path");
|
||||||
|
dirs.push(path.resolve(value));
|
||||||
|
} else throw Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
return { dryRun, yes, skipGeneric, dirs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the app keeps bundles. Mirrors the resolution in figure-import.ts /
|
||||||
|
* effect-import.ts / pet-import.ts so the script follows the same site settings
|
||||||
|
* the running instance uses. Settings are best-effort: a database that is down
|
||||||
|
* must not stop an operator from migrating files, so failures fall back to the
|
||||||
|
* on-disk defaults rather than aborting.
|
||||||
|
*/
|
||||||
|
async function resolveDirs(skipGeneric: boolean): Promise<string[]> {
|
||||||
|
const found: string[] = [];
|
||||||
|
const push = (dir: string | null | undefined) => {
|
||||||
|
if (dir?.trim()) found.push(path.resolve(dir.trim()));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Furniture: primary + every configured mirror (gamedata, nitro-files).
|
||||||
|
try {
|
||||||
|
const targets = await getFurniAssetWriteTargets();
|
||||||
|
push(targets.nitroDir);
|
||||||
|
for (const mirror of targets.mirrorDirs) push(mirror.nitroDir);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
` ! could not read furniture asset settings (${(error as Error).message}); using defaults`,
|
||||||
|
);
|
||||||
|
push(
|
||||||
|
getRuntimePath(process.cwd(), "public/nitro-assets/bundled/furniture"),
|
||||||
|
);
|
||||||
|
push("/var/www/Gamedata/bundled/furniture");
|
||||||
|
}
|
||||||
|
|
||||||
|
let gamedataRoot = "";
|
||||||
|
try {
|
||||||
|
gamedataRoot = await getGamedataRoot();
|
||||||
|
} catch {
|
||||||
|
/* defaults below cover it */
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of ["figure", "effect"]) {
|
||||||
|
let configured = "";
|
||||||
|
try {
|
||||||
|
configured = (
|
||||||
|
(await siteSettings.get(`${type}_nitro_dir`, "")) ?? ""
|
||||||
|
).trim();
|
||||||
|
} catch {
|
||||||
|
/* fall through to defaults */
|
||||||
|
}
|
||||||
|
if (configured) push(configured);
|
||||||
|
else if (gamedataRoot)
|
||||||
|
push(getRuntimePath(gamedataRoot, `bundled/${type}`));
|
||||||
|
else
|
||||||
|
push(
|
||||||
|
getRuntimePath(
|
||||||
|
getPublicAssetRoot(),
|
||||||
|
`public/nitro-assets/bundled/${type}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pets: the CMS dir is `pet`, this deployment's gamedata dir is `pets`.
|
||||||
|
// Both spellings are listed so neither is silently skipped.
|
||||||
|
push(getRuntimePath(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"));
|
||||||
|
if (gamedataRoot) {
|
||||||
|
push(getRuntimePath(gamedataRoot, "bundled/pet"));
|
||||||
|
push(getRuntimePath(gamedataRoot, "bundled/pets"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `generic` holds the stock client UI bundles (selection_arrow, room,
|
||||||
|
// tile_cursor, place_holder…) that this CMS never imported. They are included
|
||||||
|
// by default because the renderer's `generic.asset.url` template resolves to
|
||||||
|
// `.hab` too — leaving them behind breaks the room view, not just furniture.
|
||||||
|
if (!skipGeneric && gamedataRoot) {
|
||||||
|
push(getRuntimePath(gamedataRoot, "bundled/generic"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(found)];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirResult {
|
||||||
|
dir: string;
|
||||||
|
renamed: number;
|
||||||
|
alreadyHab: number;
|
||||||
|
conflicts: Array<{ from: string; to: string }>;
|
||||||
|
errors: Array<{ file: string; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateDir(dir: string, dryRun: boolean): Promise<DirResult> {
|
||||||
|
const result: DirResult = {
|
||||||
|
dir,
|
||||||
|
renamed: 0,
|
||||||
|
alreadyHab: 0,
|
||||||
|
conflicts: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir);
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({ file: dir, message: (error as Error).message });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.toLowerCase().endsWith(LEGACY_EXT)) continue;
|
||||||
|
const from = path.join(dir, entry);
|
||||||
|
const to = path.join(
|
||||||
|
dir,
|
||||||
|
`${entry.slice(0, -LEGACY_EXT.length)}${TARGET_EXT}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Never clobber. A pre-existing `.hab` is a live bundle the client is
|
||||||
|
// already serving; leaving the `.nitro` alone is the only safe answer.
|
||||||
|
if (existsSync(to)) {
|
||||||
|
result.conflicts.push({
|
||||||
|
from: path.basename(from),
|
||||||
|
to: path.basename(to),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
result.renamed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.rename(from, to);
|
||||||
|
result.renamed++;
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({ file: entry, message: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report the target state too, so a run confirms the end condition rather
|
||||||
|
// than just the work it did.
|
||||||
|
for (const entry of await fs.readdir(dir)) {
|
||||||
|
if (entry.toLowerCase().endsWith(TARGET_EXT)) result.alreadyHab++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!args.dryRun && !args.yes) {
|
||||||
|
console.error(
|
||||||
|
"Nothing to do: pass --dry-run to preview, or --yes to rename for real.",
|
||||||
|
);
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = [
|
||||||
|
...new Set([...args.dirs, ...(await resolveDirs(args.skipGeneric))]),
|
||||||
|
];
|
||||||
|
const existing = dirs.filter((dir) => existsSync(dir));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
args.dryRun
|
||||||
|
? "DRY RUN — no files will be touched."
|
||||||
|
: "Renaming .nitro bundles to .hab.",
|
||||||
|
);
|
||||||
|
if (!args.dryRun) {
|
||||||
|
console.log(
|
||||||
|
"Run this in a maintenance window: the client 404s on each file between its rename and its switch to .hab.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`\nDirectories (${existing.length} of ${dirs.length} exist):`);
|
||||||
|
for (const dir of existing) console.log(` ${dir}`);
|
||||||
|
const missing = dirs.filter((dir) => !existsSync(dir));
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(`\nNot present, skipped:`);
|
||||||
|
for (const dir of missing) console.log(` ${dir}`);
|
||||||
|
}
|
||||||
|
if (!existing.length) {
|
||||||
|
console.log("\nNo bundle directories found — nothing to migrate.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
const results: DirResult[] = [];
|
||||||
|
for (const dir of existing) {
|
||||||
|
results.push(await migrateDir(dir, args.dryRun));
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRenamed = 0;
|
||||||
|
let totalHab = 0;
|
||||||
|
let totalConflicts = 0;
|
||||||
|
let totalErrors = 0;
|
||||||
|
for (const result of results) {
|
||||||
|
totalRenamed += result.renamed;
|
||||||
|
totalHab += result.alreadyHab;
|
||||||
|
totalConflicts += result.conflicts.length;
|
||||||
|
totalErrors += result.errors.length;
|
||||||
|
console.log(
|
||||||
|
`${result.dir}\n` +
|
||||||
|
` ${args.dryRun ? "would rename" : "renamed"}: ${result.renamed}\n` +
|
||||||
|
` .hab present: ${result.alreadyHab}`,
|
||||||
|
);
|
||||||
|
for (const conflict of result.conflicts) {
|
||||||
|
console.log(
|
||||||
|
` CONFLICT: ${conflict.from} — ${conflict.to} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const error of result.errors) {
|
||||||
|
console.log(` ERROR: ${error.file} — ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${args.dryRun ? "Would rename" : "Renamed"} ${totalRenamed} file(s). ` +
|
||||||
|
`${totalHab} .hab bundle(s) present afterwards.`,
|
||||||
|
);
|
||||||
|
if (totalConflicts) {
|
||||||
|
console.log(
|
||||||
|
`\n${totalConflicts} conflict(s): a .hab with that name already exists. ` +
|
||||||
|
"The .nitro was left in place. Resolve these by hand — the client can only load one of the two.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (totalErrors)
|
||||||
|
console.log(`\n${totalErrors} error(s); re-run once they are fixed.`);
|
||||||
|
|
||||||
|
// Non-zero on a partial migration so a wrapper cannot report success.
|
||||||
|
if (totalConflicts || totalErrors) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settings lookups open a mysql2 pool, which keeps the event loop alive —
|
||||||
|
// the script prints its whole report and then sits there burning a timeout
|
||||||
|
// instead of exiting. Closing the pool is not enough on its own here, so the
|
||||||
|
// exit is explicit, matching scripts/furni-diagnose-now.ts.
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await db.$client.end().catch(() => {});
|
||||||
|
process.exit(process.exitCode ?? 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/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
|
||||||
|
# cloudflare-ips.conf -> /etc/nginx/conf.d/cloudflare-ips.conf
|
||||||
|
# cms_upstream_servers.conf -> /etc/nginx/snippets/cms_upstream_servers.conf
|
||||||
|
# (seed alleen als het bestand ontbreekt; zodra het bestaat is het runtime
|
||||||
|
# eigendom van scripts/ci-deploy.sh en wordt het hier nooit overschreven)
|
||||||
|
# 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/cloudflare-ips.conf" "$NGINX_DIR/conf.d/cloudflare-ips.conf"; then changed=1; fi
|
||||||
|
# De upstream-snippet is runtime-eigendom van ci-deploy.sh (blue/green): alleen
|
||||||
|
# aanmaken op een verse host, nooit overschrijven wat een deploy heeft gezet.
|
||||||
|
if [[ -f "$NGINX_DIR/snippets/cms_upstream_servers.conf" ]]; then
|
||||||
|
echo "= $NGINX_DIR/snippets/cms_upstream_servers.conf managed by ci-deploy.sh (untouched)"
|
||||||
|
else
|
||||||
|
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||||
|
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
|
||||||
|
# nginx-cms.conf sets `root /var/www/html` so that disk-backed locations
|
||||||
|
# (favicon.ico) resolve somewhere the www-data worker can actually traverse.
|
||||||
|
# The previous implicit root was /etc/nginx/html, which sits behind /etc/nginx
|
||||||
|
# (0750 root:root): the worker got EACCES on every stat, and nginx logs a
|
||||||
|
# failed stat at crit, so each crawler probe wrote a crit line.
|
||||||
|
if [[ ! -d /var/www/html ]]; then
|
||||||
|
install -d -o root -g root -m 755 /var/www/html
|
||||||
|
echo "+ created /var/www/html (document root)"
|
||||||
|
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"
|
||||||
@@ -79,6 +79,27 @@ function filesFrom(values) {
|
|||||||
return values.map(normalizeAsset);
|
return values.map(normalizeAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
|
||||||
|
* a real file has to be separated from its id. An id is rejected by
|
||||||
|
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
|
||||||
|
* suffix), which makes it a usable filter — but only for ids. A malformed
|
||||||
|
* *path* must still fail loudly rather than be silently dropped, or a broken
|
||||||
|
* manifest would quietly under-report a route's real weight.
|
||||||
|
*/
|
||||||
|
function assetFilesFromChunkList(values) {
|
||||||
|
if (!Array.isArray(values))
|
||||||
|
throw new Error("Unsupported JavaScript chunk list.");
|
||||||
|
const files = [];
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value !== "string")
|
||||||
|
throw new Error("Non-string JavaScript asset.");
|
||||||
|
if (/^\d+$/.test(value)) continue;
|
||||||
|
files.push(normalizeAsset(value));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
export function measureRoute({
|
export function measureRoute({
|
||||||
budget,
|
budget,
|
||||||
appPath,
|
appPath,
|
||||||
@@ -86,18 +107,44 @@ export function measureRoute({
|
|||||||
clientManifest,
|
clientManifest,
|
||||||
readAsset,
|
readAsset,
|
||||||
}) {
|
}) {
|
||||||
|
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
|
||||||
|
// not — it only records chunks per client module — so after the build moved
|
||||||
|
// to webpack (3d828a61) every route reported "unavailable" and the report
|
||||||
|
// silently stopped measuring anything. Fall back to the same source Next's
|
||||||
|
// own `static-routes-info` uses for webpack builds.
|
||||||
const entries = clientManifest?.entryJSFiles;
|
const entries = clientManifest?.entryJSFiles;
|
||||||
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
const webpackModules = clientManifest?.clientModules;
|
||||||
|
let filesBySource;
|
||||||
|
let webpackLayout = false;
|
||||||
|
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||||
|
const sourceEntries = Object.keys(entries);
|
||||||
|
if (
|
||||||
|
!sourceEntries.some((key) =>
|
||||||
|
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||||
|
filesBySource = Object.entries(entries);
|
||||||
|
} else if (webpackModules && typeof webpackModules === "object") {
|
||||||
|
webpackLayout = true;
|
||||||
|
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
|
||||||
|
filesBySource = [];
|
||||||
|
for (const node of Object.values(webpackModules)) {
|
||||||
|
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
|
||||||
|
// One shared origin label instead of the module path: the per-chunk
|
||||||
|
// `sources` list is written into report.json, and webpack records
|
||||||
|
// absolute node_modules paths for every client module on the route.
|
||||||
|
filesBySource.push(["client-module", node.chunks]);
|
||||||
|
}
|
||||||
|
if (filesBySource.length === 0)
|
||||||
|
throw new Error(
|
||||||
|
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
||||||
);
|
);
|
||||||
const sourceEntries = Object.keys(entries);
|
}
|
||||||
if (
|
|
||||||
!sourceEntries.some((key) =>
|
|
||||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
|
||||||
const bootstrap = filesFrom(
|
const bootstrap = filesFrom(
|
||||||
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
||||||
);
|
);
|
||||||
@@ -110,8 +157,9 @@ export function measureRoute({
|
|||||||
origins.set(file, sources);
|
origins.set(file, sources);
|
||||||
};
|
};
|
||||||
for (const file of bootstrap) add(file, "bootstrap");
|
for (const file of bootstrap) add(file, "bootstrap");
|
||||||
for (const [entry, values] of Object.entries(entries))
|
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom;
|
||||||
for (const file of filesFrom(values)) add(file, entry);
|
for (const [entry, values] of filesBySource)
|
||||||
|
for (const file of readChunkList(values)) add(file, entry);
|
||||||
const size = (file, sources) => {
|
const size = (file, sources) => {
|
||||||
const bytes = readAsset(file);
|
const bytes = readAsset(file);
|
||||||
return {
|
return {
|
||||||
@@ -248,12 +296,13 @@ export function collectReport(nextDir, config, metadata = {}) {
|
|||||||
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
||||||
nodeVersion: process.version,
|
nodeVersion: process.version,
|
||||||
zlibVersion: process.versions.zlib,
|
zlibVersion: process.versions.zlib,
|
||||||
manifestFormat: "Next App Router client-reference entryJSFiles",
|
manifestFormat:
|
||||||
|
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
|
||||||
definition:
|
definition:
|
||||||
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus every client chunk this route's client-reference manifest lists, including boundary/loading entries. Turbopack exposes these as entryJSFiles; webpack exposes them only through clientModules[*].chunks, so the same envelope is derived from whichever the build emitted. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||||
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
||||||
excluded:
|
excluded:
|
||||||
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
|
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from the manifest's chunk lists; legacy nomodule polyfills are reported separately.",
|
||||||
routes,
|
routes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,135 @@ describe("route JS measurement", () => {
|
|||||||
);
|
);
|
||||||
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
|
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
|
||||||
});
|
});
|
||||||
|
// The regression: after the build moved to webpack (3d828a61) the manifest
|
||||||
|
// has no entryJSFiles, only clientModules[*].chunks. Every route then
|
||||||
|
// reported "unavailable" and the report measured nothing at all while still
|
||||||
|
// exiting zero, so the budgets silently stopped being enforced.
|
||||||
|
describe("webpack manifests without entryJSFiles", () => {
|
||||||
|
const webpackManifest = {
|
||||||
|
clientModules: {
|
||||||
|
"[project]/src/components/header.tsx": {
|
||||||
|
chunks: [
|
||||||
|
"4269",
|
||||||
|
"static/chunks/4269-shared.js?dpl=abc",
|
||||||
|
"6726",
|
||||||
|
"static/chunks/header-entry.js?dpl=abc",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"[project]/src/app/(site)/news/page.tsx": {
|
||||||
|
chunks: [
|
||||||
|
"4269",
|
||||||
|
"static/chunks/4269-shared.js?dpl=abc",
|
||||||
|
"7777",
|
||||||
|
"/_next/static/chunks/page.js?dpl=abc",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Async-only modules are recorded with an empty chunk list.
|
||||||
|
"[project]/src/components/lazy.tsx": { chunks: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const webpackFiles = {
|
||||||
|
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
|
||||||
|
// bootstrap assets must exist or the read fails.
|
||||||
|
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
|
||||||
|
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
|
||||||
|
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
|
||||||
|
"static/chunks/header-entry.js": Buffer.from("header"),
|
||||||
|
"static/chunks/page.js": Buffer.from("page"),
|
||||||
|
"static/chunks/polyfill.js": Buffer.from("legacy"),
|
||||||
|
};
|
||||||
|
const measure = () =>
|
||||||
|
measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest: webpackManifest,
|
||||||
|
readAsset: (file) => webpackFiles[file],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the envelope from clientModules and ignores chunk ids", () => {
|
||||||
|
const row = measure();
|
||||||
|
expect(row.status).toBe("measured");
|
||||||
|
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
|
||||||
|
// are not assets and must not throw or be counted.
|
||||||
|
expect(row.initial.chunkCount).toBe(5);
|
||||||
|
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
|
||||||
|
"static/chunks/4269-shared.js",
|
||||||
|
"static/chunks/header-entry.js",
|
||||||
|
"static/chunks/page.js",
|
||||||
|
"static/chunks/runtime.js",
|
||||||
|
"static/chunks/shared.js",
|
||||||
|
]);
|
||||||
|
// Same bytes as the Turbopack fixture would produce for these files.
|
||||||
|
expect(row.initial.rawBytes).toBe(
|
||||||
|
Object.values(webpackFiles)
|
||||||
|
.filter((b) => !b.includes("legacy"))
|
||||||
|
.reduce((n, b) => n + b.length, 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a chunk reached by several client modules only once", () => {
|
||||||
|
const shared = measure().initial.chunks.find(
|
||||||
|
(f) => f.path === "static/chunks/4269-shared.js",
|
||||||
|
);
|
||||||
|
expect(shared).toBeDefined();
|
||||||
|
expect(measure().initial.chunkCount).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak absolute module paths into the report", () => {
|
||||||
|
const sources = new Set(
|
||||||
|
measure().initial.chunks.flatMap((chunk) => chunk.sources),
|
||||||
|
);
|
||||||
|
// Only the two known origin labels; no node_modules path may appear.
|
||||||
|
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still fails rather than under-reporting a malformed chunk path", () => {
|
||||||
|
expect(() =>
|
||||||
|
measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest: {
|
||||||
|
clientModules: {
|
||||||
|
x: { chunks: ["static/chunks/../../etc/passwd"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readAsset: (file) => webpackFiles[file],
|
||||||
|
}),
|
||||||
|
).toThrow("Unsupported JavaScript asset");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unavailable when webpack recorded no chunks at all", () => {
|
||||||
|
expect(() =>
|
||||||
|
measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest: { clientModules: { x: { chunks: [] } } },
|
||||||
|
readAsset: (file) => webpackFiles[file],
|
||||||
|
}),
|
||||||
|
).toThrow("Neither entryJSFiles nor clientModules");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers entryJSFiles when a manifest carries both", () => {
|
||||||
|
// A future Next version could emit both; the explicit list wins
|
||||||
|
// because it is per-segment and therefore the tighter envelope.
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest: {
|
||||||
|
...webpackManifest,
|
||||||
|
entryJSFiles: {
|
||||||
|
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readAsset: (file) => webpackFiles[file],
|
||||||
|
});
|
||||||
|
expect(row.initial.chunkCount).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
it("does not deduplicate shared files across independent cold route totals", () => {
|
it("does not deduplicate shared files across independent cold route totals", () => {
|
||||||
const row = measureRoute({
|
const row = measureRoute({
|
||||||
budget,
|
budget,
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Setup cron jobs for maintenance
|
# Setup cron jobs for maintenance
|
||||||
|
#
|
||||||
|
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
|
||||||
|
# script that pipes one job at a time silently drops every other scheduled job.
|
||||||
|
# Entries are matched by their command, so re-running this is idempotent.
|
||||||
|
set -Eeuo pipefail
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Adds one schedule line to the crontab unless its command is already present.
|
||||||
|
add_job() {
|
||||||
|
local schedule="$1" command
|
||||||
|
command="${schedule##* }"
|
||||||
|
local current
|
||||||
|
current="$(crontab -l 2>/dev/null || true)"
|
||||||
|
if grep -Fq "$command" <<<"$current"; then
|
||||||
|
echo "Already scheduled: $command"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -z "$current" ]]; then
|
||||||
|
printf '%s\n' "$schedule" | crontab -
|
||||||
|
else
|
||||||
|
printf '%s\n%s\n' "$current" "$schedule" | crontab -
|
||||||
|
fi
|
||||||
|
echo "Scheduled: $schedule"
|
||||||
|
}
|
||||||
|
|
||||||
# Run backup every day at 03:00
|
# Run backup every day at 03:00
|
||||||
echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
|
add_job "0 3 * * * $SCRIPT_DIR/backup.sh"
|
||||||
# Run prune every Sunday at 04:00
|
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly
|
||||||
echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
|
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly
|
||||||
|
# run would let ~12 GB accumulate before anything reclaimed it.
|
||||||
|
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
|
||||||
|
|
||||||
echo "Cron jobs configured."
|
echo "Cron jobs configured."
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse the version facts the README advertises into a single object, read
|
||||||
|
* from the source of truth (`package.json` + `.nvmrc`). Kept as a pure function
|
||||||
|
* so the test suite can pin the extraction without touching the filesystem.
|
||||||
|
*/
|
||||||
|
export function collectVersions(pkg, nvmrc) {
|
||||||
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||||
|
const require = (name) => {
|
||||||
|
const value = deps[name];
|
||||||
|
if (typeof value !== "string" || !/^\d+\.\d+/.test(value))
|
||||||
|
throw new Error(`package.json is missing a concrete ${name} version`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pnpm = /^pnpm@([^+]+)/.exec(pkg.packageManager ?? "")?.[1];
|
||||||
|
if (!pnpm) throw new Error("package.json packageManager must pin pnpm");
|
||||||
|
|
||||||
|
const node = nvmrc.trim();
|
||||||
|
if (!/^\d+\.\d+\.\d+$/.test(node))
|
||||||
|
throw new Error(".nvmrc must pin an exact Node.js version");
|
||||||
|
|
||||||
|
return {
|
||||||
|
pnpm,
|
||||||
|
node,
|
||||||
|
next: require("next"),
|
||||||
|
react: require("react"),
|
||||||
|
typescript: require("typescript"),
|
||||||
|
drizzleOrm: require("drizzle-orm"),
|
||||||
|
zod: require("zod"),
|
||||||
|
vitest: require("vitest"),
|
||||||
|
biome: require("@biomejs/biome"),
|
||||||
|
playwright: require("@playwright/test"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single "Core stack" line, built from the live versions. */
|
||||||
|
export function coreStackLine(v) {
|
||||||
|
return `Core stack: **Next.js ${v.next}** (App Router) · **React ${v.react}** · **TypeScript ${v.typescript}** · **Drizzle ORM ${v.drizzleOrm}** · **Zod ${v.zod}** · **Vitest ${v.vitest}** · **Biome ${v.biome}** · **Playwright ${v.playwright}**.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE_STACK_RE = /^Core stack: \*\*Next\.js .*$/m;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every managed version reference in `readme` to match `versions`.
|
||||||
|
* Returns the new text plus a human-readable list of what changed. Throws if a
|
||||||
|
* managed anchor is missing, so a README restructure fails loudly instead of
|
||||||
|
* silently dropping out of sync.
|
||||||
|
*/
|
||||||
|
export function syncReadmeVersions(readme, versions) {
|
||||||
|
const changes = [];
|
||||||
|
let next = readme;
|
||||||
|
|
||||||
|
// `| Node.js | 26.10.0 | … (>=26.10.0 <27) |` — version cell + engines note.
|
||||||
|
next = replaceVersionCell(
|
||||||
|
next,
|
||||||
|
"Node\\.js",
|
||||||
|
versions.node,
|
||||||
|
"Node.js",
|
||||||
|
changes,
|
||||||
|
);
|
||||||
|
const nodeMajor = Number.parseInt(versions.node.split(".")[0], 10);
|
||||||
|
const nodeNote = /(>=)\d+\.\d+\.\d+( <)\d+/;
|
||||||
|
if (!nodeNote.test(next))
|
||||||
|
throw new Error("README is missing the Node.js engines note");
|
||||||
|
next = next.replace(nodeNote, `$1${versions.node}$2${nodeMajor + 1}`);
|
||||||
|
|
||||||
|
// `| pnpm | 12.11.2 | … |`.
|
||||||
|
next = replaceVersionCell(next, "pnpm", versions.pnpm, "pnpm", changes);
|
||||||
|
|
||||||
|
// `Core stack: **Next.js …** · …`.
|
||||||
|
const stackMatch = CORE_STACK_RE.exec(next);
|
||||||
|
if (!stackMatch) throw new Error("README is missing the core stack line");
|
||||||
|
const expectedStack = coreStackLine(versions);
|
||||||
|
if (stackMatch[0] !== expectedStack) {
|
||||||
|
changes.push("core stack line");
|
||||||
|
next = next.replace(CORE_STACK_RE, expectedStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { readme: next, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the version token of a `| name | version | … |` table row, padding
|
||||||
|
* the trailing spaces so the closing pipe stays in the same column when the
|
||||||
|
* new version is a different length.
|
||||||
|
*/
|
||||||
|
function replaceVersionCell(readme, namePattern, version, label, changes) {
|
||||||
|
const re = new RegExp(
|
||||||
|
`^(\\| ${namePattern}(?: +)\\| )(\\S+)( +)(\\|.*)$`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
const match = re.exec(readme);
|
||||||
|
if (!match)
|
||||||
|
throw new Error(`README is missing the ${label} requirements row`);
|
||||||
|
const [, pre, current, pad, post] = match;
|
||||||
|
if (current === version) return readme;
|
||||||
|
changes.push(`${label} ${current} -> ${version}`);
|
||||||
|
const padLength = Math.max(1, pad.length - (version.length - current.length));
|
||||||
|
return readme.replace(re, `${pre}${version}${" ".repeat(padLength)}${post}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const check = process.argv.includes("--check");
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(resolve(projectRoot, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const nvmrc = readFileSync(resolve(projectRoot, ".nvmrc"), "utf8");
|
||||||
|
const readmePath = resolve(projectRoot, "README.md");
|
||||||
|
const readme = readFileSync(readmePath, "utf8");
|
||||||
|
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(
|
||||||
|
readme,
|
||||||
|
collectVersions(pkg, nvmrc),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
console.log("README versions are in sync with package.json / .nvmrc");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
console.error("README versions are out of date:");
|
||||||
|
for (const change of changes) console.error(` - ${change}`);
|
||||||
|
console.error("Run `pnpm readme:versions` to fix.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(readmePath, updated);
|
||||||
|
console.log("README versions updated:");
|
||||||
|
for (const change of changes) console.log(` - ${change}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
collectVersions,
|
||||||
|
coreStackLine,
|
||||||
|
syncReadmeVersions,
|
||||||
|
} from "./sync-readme-versions.mjs";
|
||||||
|
|
||||||
|
const versions = {
|
||||||
|
pnpm: "12.11.2",
|
||||||
|
node: "26.10.0",
|
||||||
|
next: "16.4.1",
|
||||||
|
react: "19.3.1",
|
||||||
|
typescript: "7.0.2",
|
||||||
|
drizzleOrm: "0.45.4",
|
||||||
|
zod: "4.6.7",
|
||||||
|
vitest: "5.0.4",
|
||||||
|
biome: "2.5.15",
|
||||||
|
playwright: "1.64.1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pkg = {
|
||||||
|
packageManager: `pnpm@${versions.pnpm}+sha512.deadbeef`,
|
||||||
|
dependencies: {
|
||||||
|
next: versions.next,
|
||||||
|
react: versions.react,
|
||||||
|
zod: versions.zod,
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
typescript: versions.typescript,
|
||||||
|
"drizzle-orm": versions.drizzleOrm,
|
||||||
|
vitest: versions.vitest,
|
||||||
|
"@biomejs/biome": versions.biome,
|
||||||
|
"@playwright/test": versions.playwright,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const readme = [
|
||||||
|
"| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |",
|
||||||
|
"| pnpm | 12.11.2 | Pinned via `packageManager` |",
|
||||||
|
"",
|
||||||
|
coreStackLine(versions),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("collectVersions", () => {
|
||||||
|
it("pulls every managed version from package.json and .nvmrc", () => {
|
||||||
|
expect(collectVersions(pkg, `${versions.node}\n`)).toEqual(versions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a packageManager without a pinned pnpm", () => {
|
||||||
|
expect(() =>
|
||||||
|
collectVersions({ ...pkg, packageManager: "npm@10" }, "26.10.0"),
|
||||||
|
).toThrow(/pnpm/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ranged dependency instead of a pinned one", () => {
|
||||||
|
const ranged = {
|
||||||
|
...pkg,
|
||||||
|
dependencies: { ...pkg.dependencies, next: "^16" },
|
||||||
|
};
|
||||||
|
expect(() => collectVersions(ranged, "26.10.0")).toThrow(/next/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncReadmeVersions", () => {
|
||||||
|
it("reports no changes when the README already matches", () => {
|
||||||
|
const result = syncReadmeVersions(readme, versions);
|
||||||
|
expect(result.changes).toEqual([]);
|
||||||
|
expect(result.readme).toBe(readme);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the pnpm row and keeps the table aligned", () => {
|
||||||
|
const bumped = { ...versions, pnpm: "13.0.0" };
|
||||||
|
const original = readme.split("\n").find((line) => line.includes("pnpm"));
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(readme, bumped);
|
||||||
|
const line = updated.split("\n").find((l) => l.includes("pnpm"));
|
||||||
|
|
||||||
|
expect(changes).toContain("pnpm 12.11.2 -> 13.0.0");
|
||||||
|
expect(line).toContain("| 13.0.0");
|
||||||
|
expect(line?.length).toBe(original?.length);
|
||||||
|
expect(line?.endsWith("|\n") || line?.endsWith("|")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites the core stack line from the live versions", () => {
|
||||||
|
const bumped = { ...versions, next: "17.0.0", zod: "5.0.0" };
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(readme, bumped);
|
||||||
|
expect(changes).toContain("core stack line");
|
||||||
|
expect(updated).toContain(coreStackLine(bumped));
|
||||||
|
expect(updated).not.toContain("Next.js 16.4.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates both the Node version cell and the engines note", () => {
|
||||||
|
const bumped = { ...versions, node: "27.3.1" };
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(readme, bumped);
|
||||||
|
expect(changes).toContain("Node.js 26.10.0 -> 27.3.1");
|
||||||
|
expect(updated).toContain("| 27.3.1 ");
|
||||||
|
expect(updated).toContain("`>=27.3.1 <28`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails loudly when a managed anchor is missing", () => {
|
||||||
|
expect(() =>
|
||||||
|
syncReadmeVersions("no table here\nno core stack", versions),
|
||||||
|
).toThrow(/requirements row/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("coreStackLine", () => {
|
||||||
|
it("renders the documented ordering", () => {
|
||||||
|
expect(coreStackLine(versions)).toBe(
|
||||||
|
"Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Voer een zwaar commando uit onder een harde geheugenplafond.
|
||||||
|
#
|
||||||
|
# Waarom dit bestaat:
|
||||||
|
# De host draait met `vm.overcommit_memory=0` en ZONDER swap. Vraagt een
|
||||||
|
# proces meer geheugen dan er vrij is, dan geeft de kernel niets weg en
|
||||||
|
# roept hij meteen de OOM-killer aan. Die kiest zijn slachtoffer over de
|
||||||
|
# héle machine, niet alleen in het schuldige proces — dus een build kan de
|
||||||
|
# database, nginx of de live release meenemen.
|
||||||
|
#
|
||||||
|
# `next build` op Turbopack groeit op dit 329-route app voorbij 12GB RSS
|
||||||
|
# en is ook met 4GB swap nog steeds dood. Zie commit 3d828a61.
|
||||||
|
#
|
||||||
|
# Wat dit doet:
|
||||||
|
# Het commando komt in zijn eigen cgroup met `MemoryMax`. Als het door die
|
||||||
|
# grens heen groeit krijgt alleen die cgroup een OOM-signaal: het commando
|
||||||
|
# zelf sterft, de rest van de machine leeft. Dat is precies het gedrag dat
|
||||||
|
# je wilt — de build faalt, de site blijft staan.
|
||||||
|
#
|
||||||
|
# Met `--max-old-space-size` lukt dat niet. Die limiet zit op de V8-heap en
|
||||||
|
# Turbopack-geheugen is native Rust-geheugen; met een 2GB cap piekte de RSS
|
||||||
|
# alsnog op 8GB. Zie het commentaar in de Dockerfile.
|
||||||
|
#
|
||||||
|
# Backends:
|
||||||
|
#
|
||||||
|
# systemd (cgroup MemoryMax)
|
||||||
|
# Meet RSS over de héle procesboom. Dit is de echte garantie en wordt
|
||||||
|
# overal gebruikt waar systemd beschikbaar is (de host waar deze
|
||||||
|
# CMS draait). De plafonds in package.json zijn hierop gekozen:
|
||||||
|
# `next build` piekt op ~9,7GB in `memory.current` (inclusief de
|
||||||
|
# page cache die de build zelf opbouwt), dus 10GB laat net genoeg ruimte
|
||||||
|
# over terwijl er ~6-9GB basislast naast blijft passen op deze machine.
|
||||||
|
#
|
||||||
|
# Twee extra vangnetten, omdat de basislast inmiddels groter kan zijn
|
||||||
|
# (Docker, de emulator en de agent tikken samen ~9GB aan) en een vaste cap
|
||||||
|
# die hoger is dan het vrije geheugen de host alsnog kan laten OOM-killen:
|
||||||
|
# - MemoryHigh (95% van de cap) dwingt de kernel eerder te reclaimen en
|
||||||
|
# te throttlen, zodat een allocatiestorm de harde grens niet voorbij
|
||||||
|
# schiet voordat de cgroup-OOM-killer reageert;
|
||||||
|
# - het plafond wordt teruggeschroefd tot `MemAvailable - reserve`, met
|
||||||
|
# `CMS_MEMORY_CAP_RESERVE` (standaard 4g) als marge voor de rest van
|
||||||
|
# de machine. Is er minder dan 1GB over, dan weigert het script.
|
||||||
|
# MemorySwapMax=0 staat expliciet aan: deze host heeft geen swap.
|
||||||
|
#
|
||||||
|
# ulimit -v (per proces, virtuele adresruimte)
|
||||||
|
# Alleen als expliciet gevraagd. Meet virtuele adresruimte, NIET RSS, en
|
||||||
|
# kan de boom helemaal niet begrenzen: elke worker krijgt z'n eigen
|
||||||
|
# limiet. Op moderne V8 is het bovendien een vergiftigde gift: `-v 10g`
|
||||||
|
# laat V8 de heaplimiet terugbrengen naar 2,25GB (webpack sterft met
|
||||||
|
# std::bad_alloc), en `-v 20g` laat de v8-wasm-memory-toewijzing falen
|
||||||
|
# tijdens `next build`. Zet CMS_MEMORY_CAP_VIRTUAL ruim boven de fysieke
|
||||||
|
# RAM als je het echt wilt gebruiken.
|
||||||
|
#
|
||||||
|
# Geen van beide -> weigeren. Stil onbegrensd doorlopen zou precies de
|
||||||
|
# valse geruststelling zijn waar 3d828a61 voor waarschuwt. Omgevingen
|
||||||
|
# zonder systemd (de Docker-build, de GitLab-runner) kiezen daarom
|
||||||
|
# expliciet voor CMS_MEMORY_CAP_BACKEND=none — met een waarschuwing,
|
||||||
|
# en met als rechtvaardiging dat die builds al begrensd zijn door
|
||||||
|
# `next build --webpack` + `--max-old-space-size` en in hun eigen
|
||||||
|
# geïsoleerde container draaien, niet op de host.
|
||||||
|
#
|
||||||
|
# Markering:
|
||||||
|
# Elke backend zet `CMS_MEMORY_CAPPED=1` voordat het commando start.
|
||||||
|
# `next.config.ts` weigert een productie-build zonder die markering, zodat
|
||||||
|
# een handmatig `npx next build` (of een IDE/agent die de build zelf
|
||||||
|
# start) niet meer onbeperkt geheugen kan vragen en de hele host mee
|
||||||
|
# neemt. `CMS_MEMORY_CAP_BACKEND=none` telt mee: die omgevingen draaien
|
||||||
|
# al in een eigen, geïsoleerde container.
|
||||||
|
#
|
||||||
|
# Gebruik: bash scripts/with-memory-cap.sh 10g <command...>
|
||||||
|
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
|
||||||
|
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
|
||||||
|
# Marge bovenop de basislast: CMS_MEMORY_CAP_RESERVE=4g
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "gebruik: $0 <plafond, bv. 10g> <command...>" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -ge 2 ] || usage
|
||||||
|
cap="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
# Zet 10g / 512m / 2G / 1234567 om in bytes. Alleen bytes gaan naar
|
||||||
|
# systemd: MemoryMax accepteert `10G` maar weigert `10g`, en die
|
||||||
|
# hoofdletterval is te makkelijk om per ongeluk te treffen.
|
||||||
|
to_bytes() {
|
||||||
|
local value="$1" number suffix
|
||||||
|
if [[ "$value" =~ ^([0-9]+)([kKmMgGtT]?)$ ]]; then
|
||||||
|
number="${BASH_REMATCH[1]}"
|
||||||
|
suffix="${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
echo "onbekend plafond-formaat: $value" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
case "$suffix" in
|
||||||
|
k | K) echo $((number * 1024)) ;;
|
||||||
|
m | M) echo $((number * 1024 * 1024)) ;;
|
||||||
|
g | G) echo $((number * 1024 * 1024 * 1024)) ;;
|
||||||
|
t | T) echo $((number * 1024 * 1024 * 1024 * 1024)) ;;
|
||||||
|
*) echo "$number" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes="$(to_bytes "$cap")" || exit 64
|
||||||
|
kilobytes=$((bytes / 1024))
|
||||||
|
|
||||||
|
# De virtuele waarde voor ulimit -v. Bewust los van `cap`: zie de toelichting
|
||||||
|
# hierboven, 10g -v breekt de webpack-build.
|
||||||
|
virtual_bytes="$(to_bytes "${CMS_MEMORY_CAP_VIRTUAL:-40g}")" || exit 64
|
||||||
|
virtual_kb=$((virtual_bytes / 1024))
|
||||||
|
|
||||||
|
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
|
||||||
|
systemd_cmd=()
|
||||||
|
|
||||||
|
# Vanaf hier is dit script de enige plek waar een zwaar commando nog mag
|
||||||
|
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
||||||
|
export CMS_MEMORY_CAPPED=1
|
||||||
|
|
||||||
|
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
||||||
|
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||||
|
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||||
|
probe_systemd() {
|
||||||
|
command -v systemd-run >/dev/null 2>&1 || return 1
|
||||||
|
[ -d /run/systemd/system ] || return 1
|
||||||
|
if systemd-run --scope --quiet true 2>/dev/null; then
|
||||||
|
systemd_cmd=(systemd-run --scope --quiet)
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if systemd-run --user --scope --quiet true 2>/dev/null; then
|
||||||
|
systemd_cmd=(systemd-run --user --scope --quiet)
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Beschikbaar geheugen in bytes: wat het systeem kan toewijzen zonder te
|
||||||
|
# swappen. Dit is inclusief opvraagbare page cache, dus de juiste maat om te
|
||||||
|
# bepalen hoeveel een nieuwe cgroup nog mag gebruiken zonder de host in de
|
||||||
|
# globale OOM-killer te duwen.
|
||||||
|
mem_available_bytes() {
|
||||||
|
local kb
|
||||||
|
kb="$(grep -m1 '^MemAvailable:' /proc/meminfo 2>/dev/null | tr -dc '0-9')"
|
||||||
|
[ -n "$kb" ] || kb=0
|
||||||
|
echo $((kb * 1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
run_systemd() {
|
||||||
|
local effective="$bytes"
|
||||||
|
# Houd marge voor de al draaiende basislast (MariaDB, Docker, de live
|
||||||
|
# release, de agent) plus kernel/page cache. Zonder die marge kan een cap
|
||||||
|
# die hoger is dan het werkelijk beschikbare geheugen alsnog de host laten
|
||||||
|
# OOM-killen voordat de cgroup-grens grijpt.
|
||||||
|
local reserve
|
||||||
|
reserve="$(to_bytes "${CMS_MEMORY_CAP_RESERVE:-4g}")" || exit 64
|
||||||
|
local available
|
||||||
|
available="$(mem_available_bytes)"
|
||||||
|
if [ "$available" -gt 0 ]; then
|
||||||
|
local headroom=$((available > reserve ? available - reserve : 0))
|
||||||
|
if [ "$headroom" -lt "$effective" ]; then
|
||||||
|
echo "[mem-cap] plafond verlaagd naar $((headroom / 1024 / 1024))MB: MemAvailable=$((available / 1024 / 1024))MB reserve=$((reserve / 1024 / 1024))MB" >&2
|
||||||
|
effective="$headroom"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$effective" -lt $((1024 * 1024 * 1024)) ]; then
|
||||||
|
echo "[mem-cap] te weinig vrij geheugen (MemAvailable=$((available / 1024 / 1024))MB) om begrensd te starten; weiger." >&2
|
||||||
|
echo "[mem-cap] maak geheugen vrij of verhoog CMS_MEMORY_CAP_RESERVE bewust niet." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# MemoryHigh laat de kernel proactief reclaimen en throttlen vóór de harde
|
||||||
|
# MemoryMax, zodat een snelle allocatiestorm niet voorbij de grens schiet en
|
||||||
|
# de (globale) OOM-killer de hele machine meeneemt. MemorySwapMax=0 is
|
||||||
|
# expliciet: deze host heeft geen swap.
|
||||||
|
local high=$((effective * 95 / 100))
|
||||||
|
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
|
||||||
|
exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_ulimit() {
|
||||||
|
echo "[mem-cap] ulimit -v ${virtual_kb}KB per proces (geen systemd; RSS-plafond ${cap} niet meetbaar zonder cgroup): $*" >&2
|
||||||
|
if [ "$virtual_bytes" -lt $((16 * 1024 * 1024 * 1024)) ]; then
|
||||||
|
echo "[mem-cap] let op: -v onder 16g verlaagt V8's heaplimiet en breekt de build; verhoog CMS_MEMORY_CAP_VIRTUAL" >&2
|
||||||
|
fi
|
||||||
|
ulimit -v "$virtual_kb" || {
|
||||||
|
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
exec "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_refuse() {
|
||||||
|
echo "[mem-cap] geen systemd hier; weiger onbegrensd te draaien." >&2
|
||||||
|
echo "[mem-cap] zet CMS_MEMORY_CAP_BACKEND=none om dit bewust te accepteren, of =ulimit voor een per-proces vangnet." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_opted_out() {
|
||||||
|
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||||
|
exec "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$backend" in
|
||||||
|
systemd)
|
||||||
|
probe_systemd || {
|
||||||
|
echo "[mem-cap] CMS_MEMORY_CAP_BACKEND=systemd maar systemd-run reageert niet" >&2
|
||||||
|
exit 70
|
||||||
|
}
|
||||||
|
run_systemd "$@"
|
||||||
|
;;
|
||||||
|
ulimit)
|
||||||
|
run_ulimit "$@"
|
||||||
|
;;
|
||||||
|
none | off)
|
||||||
|
run_opted_out "$@"
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
if probe_systemd; then
|
||||||
|
run_systemd "$@"
|
||||||
|
else
|
||||||
|
run_refuse "$@"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[mem-cap] onbekende backend: $backend" >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -34,7 +34,7 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Mark every unread ops alert as read. */
|
/** Mark every unread ops alert as read. */
|
||||||
export async function markAllAlertsRead(): Promise<void> {
|
export async function markAllAlertsRead(): Promise<void> {
|
||||||
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
await requirePermission(PERMS.NOTIFICATIONS_EDIT);
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(AlertLogs)
|
.update(AlertLogs)
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { like } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
type AntiddosBlockTier,
|
||||||
|
type AntiddosConfig,
|
||||||
|
antiddosConfigToJson,
|
||||||
|
antiddosDefaultsFromEnv,
|
||||||
|
invalidateAntiddosConfig,
|
||||||
|
} from "@/lib/antiddos-config";
|
||||||
|
import {
|
||||||
|
removeCloudflareBlock,
|
||||||
|
setLastCloudflareVerify,
|
||||||
|
verifyCloudflareConnection,
|
||||||
|
} from "@/lib/cloudflare-api";
|
||||||
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const OVERRIDE_KEY = "antiddos:config";
|
||||||
|
|
||||||
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
|
||||||
|
const n = Number(str(raw));
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
||||||
|
const tiers: AntiddosBlockTier[] = [];
|
||||||
|
for (const part of str(raw).split(",")) {
|
||||||
|
const [minRaw, ttlRaw] = part.split(":");
|
||||||
|
const min = Number(minRaw);
|
||||||
|
const ttl = Number(ttlRaw);
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(ttl) || ttl <= 0) continue;
|
||||||
|
tiers.push({
|
||||||
|
minViolations: Math.max(1, Math.floor(min)),
|
||||||
|
ttlSeconds: Math.floor(ttl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tiers.sort((a, b) => a.minViolations - b.minViolations);
|
||||||
|
return tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFromForm(formData: FormData): AntiddosConfig {
|
||||||
|
const defaults = antiddosDefaultsFromEnv();
|
||||||
|
const tierRaw = str(formData.get("block_tiers")).trim();
|
||||||
|
return {
|
||||||
|
enabled: str(formData.get("enabled")) === "1",
|
||||||
|
pages: {
|
||||||
|
limit: positiveInt(formData.get("pages_limit"), defaults.pages.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("pages_window_sec"),
|
||||||
|
defaults.pages.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
limit: positiveInt(formData.get("api_limit"), defaults.api.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("api_window_sec"),
|
||||||
|
defaults.api.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
limit: positiveInt(formData.get("auth_limit"), defaults.auth.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("auth_window_sec"),
|
||||||
|
defaults.auth.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
limit: positiveInt(formData.get("global_limit"), defaults.global.limit),
|
||||||
|
windowSeconds: positiveInt(
|
||||||
|
formData.get("global_window_sec"),
|
||||||
|
defaults.global.windowSeconds,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
violationWindowSeconds: positiveInt(
|
||||||
|
formData.get("violation_window_sec"),
|
||||||
|
defaults.violationWindowSeconds,
|
||||||
|
),
|
||||||
|
maxViolations: positiveInt(
|
||||||
|
formData.get("max_violations"),
|
||||||
|
defaults.maxViolations,
|
||||||
|
),
|
||||||
|
blockTiers:
|
||||||
|
tierRaw.length > 0
|
||||||
|
? parseTiers(formData.get("block_tiers"))
|
||||||
|
: defaults.blockTiers,
|
||||||
|
globalHaltMs: positiveInt(
|
||||||
|
formData.get("global_halt_ms"),
|
||||||
|
defaults.globalHaltMs,
|
||||||
|
),
|
||||||
|
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSettings(config: AntiddosConfig): Promise<void> {
|
||||||
|
const entries: [string, string][] = [
|
||||||
|
["antiddos_enabled", config.enabled ? "1" : "0"],
|
||||||
|
["antiddos_pages_limit", String(config.pages.limit)],
|
||||||
|
["antiddos_pages_window_sec", String(config.pages.windowSeconds)],
|
||||||
|
["antiddos_api_limit", String(config.api.limit)],
|
||||||
|
["antiddos_api_window_sec", String(config.api.windowSeconds)],
|
||||||
|
["antiddos_auth_limit", String(config.auth.limit)],
|
||||||
|
["antiddos_auth_window_sec", String(config.auth.windowSeconds)],
|
||||||
|
["antiddos_global_limit", String(config.global.limit)],
|
||||||
|
["antiddos_global_window_sec", String(config.global.windowSeconds)],
|
||||||
|
["antiddos_violation_window_sec", String(config.violationWindowSeconds)],
|
||||||
|
["antiddos_max_violations", String(config.maxViolations)],
|
||||||
|
[
|
||||||
|
"antiddos_block_tiers",
|
||||||
|
config.blockTiers
|
||||||
|
.map((t) => `${t.minViolations}:${t.ttlSeconds}`)
|
||||||
|
.join(","),
|
||||||
|
],
|
||||||
|
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||||
|
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
||||||
|
];
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(([key, value]) =>
|
||||||
|
db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment: "Anti-DDoS protection" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save anti-DDoS settings from the admin panel. Persists to site settings
|
||||||
|
* (durable across Redis flushes) and pushes the same config to the Redis
|
||||||
|
* override the proxy reads, so the change is live within the proxy cache TTL.
|
||||||
|
*/
|
||||||
|
export async function saveAntiddosSettings(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const config = configFromForm(formData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistSettings(config);
|
||||||
|
if (redis) {
|
||||||
|
await redis.set(OVERRIDE_KEY, antiddosConfigToJson(config));
|
||||||
|
}
|
||||||
|
invalidateAntiddosConfig();
|
||||||
|
siteSettings.reload();
|
||||||
|
logger.info("Anti-DDoS settings updated", {
|
||||||
|
staff: staff.username,
|
||||||
|
enabled: config.enabled,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to save anti-DDoS settings", { err });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert to the boot defaults (env) — drop the Redis live override and the
|
||||||
|
* DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*.
|
||||||
|
*/
|
||||||
|
export async function resetAntiddosSettings(): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redis) await redis.del(OVERRIDE_KEY);
|
||||||
|
// Remove every persisted antiddos_* row.
|
||||||
|
await db
|
||||||
|
.delete(WebsiteSetting)
|
||||||
|
.where(like(WebsiteSetting.key, "antiddos_%"));
|
||||||
|
invalidateAntiddosConfig();
|
||||||
|
siteSettings.reload();
|
||||||
|
logger.info("Anti-DDoS settings reset to defaults", {
|
||||||
|
staff: staff.username,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to reset anti-DDoS settings", { err });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a single IP from the temporary DDoS block list. */
|
||||||
|
export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const ip = str(formData.get("ip")).trim();
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redis) {
|
||||||
|
await Promise.all([
|
||||||
|
redis.del(`antiddos:block:${ip}`),
|
||||||
|
redis.del(`antiddos:block:meta:${ip}`),
|
||||||
|
redis.del(`antiddos:v:${ip}`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Also lift a matching Cloudflare edge block (best effort).
|
||||||
|
const cloudflare = await removeCloudflareBlock(ip);
|
||||||
|
logger.info("Anti-DDoS block manually removed", {
|
||||||
|
staff: staff.username,
|
||||||
|
ip,
|
||||||
|
cloudflareCleared: cloudflare.removed,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to remove anti-DDoS block", { err, ip });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an automatic Cloudflare edge block for a tracked IP. */
|
||||||
|
export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const ip = str(formData.get("ip")).trim();
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
const result = await removeCloudflareBlock(ip);
|
||||||
|
logger.info(
|
||||||
|
result.removed
|
||||||
|
? "Cloudflare automatic block removed"
|
||||||
|
: "Cloudflare automatic block removal skipped",
|
||||||
|
{
|
||||||
|
staff: staff.username,
|
||||||
|
ip,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test the configured Cloudflare API credentials against the zone. */
|
||||||
|
export async function verifyCloudflareConfiguration(): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const status = await verifyCloudflareConnection();
|
||||||
|
await setLastCloudflareVerify(status);
|
||||||
|
logger.info("Cloudflare API configuration verified", {
|
||||||
|
staff: staff.username,
|
||||||
|
ok: status.ok,
|
||||||
|
zoneName: status.zoneName,
|
||||||
|
message: status.message,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/devops/antiddos");
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
staff: { id: 9, rank: 7, username: "staff" },
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
logServerError: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
requirePermissionRateLimited: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermissionRateLimited: mocks.requirePermissionRateLimited,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { USERS_EDIT: "admin.users.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mocks.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/server-log", () => ({ logServerError: mocks.logServerError }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { approveApplication, dismissApplication } from "./admin-applications";
|
||||||
|
|
||||||
|
function form(id: string) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("id", id);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermissionRateLimited.mockResolvedValue(mocks.staff);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dismissApplication", () => {
|
||||||
|
it("deletes the application, logs activity and revalidates", async () => {
|
||||||
|
await dismissApplication(form("42"));
|
||||||
|
|
||||||
|
expect(mocks.requirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
|
PERMS.USERS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
staffId: 9,
|
||||||
|
action: "application_dismiss",
|
||||||
|
description: "Dismissed staff application #42",
|
||||||
|
targetType: "staff_application",
|
||||||
|
targetId: 42,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a zero id", async () => {
|
||||||
|
await dismissApplication(form("0"));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a non-numeric id", async () => {
|
||||||
|
await dismissApplication(form("abc"));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows delete failures, still logs and revalidates", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(dismissApplication(form("7"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"applications.delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ id: "7" },
|
||||||
|
);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial without touching the db", async () => {
|
||||||
|
mocks.requirePermissionRateLimited.mockRejectedValueOnce(
|
||||||
|
new Error("redirect:/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(dismissApplication(form("1"))).rejects.toThrow("redirect:/");
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approveApplication", () => {
|
||||||
|
it("deletes the application and logs approval", async () => {
|
||||||
|
await approveApplication(form("99"));
|
||||||
|
|
||||||
|
expect(mocks.requirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
|
PERMS.USERS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
staffId: 9,
|
||||||
|
action: "application_approve",
|
||||||
|
description: "Approved staff application #99",
|
||||||
|
targetType: "staff_application",
|
||||||
|
targetId: 99,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await approveApplication(form("0"));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows delete failures and still logs the approval", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(approveApplication(form("12"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"applications.delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ id: "12" },
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
|
|||||||