Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8638e81444 | ||
|
|
2e25b39364 | ||
|
|
5d7c9fccdc | ||
|
|
2c0439db6a | ||
|
|
e153300da0 | ||
|
|
905573e627 | ||
|
|
5b4b275b2a | ||
|
|
55c13b533c | ||
|
|
3d7278e96d | ||
|
|
438277a17a | ||
|
|
ea4fd375b4 | ||
|
|
694f87d98c | ||
|
|
ce93b92a81 | ||
|
|
faa37e7c58 | ||
|
|
da90b90c97 | ||
|
|
fff284aaa0 | ||
|
|
cfb4c36569 | ||
|
|
ffd68e604a | ||
|
|
0f01ebf48b | ||
|
|
3a4089a5fa |
No files matched your search
@@ -0,0 +1,14 @@
|
|||||||
|
# http://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{js,ts,tsx,jsx,json,md}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
@@ -11,6 +11,12 @@ 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
|
||||||
|
|
||||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -36,10 +42,42 @@ BADGE_URL=/swf/c_images/album1584
|
|||||||
# --- SECURITY & HASHING ---
|
# --- SECURITY & HASHING ---
|
||||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
APP_KEY=base64:your-app-key-here=
|
APP_KEY=base64:your-app-key-here=
|
||||||
CONVERT_PASSWORDS=true
|
# Bcrypt cost factor for new password hashes.
|
||||||
# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
|
|
||||||
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
|
||||||
|
|
||||||
# --- 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
|
||||||
|
|||||||
@@ -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,9 @@ jobs:
|
|||||||
- name: Toolchain check
|
- name: Toolchain check
|
||||||
run: node scripts/check-node-toolchain.mjs
|
run: node scripts/check-node-toolchain.mjs
|
||||||
|
|
||||||
|
- 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 +42,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 +70,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 +116,13 @@ jobs:
|
|||||||
test-results/ui/
|
test-results/ui/
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# Validate branch/PR Docker images before integration into a deployment branch.
|
|
||||||
preflight:
|
preflight:
|
||||||
needs: check
|
needs: [tests-unit, tests-integration, tests-ui]
|
||||||
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
||||||
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 }}
|
||||||
@@ -109,18 +145,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 }}
|
||||||
@@ -150,26 +181,3 @@ jobs:
|
|||||||
path: build-reports/
|
path: build-reports/
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# Publish only after checks and the production deployment have succeeded.
|
|
||||||
# Serial execution also avoids two builds competing on the self-hosted runner.
|
|
||||||
publish-container:
|
|
||||||
needs: deploy
|
|
||||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: ${{ gitea.repository }}
|
|
||||||
token: ${{ gitea.token }}
|
|
||||||
|
|
||||||
- name: Build, verify portability and publish
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REGISTRY_SERVER: ${{ gitea.server_url }}
|
|
||||||
REGISTRY_REPOSITORY: ${{ gitea.repository }}
|
|
||||||
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
|
|
||||||
REGISTRY_USER: ${{ secrets.CONTAINER_REGISTRY_USER }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
|
||||||
run: bash scripts/publish-container.sh
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Publish portable container
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: ${{ gitea.repository }}
|
|
||||||
token: ${{ gitea.token }}
|
|
||||||
- name: Install and verify
|
|
||||||
run: |
|
|
||||||
node scripts/check-node-toolchain.mjs
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm biome:lint
|
|
||||||
pnpm test:coverage
|
|
||||||
env:
|
|
||||||
SKIP_ENV_VALIDATION: 1
|
|
||||||
NODE_ENV: test
|
|
||||||
DATABASE_URL: mysql://test:[email protected]:9/test
|
|
||||||
AUTH_SECRET: container-test-secret-at-least-32-characters
|
|
||||||
- name: Build, verify portability and publish
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REGISTRY_SERVER: ${{ gitea.server_url }}
|
|
||||||
REGISTRY_REPOSITORY: ${{ gitea.repository }}
|
|
||||||
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
|
|
||||||
REGISTRY_USER: ${{ secrets.CONTAINER_REGISTRY_USER }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
|
||||||
run: bash scripts/publish-container.sh
|
|
||||||
@@ -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
|
||||||
@@ -52,3 +54,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.10.1 | 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.0** (App Router) · **React 19.3.0** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.3** · **Zod 4.6.5** · **Vitest 5.0.3** · **Biome 2.5.15** · **Playwright 1.63.0**.
|
||||||
|
|
||||||
|
> 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.
|
||||||
@@ -250,9 +261,7 @@ bash cms update
|
|||||||
|
|
||||||
The updater pulls the configured Git upstream, loads `.docker-install`, uses the
|
The updater pulls the configured Git upstream, loads `.docker-install`, uses the
|
||||||
matching application/migration images and verifies the running release locally
|
matching application/migration images and verifies the running release locally
|
||||||
and at the saved public URL. If publication for the new commit is still running,
|
and at the saved public URL. Existing application rollback remains available on a failed cutover;
|
||||||
it stops before replacing the current container; run the same command after CI
|
|
||||||
succeeds. Existing application rollback remains available on a failed cutover;
|
|
||||||
database migrations are not reversed.
|
database migrations are not reversed.
|
||||||
|
|
||||||
`bash cms install --configure-only` saves configuration without preparing runtime
|
`bash cms install --configure-only` saves configuration without preparing runtime
|
||||||
@@ -261,67 +270,7 @@ committed or sent in Docker build contexts. Existing users of
|
|||||||
`bash scripts/docker-update.sh` retain the previous behavior when no wizard
|
`bash scripts/docker-update.sh` retain the previous behavior when no wizard
|
||||||
profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work.
|
profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work.
|
||||||
|
|
||||||
To publish from Gitea:
|
Container publication is disabled. CI builds, checks and deploys the CMS, but it does not log in to a registry or upload container images.
|
||||||
|
|
||||||
Gitea packages belong to an account or organization, independently of repository
|
|
||||||
permissions. Publication defaults to the lowercase `CONTAINER_REGISTRY_USER`
|
|
||||||
namespace, so a Simo token publishes `simo/epicnext-cms` even though the Git
|
|
||||||
repository belongs to remco. Set the Actions variable
|
|
||||||
`CONTAINER_REGISTRY_NAMESPACE` only to override this (for example, an organization
|
|
||||||
where the token account has package write access). Keep the login username and
|
|
||||||
token from the same account. Changing namespace also changes the image URL used
|
|
||||||
by installations; existing remco image tags are not moved automatically.
|
|
||||||
|
|
||||||
1. In the repository's Actions secrets, configure `CONTAINER_REGISTRY_USER` and
|
|
||||||
`CONTAINER_REGISTRY_TOKEN`. Use a Gitea access token with package read/write
|
|
||||||
permission belonging to the login account. For the Simo token, set
|
|
||||||
`CONTAINER_REGISTRY_USER=Simo`; the default package namespace will be `simo`.
|
|
||||||
2. Every push to `main` or `master` automatically builds and publishes the images
|
|
||||||
after the CI checks and production deployment succeed. Pull requests do not
|
|
||||||
publish images. The publication job builds from committed source only and checks
|
|
||||||
the same application image with two runtime configurations before pushing.
|
|
||||||
Missing registry secrets fail the publication job explicitly; they do not undo
|
|
||||||
an already successful production deployment. No `latest` tag is moved.
|
|
||||||
**Publish portable container** remains available for manual retries on the
|
|
||||||
commit/branch to distribute, without redeploying production.
|
|
||||||
3. The images are `<gitea-host>/<owner>/<repository-lowercase>:<full-commit>` and
|
|
||||||
`:<full-commit>-migrations`. Only the application image runs the website; the
|
|
||||||
migrations image is used temporarily for the matching database migrations.
|
|
||||||
|
|
||||||
Publication uses checksum-pinned regctl v0.11.6 with 8 MiB blob requests to
|
|
||||||
avoid monolithic layer uploads exceeding reverse-proxy limits. Both images are
|
|
||||||
exported and uploaded sequentially; temporary archives and credentials are removed
|
|
||||||
on exit. The runner needs curl, sha256sum and temporary disk space for one Docker
|
|
||||||
image archive plus its extracted OCI layout. The remote image config digest is checked against the locally normalized archive
|
|
||||||
after each upload. A proxy must still allow the OCI registry PATCH/PUT endpoints.
|
|
||||||
|
|
||||||
For this repository the image base is
|
|
||||||
`gitlab.epicnabbo.nl/simo/epicnext-cms`. Package access is controlled by Gitea.
|
|
||||||
For private packages, run `docker login gitlab.epicnabbo.nl` on the installation
|
|
||||||
with a token that can read packages. Then update with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CMS_IMAGE_REPOSITORY=gitlab.epicnabbo.nl/simo/epicnext-cms \
|
|
||||||
CMS_PUBLIC_URL=https://your-hotel.example \
|
|
||||||
bash scripts/docker-update.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The updater pulls the configured Git upstream and requires both images for that
|
|
||||||
exact commit. A missing image or failed login stops before replacing the running
|
|
||||||
CMS. Local builds remain the default when `CMS_IMAGE_REPOSITORY` is unset. Both
|
|
||||||
paths retain the existing image/HTTP checks and automatic application rollback.
|
|
||||||
Migration secrets are mounted read-only for the temporary migration container;
|
|
||||||
they are never copied into its image. Registry images currently target the Linux
|
|
||||||
architecture of the self-hosted build runner; this is not a multi-architecture release.
|
|
||||||
|
|
||||||
The portability gate checks release identity, runtime avatar/badge routing and
|
|
||||||
absence of installation environment files in the application image. It uses an
|
|
||||||
unreachable fixture database and does not replace a full live database/site smoke
|
|
||||||
test. See `scripts/verify-portable-image.mjs`. Production deployment remains verified
|
|
||||||
separately by the existing CI workflow.
|
|
||||||
|
|
||||||
References: [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
|
||||||
and [Next.js runtime environment variables](https://nextjs.org/docs/app/guides/self-hosting).
|
|
||||||
|
|
||||||
### Diagnose an update that is not visible
|
### Diagnose an update that is not visible
|
||||||
|
|
||||||
@@ -645,7 +594,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`, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk) 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`
|
||||||
|
|
||||||
@@ -747,25 +696,353 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `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: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
|
||||||
@@ -773,10 +1050,22 @@ 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 |
|
||||||
|
| `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 |
|
||||||
@@ -786,11 +1075,16 @@ pm2 restart next
|
|||||||
| `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 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -915,7 +1209,7 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -932,3 +1226,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,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));
|
||||||
@@ -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());
|
||||||
@@ -295,7 +298,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
|
|||||||
body: JSON.stringify(errors, null, 2),
|
body: JSON.stringify(errors, null, 2),
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
});
|
});
|
||||||
await anonymous.close();
|
await anonymous.close().catch(() => {});
|
||||||
await database.end();
|
await database.end();
|
||||||
redis.disconnect();
|
redis.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
timeout: 120_000,
|
timeout: 240_000,
|
||||||
expect: { timeout: 15_000 },
|
expect: { timeout: 15_000 },
|
||||||
outputDir: "../../test-results/news-real",
|
outputDir: "../../test-results/news-real",
|
||||||
reporter: [
|
reporter: [
|
||||||
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -243,7 +243,19 @@ test("Studio loads organize imports on demand and preserves the dialog on reopen
|
|||||||
moved: 0,
|
moved: 0,
|
||||||
added: 1,
|
added: 1,
|
||||||
thumbs: [],
|
thumbs: [],
|
||||||
items: [{ itemId: 1, catalogItemId: null, alreadyPlaced: false }],
|
items: [
|
||||||
|
{
|
||||||
|
itemId: 1,
|
||||||
|
catalogItemId: null,
|
||||||
|
alreadyPlaced: false,
|
||||||
|
itemName: "fixture_table",
|
||||||
|
publicName: "Fixture Table",
|
||||||
|
spriteId: 0,
|
||||||
|
type: "s",
|
||||||
|
interactionType: "default",
|
||||||
|
sourcePageCaption: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
importRootPageId: null,
|
importRootPageId: null,
|
||||||
|
|||||||
@@ -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,12 +3,12 @@
|
|||||||
"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",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
"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",
|
||||||
@@ -30,82 +30,82 @@
|
|||||||
"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",
|
||||||
"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.17",
|
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.102.8",
|
"@tanstack/react-query": "5.104.2",
|
||||||
"@tanstack/react-virtual": "3.14.10",
|
"@tanstack/react-virtual": "3.14.17",
|
||||||
"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.1.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.1",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.41.0",
|
"lucide-react": "1.55.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "13.2.0",
|
"motion": "14.1.0",
|
||||||
"music-metadata": "11.15.0",
|
"music-metadata": "12.1.1",
|
||||||
"mysql2": "3.24.3",
|
"mysql2": "3.24.5",
|
||||||
"next": "16.3.5",
|
"next": "16.4.0",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.2",
|
"next-intl": "4.14.9",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.3.1",
|
"pino": "10.4.0",
|
||||||
"react": "19.2.8",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.3.0",
|
||||||
"react-hook-form": "7.87.0",
|
"react-hook-form": "7.90.0",
|
||||||
"resend": "6.26.0",
|
"resend": "6.32.1",
|
||||||
"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.6.0",
|
"tailwind-merge": "3.7.0",
|
||||||
"tinymce": "8.9.0",
|
"tinymce": "8.9.3",
|
||||||
"zod": "4.5.4"
|
"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.12",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.64.0",
|
||||||
"@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.0",
|
"@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.29",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"testcontainers": "12.1.0",
|
"testcontainers": "12.2.0",
|
||||||
"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.0"
|
"vitest": "5.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
|
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'
|
||||||
@@ -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,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,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||||
|
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx
|
||||||
|
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`).
|
||||||
|
#
|
||||||
|
# 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 edge cache (cms-public, cms-gamedata)"
|
||||||
|
|
||||||
|
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
||||||
|
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||||
|
# `starts_with()`.
|
||||||
|
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") 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,40 @@ 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
|
pnpm exec playwright install chromium
|
||||||
|
|
||||||
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 +440,151 @@ 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ trap 'exit 143' TERM
|
|||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm exec playwright install chromium
|
pnpm exec playwright install chromium
|
||||||
|
export NEWS_E2E_IMAGE="$image"
|
||||||
|
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);
|
|
||||||
});
|
|
||||||
@@ -78,7 +78,7 @@ else
|
|||||||
[[ "$auth_secret" =~ ^[0-9a-f]{64}$ ]] || fail "Could not generate the authentication secret."
|
[[ "$auth_secret" =~ ^[0-9a-f]{64}$ ]] || fail "Could not generate the authentication secret."
|
||||||
env_tmp="$(mktemp "$DIR/.env.install.XXXXXX")"
|
env_tmp="$(mktemp "$DIR/.env.install.XXXXXX")"
|
||||||
{
|
{
|
||||||
printf '%s\n' 'NODE_ENV=production' 'PORT=3002' 'NEXT_TELEMETRY_DISABLED=1' 'AUTH_TRUST_HOST=true' 'CONVERT_PASSWORDS=true'
|
printf '%s\n' 'NODE_ENV=production' 'PORT=3002' 'NEXT_TELEMETRY_DISABLED=1' 'AUTH_TRUST_HOST=true'
|
||||||
printf "HOTEL_NAME='%s'\nAPP_URL='%s'\nAUTH_URL='%s'\n" "$hotel" "$public_url" "$public_url"
|
printf "HOTEL_NAME='%s'\nAPP_URL='%s'\nAUTH_URL='%s'\n" "$hotel" "$public_url" "$public_url"
|
||||||
printf "DATABASE_URL='%s'\nREDIS_URL='%s'\nAUTH_SECRET='%s'\n" "$database_url" "$redis_url" "$auth_secret"
|
printf "DATABASE_URL='%s'\nREDIS_URL='%s'\nAUTH_SECRET='%s'\n" "$database_url" "$redis_url" "$auth_secret"
|
||||||
printf "IMAGER_URL='%s'\nIMAGING_UPSTREAM_URL='%s'\nBADGE_URL='/swf/c_images/album1584'\n" "$imager_url" "$imager_url"
|
printf "IMAGER_URL='%s'\nIMAGING_UPSTREAM_URL='%s'\nBADGE_URL='/swf/c_images/album1584'\n" "$imager_url" "$imager_url"
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -58,10 +58,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
|
||||||
|
die "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
|
||||||
|
die "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 +123,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);
|
||||||
|
});
|
||||||
@@ -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,73 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
: "${REGISTRY_SERVER:?Missing Gitea server URL}"
|
|
||||||
: "${REGISTRY_REPOSITORY:?Missing owner/repository}"
|
|
||||||
: "${REGISTRY_USER:?Configure CONTAINER_REGISTRY_USER}"
|
|
||||||
: "${REGISTRY_TOKEN:?Configure CONTAINER_REGISTRY_TOKEN with package write access}"
|
|
||||||
sha="$(git rev-parse HEAD)"
|
|
||||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
|
||||||
registry="${REGISTRY_SERVER#https://}"
|
|
||||||
registry="${registry%/}"
|
|
||||||
[[ "$REGISTRY_SERVER" = https://* && "$registry" != */* ]] || { echo "Registry must use HTTPS at the Gitea server root" >&2; exit 1; }
|
|
||||||
repository="${REGISTRY_REPOSITORY,,}"
|
|
||||||
[[ "$repository" =~ ^[a-z0-9._-]+/[a-z0-9._-]+$ ]] || exit 1
|
|
||||||
# Gitea packages belong to a user/organization, independently of repository ACLs.
|
|
||||||
# A collaborator token cannot publish to another user's personal namespace.
|
|
||||||
namespace="${REGISTRY_NAMESPACE:-$REGISTRY_USER}"
|
|
||||||
namespace="${namespace,,}"
|
|
||||||
[[ "$namespace" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || { echo "Invalid registry namespace; use a Gitea username or organization" >&2; exit 1; }
|
|
||||||
repository="$namespace/${repository#*/}"
|
|
||||||
image="$registry/$repository:$sha"
|
|
||||||
# Isolate credentials from the self-hosted runner's normal Docker configuration.
|
|
||||||
export DOCKER_CONFIG
|
|
||||||
DOCKER_CONFIG="$(mktemp -d)"
|
|
||||||
context="$(mktemp -d)"
|
|
||||||
trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT
|
|
||||||
# Build only the committed source, never untracked files from a shared runner.
|
|
||||||
git archive HEAD | tar -x -C "$context"
|
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
|
|
||||||
unset REGISTRY_TOKEN
|
|
||||||
# On the shared runner, publish the exact image already verified by deployment.
|
|
||||||
local_image="epicnext-cms:$sha"
|
|
||||||
local_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$local_image" 2>/dev/null || true)"
|
|
||||||
local_id="$(docker image inspect --format '{{.Id}}' "$local_image" 2>/dev/null || true)"
|
|
||||||
verified_id="$(docker image inspect --format '{{.Id}}' "epicnext-cms:verified-$sha" 2>/dev/null || true)"
|
|
||||||
if [[ "$local_revision" = "$sha" && -n "$local_id" && "$local_id" = "$verified_id" ]]; then
|
|
||||||
docker tag "$verified_id" "$image"
|
|
||||||
echo "Reusing verified release image $local_image"
|
|
||||||
else
|
|
||||||
docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context"
|
|
||||||
fi
|
|
||||||
docker build --network=host --target migrations -t "$image-migrations" "$context"
|
|
||||||
node scripts/verify-portable-image.mjs "$image" "$sha"
|
|
||||||
# Publish only after the same application image passed both runtime configurations.
|
|
||||||
# Bound each blob request below reverse-proxy upload limits. Pin the uploader
|
|
||||||
# and verify its checksum before giving it access to the temporary Docker login.
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) arch=amd64; checksum=8e0e62a497fcdb8048d18aa927a139613176ba0531f412bc541044e28f9856bd ;;
|
|
||||||
aarch64|arm64) arch=arm64; checksum=a9b71a3ee79b2d1dbbd7d51fd5e8fa214722c192864235d3d8764463c751a1ff ;;
|
|
||||||
*) echo "Unsupported registry uploader architecture" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
curl --fail --silent --show-error --location --retry 3 --connect-timeout 15 --max-time 120 \
|
|
||||||
"https://github.com/regclient/regclient/releases/download/v0.11.6/regctl-linux-$arch" -o "$context/regctl"
|
|
||||||
printf '%s %s\n' "$checksum" "$context/regctl" | sha256sum --check --status
|
|
||||||
chmod 700 "$context/regctl"
|
|
||||||
export REGCTL_CONFIG="$DOCKER_CONFIG/regctl.json"
|
|
||||||
regctl() { "$context/regctl" "$@"; }
|
|
||||||
regctl registry set "$registry" --blob-chunk 8388608 --blob-max 8388608
|
|
||||||
for target in "$image-migrations" "$image"; do
|
|
||||||
echo "Publishing $target with blob requests up to 8 MiB"
|
|
||||||
docker image save --output "$context/image.tar" "$target"
|
|
||||||
# Normalize the saved archive locally before copying it unchanged to Gitea.
|
|
||||||
# Docker engine IDs and OCI index IDs are not interchangeable with config IDs.
|
|
||||||
local_ref="ocidir://$context/oci:verified"
|
|
||||||
regctl image import "$local_ref" "$context/image.tar"
|
|
||||||
expected_config="$(regctl manifest get "$local_ref" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
|
|
||||||
[[ "$expected_config" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid local image config digest" >&2; exit 1; }
|
|
||||||
regctl image copy "$local_ref" "$target"
|
|
||||||
remote_config="$(regctl manifest get "$target" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
|
|
||||||
[[ "$remote_config" = "$expected_config" ]] || { echo "Published image config does not match verified local image" >&2; exit 1; }
|
|
||||||
rm -f -- "$context/image.tar"
|
|
||||||
rm -rf -- "$context/oci"
|
|
||||||
done
|
|
||||||
echo "Published application and migrations: $image"
|
|
||||||
@@ -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,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
|
||||||
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { ActionError } from "@/lib/safe-action-shared";
|
import { ActionError } from "@/lib/safe-action-shared";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
import { createAd, deleteAd } from "./admin-ads";
|
import { createAd, deleteAd } from "./admin-ads";
|
||||||
|
|
||||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||||
@@ -41,10 +42,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
|
||||||
get: (k: string) => data[k] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
@@ -55,7 +52,7 @@ beforeEach(() => {
|
|||||||
describe("createAd", () => {
|
describe("createAd", () => {
|
||||||
it("creates ad and redirects", async () => {
|
it("creates ad and redirects", async () => {
|
||||||
await createAd(
|
await createAd(
|
||||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
fakeForm({ image: "https://example.com/ad.png" }) as FormData,
|
||||||
);
|
);
|
||||||
expect(insertValues).toHaveBeenCalled();
|
expect(insertValues).toHaveBeenCalled();
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
@@ -63,13 +60,13 @@ describe("createAd", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when image empty", async () => {
|
it("returns early when image empty", async () => {
|
||||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
await createAd(fakeForm({ image: "" }) as FormData);
|
||||||
expect(insertValues).not.toHaveBeenCalled();
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs error on db failure", async () => {
|
it("logs error on db failure", async () => {
|
||||||
insertValues.mockRejectedValue(new Error("db"));
|
insertValues.mockRejectedValue(new Error("db"));
|
||||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
await createAd(fakeForm({ image: "x" }) as FormData);
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -26,11 +25,11 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
const [result] = (await db.insert(WebsiteAds).values({
|
const [result] = await db.insert(WebsiteAds).values({
|
||||||
image,
|
image,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})) as unknown as [ResultSetHeader];
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "ad_create",
|
action: "ad_create",
|
||||||
@@ -95,9 +94,7 @@ export const deleteAd = adminAction(
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const id = ctx.data.id;
|
const id = ctx.data.id;
|
||||||
try {
|
try {
|
||||||
const [result] = (await db
|
const [result] = await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
||||||
.delete(WebsiteAds)
|
|
||||||
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
|
|
||||||
if (!result.affectedRows) {
|
if (!result.affectedRows) {
|
||||||
throw new ActionError("Advertisement not found");
|
throw new ActionError("Advertisement not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
import { sendHotelAlert } from "./admin-alerts";
|
import { sendHotelAlert } from "./admin-alerts";
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
@@ -18,10 +19,6 @@ vi.mock("@/lib/db", () => ({
|
|||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
|
||||||
get: (key: string) => data[key] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
@@ -33,15 +30,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("sendHotelAlert", () => {
|
describe("sendHotelAlert", () => {
|
||||||
it("sends hotel alert and revalidates", async () => {
|
it("sends hotel alert and revalidates", async () => {
|
||||||
await sendHotelAlert(
|
await sendHotelAlert(fakeForm({ message: "Hello!" }) as FormData);
|
||||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when message is empty", async () => {
|
it("returns early when message is empty", async () => {
|
||||||
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
await sendHotelAlert(fakeForm({ message: "" }) as FormData);
|
||||||
expect(rcon.send).not.toHaveBeenCalled();
|
expect(rcon.send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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(() => ({
|
||||||
@@ -37,6 +38,7 @@ vi.mock("next/navigation", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/db", async () => {
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const { createDbHelpers } = await import("@/test/db-helpers");
|
||||||
const { MySqlDialect } = await import("drizzle-orm/mysql-core");
|
const { MySqlDialect } = await import("drizzle-orm/mysql-core");
|
||||||
const dialect = new MySqlDialect();
|
const dialect = new MySqlDialect();
|
||||||
const database = {
|
const database = {
|
||||||
@@ -85,7 +87,11 @@ vi.mock("@/lib/db", async () => {
|
|||||||
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
|
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
|
||||||
fn(database),
|
fn(database),
|
||||||
};
|
};
|
||||||
return { ...(await import("@/db/schema")), db: database };
|
return {
|
||||||
|
...(await import("@/db/schema")),
|
||||||
|
...createDbHelpers(database.execute),
|
||||||
|
db: database,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { articleEditToken } from "@/lib/article-edit-token";
|
import { articleEditToken } from "@/lib/article-edit-token";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/lib/article-input";
|
} from "@/lib/article-input";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
|
rowsFrom,
|
||||||
WebsiteArticleComments,
|
WebsiteArticleComments,
|
||||||
WebsiteArticleReactions,
|
WebsiteArticleReactions,
|
||||||
WebsiteArticles,
|
WebsiteArticles,
|
||||||
@@ -128,11 +129,9 @@ export async function createArticle(
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
publishedAt: fields.status === "published" ? now : null,
|
publishedAt: fields.status === "published" ? now : null,
|
||||||
});
|
});
|
||||||
const [createdRows] = await tx.execute(
|
const articleId = rowsFrom<{ id: string }>(
|
||||||
sql`SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id`,
|
await tx.execute(sql`SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id`),
|
||||||
);
|
)[0]?.id;
|
||||||
const articleId = (createdRows as unknown as Array<{ id: string }>)[0]
|
|
||||||
?.id;
|
|
||||||
if (!articleId || !/^[1-9][0-9]*$/.test(articleId))
|
if (!articleId || !/^[1-9][0-9]*$/.test(articleId))
|
||||||
throw new Error("Article identity unavailable");
|
throw new Error("Article identity unavailable");
|
||||||
await enqueueEffect(tx, operationId, "news.refresh");
|
await enqueueEffect(tx, operationId, "news.refresh");
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(async () => ({ id: 1, rank: 7, username: "a" })),
|
||||||
|
writeFile: vi.fn(async () => undefined),
|
||||||
|
toBadgeGif: vi.fn(async () => Buffer.from("gif")),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
redirect: vi.fn((url: string) => {
|
||||||
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { CATALOG_EDIT: "admin.catalog.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/images/badge-gif", () => ({ toBadgeGif: mocks.toBadgeGif }));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mocks.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: mocks.redirect }));
|
||||||
|
vi.mock("node:fs/promises", () => ({ writeFile: mocks.writeFile }));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { uploadBadge } from "./admin-badge-upload";
|
||||||
|
|
||||||
|
function form(fields: Record<string, string | File>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(fields)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function png(size = 4, type = "image/png") {
|
||||||
|
return new File([new Uint8Array(size)], "badge.png", { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(fd: FormData): Promise<Error | null> {
|
||||||
|
try {
|
||||||
|
await uploadBadge(fd);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return error as Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIR = "/var/www/atom-nexst/storage/badges";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubEnv("BADGE_UPLOAD_DIR", DIR);
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.writeFile.mockResolvedValue(undefined);
|
||||||
|
mocks.toBadgeGif.mockResolvedValue(Buffer.from("gif"));
|
||||||
|
mocks.logStaffActivity.mockResolvedValue(undefined);
|
||||||
|
mocks.redirect.mockImplementation((url: string) => {
|
||||||
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadBadge", () => {
|
||||||
|
it("normalises the image and writes it as <code>.gif", async () => {
|
||||||
|
const error = await run(form({ code: " ACH_1 ", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||||
|
expect(mocks.toBadgeGif).toHaveBeenCalledWith(expect.any(Buffer));
|
||||||
|
expect(mocks.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
const [target, gif] = mocks.writeFile.mock.calls[0] as [unknown, Buffer];
|
||||||
|
expect(String(target)).toBe(`${DIR}/ACH_1.gif`);
|
||||||
|
expect(gif).toEqual(Buffer.from("gif"));
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "badge_upload",
|
||||||
|
description: 'Uploaded badge image "ACH_1.gif"',
|
||||||
|
targetType: "badge",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?uploaded=${encodeURIComponent("ACH_1")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts GIF uploads", async () => {
|
||||||
|
const error = await run(form({ code: "G1", file: png(4, "image/gif") }));
|
||||||
|
|
||||||
|
expect(mocks.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the badge directory is not configured", async () => {
|
||||||
|
vi.stubEnv("BADGE_UPLOAD_DIR", "");
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Badge upload directory not configured")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request with no code field", async () => {
|
||||||
|
const error = await run(form({ file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid badge code", async () => {
|
||||||
|
const error = await run(form({ code: "bad code!", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing file", async () => {
|
||||||
|
const error = await run(form({ code: "A" }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("No file uploaded")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty file", async () => {
|
||||||
|
const error = await run(form({ code: "A", file: png(0) }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Uploaded file is empty")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a file larger than 1MB", async () => {
|
||||||
|
const error = await run(form({ code: "A", file: png(1024 * 1024 + 1) }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("File too large (max 1MB)")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a disallowed mime type", async () => {
|
||||||
|
const error = await run(form({ code: "A", file: png(4, "image/jpeg") }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("File must be a GIF or PNG image")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a processing failure when the converter throws", async () => {
|
||||||
|
mocks.toBadgeGif.mockRejectedValueOnce(new Error("sharp failed"));
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Could not process or write the badge file")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a processing failure when the write fails", async () => {
|
||||||
|
mocks.writeFile.mockRejectedValueOnce(new Error("EACCES"));
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Could not process or write the badge file")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a resolved target outside the configured directory", async () => {
|
||||||
|
vi.stubEnv("BADGE_UPLOAD_DIR", "/");
|
||||||
|
|
||||||
|
const error = await run(form({ code: "escape", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Invalid path")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.toBadgeGif).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("error=Invalid%20path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(error?.message).toBe("redirect:/admin");
|
||||||
|
expect(mocks.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,14 +51,15 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
|||||||
back("error", "File must be a GIF or PNG image");
|
back("error", "File must be a GIF or PNG image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseDir = path.resolve(dir);
|
||||||
|
const target = path.resolve(baseDir, `${code}.gif`);
|
||||||
|
if (!target.startsWith(baseDir + path.sep)) {
|
||||||
|
back("error", "Invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const gif = await toBadgeGif(buffer);
|
const gif = await toBadgeGif(buffer);
|
||||||
const baseDir = path.resolve(dir);
|
|
||||||
const target = path.resolve(baseDir, `${code}.gif`);
|
|
||||||
if (!target.startsWith(baseDir + path.sep)) {
|
|
||||||
back("error", "Invalid path");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
await writeFile(target, gif);
|
await writeFile(target, gif);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
existingRows: [] as unknown[],
|
||||||
|
maxRows: [] as unknown[],
|
||||||
|
insertValues: vi.fn(async () => [{}]),
|
||||||
|
fail: false,
|
||||||
|
giveBadge: vi.fn(async () => undefined),
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
tryGuardRank: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: state.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/rank-guard", () => ({
|
||||||
|
tryGuardRank: state.tryGuardRank,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { CATALOG_EDIT: "admin.catalog.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { giveBadge: state.giveBadge },
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((_table, projection) => {
|
||||||
|
if (state.fail) throw new Error("db down");
|
||||||
|
return "maxSlot" in projection ? state.maxRows : state.existingRows;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: vi.fn(() => ({ values: state.insertValues })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { giveBadge } from "./admin-badges";
|
||||||
|
|
||||||
|
function form(userId: string, code: string) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("userId", userId);
|
||||||
|
f.set("code", code);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.existingRows = [];
|
||||||
|
state.maxRows = [];
|
||||||
|
state.fail = false;
|
||||||
|
state.insertValues.mockResolvedValue([{}]);
|
||||||
|
state.giveBadge.mockResolvedValue(undefined);
|
||||||
|
state.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
state.tryGuardRank.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
target: { username: "u", rank: 1, mail: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("giveBadge", () => {
|
||||||
|
it("grants via rcon and persists at the next free slot", async () => {
|
||||||
|
state.maxRows = [{ maxSlot: 5 }];
|
||||||
|
|
||||||
|
await giveBadge(form("3", "ACH_Test"));
|
||||||
|
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||||
|
expect(state.tryGuardRank).toHaveBeenCalledWith(3, 7);
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(3, "ACH_Test");
|
||||||
|
expect(state.insertValues).toHaveBeenCalledWith({
|
||||||
|
userId: 3,
|
||||||
|
slotId: 6,
|
||||||
|
badgeCode: "ACH_Test",
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses slot 1 when the user has no badges yet", async () => {
|
||||||
|
state.maxRows = [];
|
||||||
|
|
||||||
|
await giveBadge(form("3", "NEW"));
|
||||||
|
|
||||||
|
expect(state.insertValues).toHaveBeenCalledWith({
|
||||||
|
userId: 3,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "NEW",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a null max slot as zero", async () => {
|
||||||
|
state.maxRows = [{ maxSlot: null }];
|
||||||
|
|
||||||
|
await giveBadge(form("3", "NEW"));
|
||||||
|
|
||||||
|
expect(state.insertValues).toHaveBeenCalledWith({
|
||||||
|
userId: 3,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "NEW",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist a duplicate badge and skips revalidation", async () => {
|
||||||
|
state.existingRows = [{ id: 10 }];
|
||||||
|
|
||||||
|
await giveBadge(form("4", "DUP"));
|
||||||
|
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(4, "DUP");
|
||||||
|
expect(state.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a user at or above the actor rank", async () => {
|
||||||
|
state.tryGuardRank.mockResolvedValueOnce({ ok: false, reason: "rank" });
|
||||||
|
|
||||||
|
await giveBadge(form("4", "DUP"));
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
expect(state.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an unknown user", async () => {
|
||||||
|
state.tryGuardRank.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
reason: "not_found",
|
||||||
|
});
|
||||||
|
|
||||||
|
await giveBadge(form("404", "DUP"));
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
expect(state.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a non-positive user id", async () => {
|
||||||
|
await giveBadge(form("0", "X"));
|
||||||
|
|
||||||
|
expect(state.tryGuardRank).not.toHaveBeenCalled();
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
expect(state.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an empty code", async () => {
|
||||||
|
await giveBadge(form("5", " "));
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the code field is absent", async () => {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("userId", "5");
|
||||||
|
|
||||||
|
await giveBadge(f);
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises and truncates the badge code to 32 characters", async () => {
|
||||||
|
const longCode = "a".repeat(40);
|
||||||
|
|
||||||
|
await giveBadge(form("5", ` ${longCode} `));
|
||||||
|
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(5, "a".repeat(32));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps bulk user and code lists", async () => {
|
||||||
|
const users = Array.from({ length: 250 }, (_, i) => i + 1).join(",");
|
||||||
|
const codes = Array.from({ length: 250 }, (_, i) => `C${i}`).join(",");
|
||||||
|
|
||||||
|
await giveBadge(form(users, codes));
|
||||||
|
|
||||||
|
// 100 users x 100 codes = 10k pairs, capped to MAX_PAIRS (500).
|
||||||
|
const calls = state.insertValues.mock.calls.length;
|
||||||
|
expect(calls).toBeLessThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when persistence fails instead of swallowing it", async () => {
|
||||||
|
state.insertValues.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(giveBadge(form("6", "BEST"))).rejects.toThrow(
|
||||||
|
"Failed to persist",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(6, "BEST");
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the select fails instead of reporting a false success", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
|
||||||
|
await expect(giveBadge(form("6", "BEST"))).rejects.toThrow(
|
||||||
|
"Failed to persist",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(giveBadge(form("1", "X"))).rejects.toThrow("redirect:/admin");
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,45 +3,105 @@
|
|||||||
import { and, eq, max } from "drizzle-orm";
|
import { and, eq, max } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { tryGuardRank } from "@/lib/admin/rank-guard";
|
||||||
import { db, UsersBadges } from "@/lib/db";
|
import { db, UsersBadges } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
|
// Bound the bulk surface so a paste bomb cannot turn into an unbounded N+1.
|
||||||
|
const MAX_USERS = 100;
|
||||||
|
const MAX_CODES = 100;
|
||||||
|
const MAX_PAIRS = 500;
|
||||||
|
|
||||||
export async function giveBadge(formData: FormData): Promise<void> {
|
export async function giveBadge(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
|
|
||||||
const userId = Number(formData.get("userId"));
|
// Support comma-separated userIds and/or codes for bulk operations
|
||||||
const code = String(formData.get("code") ?? "")
|
const userIdInput = String(formData.get("userId") ?? "").trim();
|
||||||
.normalize("NFC")
|
const codeInput = String(formData.get("code") ?? "").trim();
|
||||||
.trim()
|
const userIds = userIdInput
|
||||||
.slice(0, 32);
|
? userIdInput
|
||||||
if (!(userId > 0) || code.length === 0) return;
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_USERS)
|
||||||
|
: [];
|
||||||
|
const codes = codeInput
|
||||||
|
? codeInput
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_CODES)
|
||||||
|
: [];
|
||||||
|
|
||||||
// Fire the emulator command so the badge appears live for online users.
|
if (userIds.length === 0 || codes.length === 0) {
|
||||||
await rcon.giveBadge(userId, code);
|
return;
|
||||||
|
|
||||||
// Persist the badge directly so it survives a relog / offline grant.
|
|
||||||
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
|
||||||
// against duplicates and compute the next free slot ourselves.
|
|
||||||
try {
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: UsersBadges.id })
|
|
||||||
.from(UsersBadges)
|
|
||||||
.where(
|
|
||||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) {
|
|
||||||
const [agg] = await db
|
|
||||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
|
||||||
.from(UsersBadges)
|
|
||||||
.where(eq(UsersBadges.userId, userId));
|
|
||||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
|
||||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort: the RCON grant already succeeded for online users.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/badges");
|
let granted = 0;
|
||||||
|
let failures = 0;
|
||||||
|
for (const rawUserId of userIds) {
|
||||||
|
const userId = Number(rawUserId);
|
||||||
|
if (!(userId > 0)) continue;
|
||||||
|
|
||||||
|
// Never hand badges to a target at or above the actor's rank.
|
||||||
|
const guard = await tryGuardRank(userId, staff.rank);
|
||||||
|
if (!guard.ok) continue;
|
||||||
|
|
||||||
|
for (const rawCode of codes) {
|
||||||
|
if (granted + failures >= MAX_PAIRS) break;
|
||||||
|
// Truncate badge code to 32 characters.
|
||||||
|
const truncatedCode = rawCode.slice(0, 32);
|
||||||
|
if (truncatedCode.length === 0) continue;
|
||||||
|
|
||||||
|
// Fire the emulator command so the badge appears live for online users.
|
||||||
|
// Offline/unreachable emulator must not block the DB persistence.
|
||||||
|
try {
|
||||||
|
await rcon.giveBadge(userId, truncatedCode);
|
||||||
|
} catch {
|
||||||
|
// ignore rcon errors per pair
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the badge directly so it survives a relog / offline grant.
|
||||||
|
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||||
|
// against duplicates and compute the next free slot ourselves.
|
||||||
|
try {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: UsersBadges.id })
|
||||||
|
.from(UsersBadges)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(UsersBadges.userId, userId),
|
||||||
|
eq(UsersBadges.badgeCode, truncatedCode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) {
|
||||||
|
const [agg] = await db
|
||||||
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||||
|
.from(UsersBadges)
|
||||||
|
.where(eq(UsersBadges.userId, userId));
|
||||||
|
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||||
|
await db.insert(UsersBadges).values({
|
||||||
|
userId,
|
||||||
|
slotId,
|
||||||
|
badgeCode: truncatedCode,
|
||||||
|
});
|
||||||
|
granted++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A persistence failure must be visible, not silently dropped.
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to persist ${failures} badge grant(s); the emulator may have succeeded for some users`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (granted > 0) {
|
||||||
|
revalidatePath("/admin/badges");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock next/server, next-auth and @/lib/auth before imports
|
// Mock next/server, next-auth and @/lib/auth before imports
|
||||||
@@ -21,7 +22,7 @@ vi.mock("@/lib/auth", () => ({
|
|||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { Ban, db, User } from "@/lib/db";
|
import { Ban, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { createBan, liftBan } from "./admin-bans";
|
import { createBan, liftBan } from "./admin-bans";
|
||||||
|
|
||||||
@@ -41,6 +42,20 @@ vi.mock("@/lib/admin/guard", () => ({
|
|||||||
requirePermissionRateLimited: mockRequirePermissionRateLimited,
|
requirePermissionRateLimited: mockRequirePermissionRateLimited,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockTryGuardRank = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin/rank-guard", () => ({
|
||||||
|
tryGuardRank: mockTryGuardRank,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRedirect = vi.hoisted(() =>
|
||||||
|
vi.fn(() => {
|
||||||
|
throw new Error("NEXT_REDIRECT");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: mockRedirect,
|
||||||
|
}));
|
||||||
|
|
||||||
// Hoisted mocks for internal db functions
|
// Hoisted mocks for internal db functions
|
||||||
const hoistedInsertValues = vi.hoisted(() => vi.fn());
|
const hoistedInsertValues = vi.hoisted(() => vi.fn());
|
||||||
const hoistedDeleteWhere = vi.hoisted(() => vi.fn());
|
const hoistedDeleteWhere = vi.hoisted(() => vi.fn());
|
||||||
@@ -60,7 +75,6 @@ vi.mock("@/lib/db", () => {
|
|||||||
select: vi.fn(() => ({ from: hoistedSelectFrom })),
|
select: vi.fn(() => ({ from: hoistedSelectFrom })),
|
||||||
},
|
},
|
||||||
Ban: { id: "Ban.id", userId: "Ban.userId" },
|
Ban: { id: "Ban.id", userId: "Ban.userId" },
|
||||||
User: { id: "User.id", username: "User.username" },
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +106,11 @@ beforeEach(() => {
|
|||||||
mockRequirePermissionRateLimited.mockResolvedValue({ id: 100, rank: 7 });
|
mockRequirePermissionRateLimited.mockResolvedValue({ id: 100, rank: 7 });
|
||||||
hoistedInsertValues.mockResolvedValue([{ insertId: 1 }]);
|
hoistedInsertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
hoistedDeleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
hoistedDeleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
hoistedSelectLimit.mockResolvedValue([{ username: "testuser" }]);
|
hoistedSelectLimit.mockResolvedValue([{ userId: 5 }]);
|
||||||
|
mockTryGuardRank.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
target: { username: "testuser", rank: 1, mail: null },
|
||||||
|
});
|
||||||
mockHeaders.mockReturnValue(new Map());
|
mockHeaders.mockReturnValue(new Map());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,6 +127,7 @@ describe("createBan", () => {
|
|||||||
expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith(
|
expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
PERMS.USERS_BAN,
|
PERMS.USERS_BAN,
|
||||||
);
|
);
|
||||||
|
expect(mockTryGuardRank).toHaveBeenCalledWith(1, 7);
|
||||||
expect(db.insert).toHaveBeenCalledWith(Ban);
|
expect(db.insert).toHaveBeenCalledWith(Ban);
|
||||||
expect(hoistedInsertValues).toHaveBeenCalledWith(
|
expect(hoistedInsertValues).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -155,6 +174,7 @@ describe("createBan", () => {
|
|||||||
|
|
||||||
await createBan(formData);
|
await createBan(formData);
|
||||||
|
|
||||||
|
expect(mockTryGuardRank).not.toHaveBeenCalled();
|
||||||
expect(db.insert).not.toHaveBeenCalled();
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -168,27 +188,45 @@ describe("createBan", () => {
|
|||||||
|
|
||||||
await createBan(formData);
|
await createBan(formData);
|
||||||
|
|
||||||
|
expect(mockTryGuardRank).not.toHaveBeenCalled();
|
||||||
expect(db.insert).not.toHaveBeenCalled();
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnects user via RCON if user found", async () => {
|
it("disconnects user via RCON using the guarded username", async () => {
|
||||||
|
mockTryGuardRank.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
target: { username: "anotheruser", rank: 1, mail: null },
|
||||||
|
});
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("userId", "3");
|
formData.append("userId", "3");
|
||||||
formData.append("type", "account");
|
formData.append("type", "account");
|
||||||
formData.append("reason", "Test reason");
|
formData.append("reason", "Test reason");
|
||||||
|
|
||||||
hoistedSelectLimit.mockResolvedValueOnce([{ username: "anotheruser" }]);
|
|
||||||
|
|
||||||
await createBan(formData);
|
await createBan(formData);
|
||||||
|
|
||||||
|
expect(mockTryGuardRank).toHaveBeenCalledWith(3, 7);
|
||||||
expect(mockRconDisconnectUser).toHaveBeenCalledWith(3, "anotheruser");
|
expect(mockRconDisconnectUser).toHaveBeenCalledWith(3, "anotheruser");
|
||||||
expect(mockEq).toHaveBeenCalledWith(User.id, 3);
|
});
|
||||||
|
|
||||||
|
it("refuses to ban a user with equal or higher rank", async () => {
|
||||||
|
mockTryGuardRank.mockResolvedValueOnce({ ok: false, reason: "rank" });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("userId", "3");
|
||||||
|
formData.append("type", "account");
|
||||||
|
formData.append("reason", "Test reason");
|
||||||
|
|
||||||
|
await expect(createBan(formData)).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/bans?error=rank");
|
||||||
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("liftBan", () => {
|
describe("liftBan", () => {
|
||||||
it("lifts an existing ban and logs activity", async () => {
|
it("lifts an existing ban and logs activity", async () => {
|
||||||
|
hoistedSelectLimit.mockResolvedValueOnce([{ userId: 5 }]);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("id", "123");
|
formData.append("id", "123");
|
||||||
|
|
||||||
@@ -197,6 +235,8 @@ describe("liftBan", () => {
|
|||||||
expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith(
|
expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
PERMS.USERS_BAN,
|
PERMS.USERS_BAN,
|
||||||
);
|
);
|
||||||
|
expect(mockEq).toHaveBeenCalledWith(Ban.id, 123);
|
||||||
|
expect(mockTryGuardRank).toHaveBeenCalledWith(5, 7);
|
||||||
expect(db.delete).toHaveBeenCalledWith(Ban);
|
expect(db.delete).toHaveBeenCalledWith(Ban);
|
||||||
expect(hoistedDeleteWhere).toHaveBeenCalledWith(mockEq(Ban.id, 123));
|
expect(hoistedDeleteWhere).toHaveBeenCalledWith(mockEq(Ban.id, 123));
|
||||||
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
@@ -208,12 +248,37 @@ describe("liftBan", () => {
|
|||||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/bans");
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses to lift a ban placed on an equal or higher rank user", async () => {
|
||||||
|
hoistedSelectLimit.mockResolvedValueOnce([{ userId: 5 }]);
|
||||||
|
mockTryGuardRank.mockResolvedValueOnce({ ok: false, reason: "rank" });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("id", "123");
|
||||||
|
|
||||||
|
await expect(liftBan(formData)).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/bans?error=rank");
|
||||||
|
expect(hoistedDeleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates a lift when the targeted user no longer exists", async () => {
|
||||||
|
hoistedSelectLimit.mockResolvedValueOnce([{ userId: 5 }]);
|
||||||
|
mockTryGuardRank.mockResolvedValueOnce({ ok: false, reason: "not_found" });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("id", "123");
|
||||||
|
|
||||||
|
await liftBan(formData);
|
||||||
|
|
||||||
|
expect(db.delete).toHaveBeenCalledWith(Ban);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not delete if ban id is not positive", async () => {
|
it("does not delete if ban id is not positive", async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("id", "0");
|
formData.append("id", "0");
|
||||||
|
|
||||||
await liftBan(formData);
|
await liftBan(formData);
|
||||||
|
|
||||||
|
expect(mockEq).not.toHaveBeenCalled();
|
||||||
expect(db.delete).not.toHaveBeenCalled();
|
expect(db.delete).not.toHaveBeenCalled();
|
||||||
expect(hoistedDeleteWhere).not.toHaveBeenCalled();
|
expect(hoistedDeleteWhere).not.toHaveBeenCalled();
|
||||||
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { Ban, db, User } from "@/lib/db";
|
import { tryGuardRank } from "@/lib/admin/rank-guard";
|
||||||
|
import { Ban, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -26,16 +28,15 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
const type = String(formData.get("type"));
|
const type = String(formData.get("type"));
|
||||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||||
|
|
||||||
|
const guard = await tryGuardRank(userId, staff.rank);
|
||||||
|
if (!guard.ok && guard.reason === "rank") {
|
||||||
|
redirect("/admin/bans?error=rank");
|
||||||
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||||
|
|
||||||
const [user] = await db
|
|
||||||
.select({ username: User.username })
|
|
||||||
.from(User)
|
|
||||||
.where(eq(User.id, userId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
await db.insert(Ban).values({
|
await db.insert(Ban).values({
|
||||||
userId,
|
userId,
|
||||||
ip: "",
|
ip: "",
|
||||||
@@ -48,7 +49,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
cfhTopic: -1,
|
cfhTopic: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) await rcon.disconnectUser(userId, user.username);
|
if (guard.ok) await rcon.disconnectUser(userId, guard.target.username);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "user_ban",
|
action: "user_ban",
|
||||||
@@ -63,6 +64,17 @@ export async function liftBan(formData: FormData): Promise<void> {
|
|||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (id > 0) {
|
if (id > 0) {
|
||||||
|
const [ban] = await db
|
||||||
|
.select({ userId: Ban.userId })
|
||||||
|
.from(Ban)
|
||||||
|
.where(eq(Ban.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (ban) {
|
||||||
|
const guard = await tryGuardRank(ban.userId, staff.rank);
|
||||||
|
if (!guard.ok && guard.reason === "rank") {
|
||||||
|
redirect("/admin/bans?error=rank");
|
||||||
|
}
|
||||||
|
}
|
||||||
await db.delete(Ban).where(eq(Ban.id, id));
|
await db.delete(Ban).where(eq(Ban.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
insertValues: vi.fn(),
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
logServerError: vi.fn(),
|
||||||
|
siteSettingsUpdate: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { DAILY_REWARDS_EDIT: "admin.dailyrewards.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/server-log", () => ({ logServerError: mocks.logServerError }));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { update: mocks.siteSettingsUpdate },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
deleteDailyReward,
|
||||||
|
setDailyRewardEnabled,
|
||||||
|
upsertDailyReward,
|
||||||
|
} from "./admin-daily-rewards";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.insertValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: vi.fn(async () => [{}]),
|
||||||
|
});
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.siteSettingsUpdate.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertDailyReward", () => {
|
||||||
|
it("inserts a new reward and revalidates both surfaces", async () => {
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
day: "3",
|
||||||
|
currency: "Duckets",
|
||||||
|
amount: "500",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
PERMS.DAILY_REWARDS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
day: 3,
|
||||||
|
currency: "duckets",
|
||||||
|
amount: 500,
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/daily-rewards");
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing reward by id", async () => {
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
id: "12",
|
||||||
|
day: "1",
|
||||||
|
currency: "credits",
|
||||||
|
amount: "10",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
day: 1,
|
||||||
|
currency: "credits",
|
||||||
|
amount: 10,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.updateWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a day outside 1..365", async () => {
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "0", currency: "credits", amount: "5" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day must be between 1 and 365",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "366", currency: "credits", amount: "5" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day must be between 1 and 365",
|
||||||
|
});
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive amount", async () => {
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "1", currency: "credits", amount: "0" }),
|
||||||
|
).toEqual({ ok: false, error: "Reward amount must be positive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown currency", async () => {
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "1", currency: "gold", amount: "5" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward currency is not one of the emulator wallets",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a payload with no currency", async () => {
|
||||||
|
expect(await upsertDailyReward({ day: "1", amount: "5" } as never)).toEqual(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Reward currency is not one of the emulator wallets",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the write fails", async () => {
|
||||||
|
mocks.insertValues.mockReturnValueOnce({
|
||||||
|
onDuplicateKeyUpdate: vi.fn(async () => {
|
||||||
|
throw new Error("db down");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
day: "2",
|
||||||
|
currency: "diamonds",
|
||||||
|
amount: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.daily_reward_upsert_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day could not be saved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the update fails", async () => {
|
||||||
|
mocks.updateWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
id: "3",
|
||||||
|
day: "2",
|
||||||
|
currency: "points",
|
||||||
|
amount: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day could not be saved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
upsertDailyReward({ day: "1", currency: "credits", amount: "1" }),
|
||||||
|
).rejects.toThrow("redirect:/admin");
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteDailyReward", () => {
|
||||||
|
it("deletes a reward by id", async () => {
|
||||||
|
const result = await deleteDailyReward({ id: "5" });
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
PERMS.DAILY_REWARDS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/daily-rewards");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing or invalid id", async () => {
|
||||||
|
expect(await deleteDailyReward({ id: "" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing reward id",
|
||||||
|
});
|
||||||
|
expect(await deleteDailyReward({ id: "abc" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing reward id",
|
||||||
|
});
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a payload with no id field", async () => {
|
||||||
|
expect(await deleteDailyReward({} as never)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing reward id",
|
||||||
|
});
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the delete fails", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await deleteDailyReward({ id: "8" });
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.daily_reward_delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ rewardId: "8" },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day could not be deleted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setDailyRewardEnabled", () => {
|
||||||
|
it("enables the reward feature", async () => {
|
||||||
|
const result = await setDailyRewardEnabled({ enabled: true });
|
||||||
|
|
||||||
|
expect(mocks.siteSettingsUpdate).toHaveBeenCalledWith(
|
||||||
|
"daily_reward_enabled",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the reward feature", async () => {
|
||||||
|
await setDailyRewardEnabled({ enabled: false });
|
||||||
|
|
||||||
|
expect(mocks.siteSettingsUpdate).toHaveBeenCalledWith(
|
||||||
|
"daily_reward_enabled",
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the setting write fails", async () => {
|
||||||
|
mocks.siteSettingsUpdate.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await setDailyRewardEnabled({ enabled: true });
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.daily_reward_toggle_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward setting could not be saved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
|
import { db, WebsiteDailyRewards } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
type ActionResult,
|
||||||
|
actionError,
|
||||||
|
actionOk,
|
||||||
|
} from "@/lib/safe-action-shared";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const VALID_CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
|
||||||
|
|
||||||
|
async function normalizeReward(input: {
|
||||||
|
day: string;
|
||||||
|
currency: string;
|
||||||
|
amount: string;
|
||||||
|
}): Promise<
|
||||||
|
| { ok: true; day: number; currency: string; amount: number }
|
||||||
|
| { ok: false; message: string }
|
||||||
|
> {
|
||||||
|
const day = Number.parseInt(input.day, 10);
|
||||||
|
const amount = Number.parseInt(input.amount, 10);
|
||||||
|
const currency = String(input.currency ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!(day >= 1 && day <= 365))
|
||||||
|
return { ok: false, message: "Reward day must be between 1 and 365" };
|
||||||
|
if (!(amount > 0))
|
||||||
|
return { ok: false, message: "Reward amount must be positive" };
|
||||||
|
if (!(VALID_CURRENCIES as readonly string[]).includes(currency))
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Reward currency is not one of the emulator wallets",
|
||||||
|
};
|
||||||
|
return { ok: true, day, currency, amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertDailyReward(input: {
|
||||||
|
id?: string;
|
||||||
|
day: string;
|
||||||
|
currency: string;
|
||||||
|
amount: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
|
||||||
|
|
||||||
|
const normalized = await normalizeReward(input);
|
||||||
|
if (!normalized.ok) return actionError(normalized.message);
|
||||||
|
|
||||||
|
const id = input.id ? positiveBigInt(String(input.id).trim()) : null;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
await db
|
||||||
|
.update(WebsiteDailyRewards)
|
||||||
|
.set({
|
||||||
|
day: normalized.day,
|
||||||
|
currency: normalized.currency,
|
||||||
|
amount: normalized.amount,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteDailyRewards.id, Number(id)));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.insert(WebsiteDailyRewards)
|
||||||
|
.values({
|
||||||
|
day: normalized.day,
|
||||||
|
currency: normalized.currency,
|
||||||
|
amount: normalized.amount,
|
||||||
|
})
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
currency: normalized.currency,
|
||||||
|
amount: normalized.amount,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/daily-rewards");
|
||||||
|
revalidatePath("/me");
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.daily_reward_upsert_failed", error);
|
||||||
|
return actionError("Reward day could not be saved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDailyReward(input: {
|
||||||
|
id: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
|
||||||
|
|
||||||
|
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||||
|
if (!id) return actionError("Missing reward id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(WebsiteDailyRewards)
|
||||||
|
.where(eq(WebsiteDailyRewards.id, Number(id)));
|
||||||
|
revalidatePath("/admin/daily-rewards");
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.daily_reward_delete_failed", error, {
|
||||||
|
rewardId: String(id),
|
||||||
|
});
|
||||||
|
return actionError("Reward day could not be deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDailyRewardEnabled(input: {
|
||||||
|
enabled: boolean;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await siteSettings.update(
|
||||||
|
"daily_reward_enabled",
|
||||||
|
input.enabled ? "1" : "0",
|
||||||
|
);
|
||||||
|
revalidatePath("/admin/daily-rewards");
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.daily_reward_toggle_failed", error);
|
||||||
|
return actionError("Reward setting could not be saved");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
insertValues: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { PAGES_EDIT: "admin.pages.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createEmailTemplate,
|
||||||
|
deleteEmailTemplate,
|
||||||
|
updateEmailTemplate,
|
||||||
|
} from "./admin-email-templates";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createEmailTemplate", () => {
|
||||||
|
it("creates a template with trimmed fields and active flag", async () => {
|
||||||
|
await createEmailTemplate(
|
||||||
|
form({
|
||||||
|
name: " Welcome ",
|
||||||
|
subject: " Hi ",
|
||||||
|
body: "Hello",
|
||||||
|
variables: "{{username}}",
|
||||||
|
isActive: "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.PAGES_EDIT);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
name: "Welcome",
|
||||||
|
subject: "Hi",
|
||||||
|
body: "Hello",
|
||||||
|
variables: "{{username}}",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/email-templates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null variables and inactive when the variable field is blank", async () => {
|
||||||
|
await createEmailTemplate(
|
||||||
|
form({ name: "Welcome", subject: "Hi", body: "Hello" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
name: "Welcome",
|
||||||
|
subject: "Hi",
|
||||||
|
body: "Hello",
|
||||||
|
variables: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when every field is absent", async () => {
|
||||||
|
await createEmailTemplate(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when a required field is missing", async () => {
|
||||||
|
await createEmailTemplate(form({ name: "", subject: "Hi", body: "Hello" }));
|
||||||
|
await createEmailTemplate(form({ name: "Hi", subject: "", body: "Hello" }));
|
||||||
|
await createEmailTemplate(form({ name: "Hi", subject: "Hi", body: "" }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmailTemplate", () => {
|
||||||
|
it("updates the subject, body, variables and active flag", async () => {
|
||||||
|
await updateEmailTemplate(
|
||||||
|
form({
|
||||||
|
id: "42",
|
||||||
|
subject: " Updated ",
|
||||||
|
body: "Body",
|
||||||
|
variables: "{{x}}",
|
||||||
|
isActive: "1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith({
|
||||||
|
subject: "Updated",
|
||||||
|
body: "Body",
|
||||||
|
variables: "{{x}}",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(mocks.updateWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/email-templates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank or invalid id", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "", subject: "a", body: "b" }));
|
||||||
|
await updateEmailTemplate(
|
||||||
|
form({ id: "not-a-number", subject: "a", body: "b" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when subject or body is missing", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "1", subject: "", body: "b" }));
|
||||||
|
await updateEmailTemplate(form({ id: "1", subject: "a", body: "" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when every field is absent", async () => {
|
||||||
|
await updateEmailTemplate(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the id is present but subject and body are absent", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "1" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null variables and inactive flag when omitted", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "3", subject: "s", body: "b" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith({
|
||||||
|
subject: "s",
|
||||||
|
body: "b",
|
||||||
|
variables: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteEmailTemplate", () => {
|
||||||
|
it("deletes the template by id", async () => {
|
||||||
|
await deleteEmailTemplate(form({ id: "7" }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.PAGES_EDIT);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/email-templates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an invalid id", async () => {
|
||||||
|
await deleteEmailTemplate(form({ id: "0" }));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(deleteEmailTemplate(form({ id: "1" }))).rejects.toThrow(
|
||||||
|
"redirect:/admin",
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const onDuplicateKeyUpdate = vi.fn(async () => [{}]);
|
||||||
|
const insertValues = vi.fn(() => ({ onDuplicateKeyUpdate }));
|
||||||
|
return {
|
||||||
|
insertValues,
|
||||||
|
onDuplicateKeyUpdate,
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { updateEmulatorSetting, updateEmulatorText } from "./admin-emulator";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.insertValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: mocks.onDuplicateKeyUpdate,
|
||||||
|
});
|
||||||
|
mocks.onDuplicateKeyUpdate.mockResolvedValue([{}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmulatorSetting", () => {
|
||||||
|
it("upserts a setting and revalidates", async () => {
|
||||||
|
await updateEmulatorSetting(
|
||||||
|
form({ key: " welcome_message ", value: "Hi" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "welcome_message",
|
||||||
|
value: "Hi",
|
||||||
|
});
|
||||||
|
expect(mocks.onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
set: { value: "Hi" },
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/emulator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await updateEmulatorSetting(form({ key: " ", value: "Hi" }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when key and value fields are absent", async () => {
|
||||||
|
await updateEmulatorSetting(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the key to 100 and the value to 512 characters", async () => {
|
||||||
|
await updateEmulatorSetting(
|
||||||
|
form({ key: "k".repeat(120), value: "v".repeat(600) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "k".repeat(100),
|
||||||
|
value: "v".repeat(512),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateEmulatorSetting(form({ key: "a", value: "b" })),
|
||||||
|
).rejects.toThrow("redirect:/admin");
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmulatorText", () => {
|
||||||
|
it("upserts a text and revalidates", async () => {
|
||||||
|
await updateEmulatorText(form({ key: " text.key ", value: "Hello" }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "text.key",
|
||||||
|
value: "Hello",
|
||||||
|
});
|
||||||
|
expect(mocks.onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
set: { value: "Hello" },
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/emulator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await updateEmulatorText(form({ key: "", value: "Hello" }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when key and value fields are absent", async () => {
|
||||||
|
await updateEmulatorText(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the value to 4096 characters", async () => {
|
||||||
|
await updateEmulatorText(form({ key: "k", value: "v".repeat(5000) }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "k",
|
||||||
|
value: "v".repeat(4096),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
import { disbandGuild } from "./admin-guilds";
|
import { disbandGuild } from "./admin-guilds";
|
||||||
|
|
||||||
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
|
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
|
||||||
@@ -42,10 +43,6 @@ vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
|||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
|
||||||
get: (key: string) => data[key] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
@@ -79,13 +76,13 @@ describe("disbandGuild", () => {
|
|||||||
await fn(tx);
|
await fn(tx);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
await disbandGuild(fakeForm({ id: "1" }) as FormData);
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
await disbandGuild(fakeForm({ id: "0" }) as FormData);
|
||||||
expect(selectLimit).not.toHaveBeenCalled();
|
expect(selectLimit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Items,
|
Items,
|
||||||
Rooms,
|
Rooms,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
|||||||
targetId: id,
|
targetId: id,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/guilds");
|
revalidatePath("/admin/guilds");
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild disbanded");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGuild(formData: FormData): Promise<void> {
|
export async function updateGuild(formData: FormData): Promise<void> {
|
||||||
@@ -151,4 +153,5 @@ export async function updateGuild(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/guilds");
|
revalidatePath("/admin/guilds");
|
||||||
revalidatePath(`/admin/guilds/${id}`);
|
revalidatePath(`/admin/guilds/${id}`);
|
||||||
|
void purgeEdgeCache([EDGE_CACHE_TAGS.public], "guild updated");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
canAccess: vi.fn(() => true),
|
||||||
|
getApiAdminContext: vi.fn(),
|
||||||
|
logAuthorizationEvent: vi.fn(),
|
||||||
|
logAudit: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
extractClientIpAsync: vi.fn(async () => "127.0.0.1"),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
reportError: vi.fn(),
|
||||||
|
selectRows: [] as unknown[],
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
insertValues: vi.fn(async () => [{}]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 2 }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||||
|
logAuthorizationEvent: mocks.logAuthorizationEvent,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: {
|
||||||
|
USERS_BAN: "admin.users.ban",
|
||||||
|
TICKETS_EDIT: "admin.tickets.edit",
|
||||||
|
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
||||||
|
},
|
||||||
|
canAccess: mocks.canAccess,
|
||||||
|
getApiAdminContext: mocks.getApiAdminContext,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mocks.rateLimit }));
|
||||||
|
vi.mock("@/lib/report-error", () => ({ reportError: mocks.reportError }));
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({
|
||||||
|
extractClientIpAsync: mocks.extractClientIpAsync,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.logAudit }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => mocks.selectRows);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
|
||||||
|
fn({
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
closeHelpCenterTicket,
|
||||||
|
liftBanFromHelpTicket,
|
||||||
|
reopenHelpCenterTicket,
|
||||||
|
replyHelpCenterTicket,
|
||||||
|
} from "./admin-help-tickets";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.canAccess.mockReturnValue(true);
|
||||||
|
mocks.getApiAdminContext.mockResolvedValue({
|
||||||
|
session: { user: { id: 7, rank: 7, name: "staff" } },
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
mocks.selectRows = [];
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.insertValues.mockResolvedValue([{}]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function ticketId(id: string | number | bigint) {
|
||||||
|
return { ticketId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("liftBanFromHelpTicket", () => {
|
||||||
|
it("removes bans, reopens state and returns the removed count", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: true, title: "Appeal" }];
|
||||||
|
|
||||||
|
const result = await liftBanFromHelpTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: { removed: 2, userId: 3 } });
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 7,
|
||||||
|
action: "unban_via_help_ticket",
|
||||||
|
target: "User",
|
||||||
|
targetId: 3,
|
||||||
|
after: { ticketId: "5", removedBans: 2, title: "Appeal" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ open: false, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users/show/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips closing an already closed ticket", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: false, title: "Appeal" }];
|
||||||
|
|
||||||
|
await liftBanFromHelpTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a ticket without a requester", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: null, open: true, title: "x" }];
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket has no requester to unban",
|
||||||
|
});
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the removed count to zero when the driver omits affectedRows", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: true, title: "x" }];
|
||||||
|
mocks.deleteWhere.mockResolvedValueOnce([{}]);
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: { removed: 0, userId: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms numeric and bigint ticket ids", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: true, title: "x" }];
|
||||||
|
|
||||||
|
await liftBanFromHelpTicket(ticketId(5));
|
||||||
|
await liftBanFromHelpTicket(ticketId(5n));
|
||||||
|
|
||||||
|
expect(mocks.selectRows).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated callers", async () => {
|
||||||
|
mocks.getApiAdminContext.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects callers without the ban permission and logs an authorization event", async () => {
|
||||||
|
mocks.canAccess.mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
expect(mocks.logAuthorizationEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "permission.denied",
|
||||||
|
userId: 7,
|
||||||
|
permission: PERMS.USERS_BAN,
|
||||||
|
source: "adminAction",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed input before the handler runs", async () => {
|
||||||
|
const result = await liftBanFromHelpTicket({ ticketId: true } as never);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result).toMatchObject({ error: "Validation failed" });
|
||||||
|
expect(mocks.logAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replyHelpCenterTicket", () => {
|
||||||
|
it("inserts a trimmed reply and bumps the ticket inside a transaction", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
const result = await replyHelpCenterTicket({
|
||||||
|
ticketId: "5",
|
||||||
|
content: " thanks ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ticketId: 5n,
|
||||||
|
userId: 7,
|
||||||
|
content: "thanks",
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "help_center_ticket_reply",
|
||||||
|
target: "WebsiteHelpCenterTickets",
|
||||||
|
targetId: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a moderator permission from the any-of list", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
mocks.canAccess.mockImplementation(
|
||||||
|
(_perms: unknown, slug: string) => slug === PERMS.MOD_TICKETS_EDIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await replyHelpCenterTicket({
|
||||||
|
ticketId: "5",
|
||||||
|
content: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(mocks.canAccess).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
PERMS.TICKETS_EDIT,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(mocks.canAccess).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
PERMS.MOD_TICKETS_EDIT,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await replyHelpCenterTicket({ ticketId: "5", content: "hi" }),
|
||||||
|
).toEqual({ ok: false, error: "Ticket not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty reply", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
const result = await replyHelpCenterTicket({ ticketId: "5", content: "" });
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result).toMatchObject({ error: "Validation failed" });
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated callers", async () => {
|
||||||
|
mocks.getApiAdminContext.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await replyHelpCenterTicket({ ticketId: "5", content: "hi" }),
|
||||||
|
).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closeHelpCenterTicket", () => {
|
||||||
|
it("closes an open ticket and records the audit trail", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
const result = await closeHelpCenterTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ open: false, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "help_center_ticket_close",
|
||||||
|
before: { open: true },
|
||||||
|
after: { open: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(await closeHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an already closed ticket", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: false }];
|
||||||
|
|
||||||
|
expect(await closeHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket is already closed",
|
||||||
|
});
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reopenHelpCenterTicket", () => {
|
||||||
|
it("reopens a closed ticket and records the audit trail", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: false }];
|
||||||
|
|
||||||
|
const result = await reopenHelpCenterTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ open: true, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "help_center_ticket_reopen",
|
||||||
|
before: { open: false },
|
||||||
|
after: { open: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(await reopenHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an already open ticket", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
expect(await reopenHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket is already open",
|
||||||
|
});
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects callers without the ticket permission", async () => {
|
||||||
|
mocks.canAccess.mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(await reopenHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,9 +60,7 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
||||||
const removed = Number(
|
const removed = Number(result[0]?.affectedRows ?? 0);
|
||||||
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (ticket.open) {
|
if (ticket.open) {
|
||||||
@@ -72,7 +70,7 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({
|
await logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "unban_via_help_ticket",
|
action: "unban_via_help_ticket",
|
||||||
target: "User",
|
target: "User",
|
||||||
@@ -125,7 +123,7 @@ export const replyHelpCenterTicket = adminAction(
|
|||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
});
|
});
|
||||||
|
|
||||||
logAudit({
|
await logAudit({
|
||||||
userId: staffId,
|
userId: staffId,
|
||||||
action: "help_center_ticket_reply",
|
action: "help_center_ticket_reply",
|
||||||
target: "WebsiteHelpCenterTickets",
|
target: "WebsiteHelpCenterTickets",
|
||||||
@@ -159,7 +157,7 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
.set({ open: false, updatedAt: now })
|
.set({ open: false, updatedAt: now })
|
||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
logAudit({
|
await logAudit({
|
||||||
userId: Number(ctx.session.user.id),
|
userId: Number(ctx.session.user.id),
|
||||||
action: "help_center_ticket_close",
|
action: "help_center_ticket_close",
|
||||||
target: "WebsiteHelpCenterTickets",
|
target: "WebsiteHelpCenterTickets",
|
||||||
@@ -195,7 +193,7 @@ export const reopenHelpCenterTicket = adminAction(
|
|||||||
.set({ open: true, updatedAt: now })
|
.set({ open: true, updatedAt: now })
|
||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
|
||||||
logAudit({
|
await logAudit({
|
||||||
userId: Number(ctx.session.user.id),
|
userId: Number(ctx.session.user.id),
|
||||||
action: "help_center_ticket_reopen",
|
action: "help_center_ticket_reopen",
|
||||||
target: "WebsiteHelpCenterTickets",
|
target: "WebsiteHelpCenterTickets",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
import {
|
import {
|
||||||
createHelpQuestion,
|
createHelpQuestion,
|
||||||
deleteHelpQuestion,
|
deleteHelpQuestion,
|
||||||
@@ -29,10 +31,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string | null>) => ({
|
|
||||||
get: (key: string) => (key in data ? data[key] : null),
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
@@ -47,7 +45,7 @@ describe("createHelpQuestion", () => {
|
|||||||
fakeForm({
|
fakeForm({
|
||||||
name: "FAQ",
|
name: "FAQ",
|
||||||
content: "<p>Answer</p>",
|
content: "<p>Answer</p>",
|
||||||
}) as unknown as FormData,
|
}) as FormData,
|
||||||
);
|
);
|
||||||
expect(insertValues).toHaveBeenCalled();
|
expect(insertValues).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
@@ -61,7 +59,7 @@ describe("updateHelpQuestion", () => {
|
|||||||
id: "42",
|
id: "42",
|
||||||
name: "Updated",
|
name: "Updated",
|
||||||
content: "New",
|
content: "New",
|
||||||
}) as unknown as FormData,
|
}) as FormData,
|
||||||
);
|
);
|
||||||
expect(updateWhere).toHaveBeenCalled();
|
expect(updateWhere).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
@@ -70,7 +68,7 @@ describe("updateHelpQuestion", () => {
|
|||||||
|
|
||||||
describe("deleteHelpQuestion", () => {
|
describe("deleteHelpQuestion", () => {
|
||||||
it("deletes and redirects", async () => {
|
it("deletes and redirects", async () => {
|
||||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
@@ -34,7 +33,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
const [result] = await db.insert(WebsiteHelpCenterCategories).values({
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
position: parsePosition(formData.get("position")),
|
position: parsePosition(formData.get("position")),
|
||||||
@@ -44,7 +43,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
buttonColor,
|
buttonColor,
|
||||||
buttonBorderColor,
|
buttonBorderColor,
|
||||||
smallBox: formData.get("smallBox") != null,
|
smallBox: formData.get("smallBox") != null,
|
||||||
})) as unknown as [ResultSetHeader];
|
});
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
action: "help_create",
|
action: "help_create",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
rows: [] as unknown[],
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: state.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_VIEW: "admin.settings.view" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: createFakeDb(() => state.rows),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { exportPermissions } from "./admin-housekeeping";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.rows = [];
|
||||||
|
state.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportPermissions", () => {
|
||||||
|
it("returns the housekeeping permissions as pretty JSON", async () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
permission: "admin.users.edit",
|
||||||
|
minRank: 7,
|
||||||
|
description: "Edit users",
|
||||||
|
groupName: "Users",
|
||||||
|
dependsOn: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.rows = rows;
|
||||||
|
|
||||||
|
const result = await exportPermissions();
|
||||||
|
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_VIEW);
|
||||||
|
expect(result).toBe(JSON.stringify(rows, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty JSON array when there are no permissions", async () => {
|
||||||
|
await expect(exportPermissions()).resolves.toBe("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("redirect:/"));
|
||||||
|
|
||||||
|
await expect(exportPermissions()).rejects.toThrow("redirect:/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
import {
|
import {
|
||||||
addBlacklist,
|
addBlacklist,
|
||||||
addWhitelist,
|
addWhitelist,
|
||||||
@@ -30,10 +31,6 @@ vi.mock("@/lib/db", () => ({
|
|||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
|
||||||
get: (key: string) => data[key] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
@@ -43,9 +40,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("addWhitelist", () => {
|
describe("addWhitelist", () => {
|
||||||
it("creates whitelist entry", async () => {
|
it("creates whitelist entry", async () => {
|
||||||
await addWhitelist(
|
await addWhitelist(fakeForm({ ipAddress: "192.168.1.1" }) as FormData);
|
||||||
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(insertValues).toHaveBeenCalledWith({
|
expect(insertValues).toHaveBeenCalledWith({
|
||||||
ipAddress: "192.168.1.1",
|
ipAddress: "192.168.1.1",
|
||||||
asn: null,
|
asn: null,
|
||||||
@@ -55,23 +50,21 @@ describe("addWhitelist", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when ip is empty", async () => {
|
it("returns early when ip is empty", async () => {
|
||||||
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
await addWhitelist(fakeForm({ ipAddress: "" }) as FormData);
|
||||||
expect(insertValues).not.toHaveBeenCalled();
|
expect(insertValues).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteWhitelist", () => {
|
describe("deleteWhitelist", () => {
|
||||||
it("deletes whitelist entry", async () => {
|
it("deletes whitelist entry", async () => {
|
||||||
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
await deleteWhitelist(fakeForm({ id: "42" }) as FormData);
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("addBlacklist", () => {
|
describe("addBlacklist", () => {
|
||||||
it("creates blacklist entry", async () => {
|
it("creates blacklist entry", async () => {
|
||||||
await addBlacklist(
|
await addBlacklist(fakeForm({ ipAddress: "203.0.113.1" }) as FormData);
|
||||||
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(insertValues).toHaveBeenCalledWith({
|
expect(insertValues).toHaveBeenCalledWith({
|
||||||
ipAddress: "203.0.113.1",
|
ipAddress: "203.0.113.1",
|
||||||
asn: null,
|
asn: null,
|
||||||
@@ -82,7 +75,7 @@ describe("addBlacklist", () => {
|
|||||||
|
|
||||||
describe("deleteBlacklist", () => {
|
describe("deleteBlacklist", () => {
|
||||||
it("deletes blacklist entry", async () => {
|
it("deletes blacklist entry", async () => {
|
||||||
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
await deleteBlacklist(fakeForm({ id: "99" }) as FormData);
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
expect(deleteWhere).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||