Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7ef6aae7e | ||
|
|
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 | ||
|
|
644d53ca16 | ||
|
|
67430e7e9d | ||
|
|
6e0ffff94b | ||
|
|
6ea0ec7d99 | ||
|
|
1df1ffc3e9 | ||
|
|
1bbd809b43 | ||
|
|
1a9e1e791a | ||
|
|
349188af10 | ||
|
|
d4e4711a77 | ||
|
|
b688760309 | ||
|
|
bd977da3a5 | ||
|
|
a605807c69 | ||
|
|
636fde523f | ||
|
|
f2b64bc83a | ||
|
|
7840f44e5e | ||
|
|
9ef7c6393d | ||
|
|
b234a51aea | ||
|
|
33a760ab6b | ||
|
|
043c763484 | ||
|
|
46f7ad6571 | ||
|
|
7867bf6b72 | ||
|
|
60e49c1ec9 | ||
|
|
275a574203 | ||
|
|
9a2d73a6f6 | ||
|
|
fe34d4ac93 | ||
|
|
8aefb415b6 | ||
|
|
c5c9941768 | ||
|
|
dd7613850e | ||
|
|
f7b9b55700 | ||
|
|
8abfe352ef | ||
|
|
52f6d1491f | ||
|
|
c977fe95ba | ||
|
|
afea80708c | ||
|
|
13665e0c3c | ||
|
|
76e46d295c | ||
|
|
1eee6661f9 | ||
|
|
8f55ff2d17 | ||
|
|
a4266f297c | ||
|
|
a320e47c29 | ||
|
|
422be3ce2d | ||
|
|
966a925614 | ||
|
|
d78744efc1 | ||
|
|
7a2c75701e |
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_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
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 ---
|
||||
NODE_ENV=production
|
||||
@@ -36,10 +42,42 @@ BADGE_URL=/swf/c_images/album1584
|
||||
# --- SECURITY & HASHING ---
|
||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
CONVERT_PASSWORDS=true
|
||||
# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
|
||||
# Bcrypt cost factor for new password hashes.
|
||||
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 ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
|
||||
@@ -2,25 +2,21 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main, master, "codex/**"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /opt/ms-playwright
|
||||
|
||||
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:
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
@@ -28,6 +24,9 @@ jobs:
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
- name: Deploy port-selection tests
|
||||
run: bash scripts/ci-deploy-ports.test.sh
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -43,7 +42,20 @@ jobs:
|
||||
- name: 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:
|
||||
SKIP_ENV_VALIDATION: 1
|
||||
NODE_ENV: test
|
||||
@@ -58,7 +70,35 @@ jobs:
|
||||
time pnpm test:coverage --maxWorkers=4
|
||||
fi
|
||||
|
||||
# Compare against reviewed Linux references; updates are explicit.
|
||||
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
|
||||
run: pnpm test:integration
|
||||
|
||||
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
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
@@ -76,18 +116,42 @@ jobs:
|
||||
test-results/ui/
|
||||
retention-days: 14
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Docker build & deploy
|
||||
# Draait op de host (self-hosted) zodat Docker
|
||||
# toegang heeft tot de daemon en volumes.
|
||||
# ─────────────────────────────────────────────
|
||||
deploy:
|
||||
needs: check
|
||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||
runs-on: self-hosted
|
||||
preflight:
|
||||
needs: [tests-unit, tests-integration, tests-ui]
|
||||
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
- name: Build and verify isolated candidate
|
||||
shell: bash
|
||||
run: bash scripts/ci-preflight.sh
|
||||
|
||||
- name: Upload preflight news browser results
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: preflight-news-browser-results
|
||||
path: |
|
||||
test-results/news-real/
|
||||
playwright-report/news-real/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
deploy:
|
||||
needs: [tests-unit, tests-integration, tests-ui]
|
||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
@@ -98,6 +162,17 @@ jobs:
|
||||
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci-deploy.sh
|
||||
|
||||
- name: Upload isolated news browser results
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: news-browser-results
|
||||
path: |
|
||||
test-results/news-real/
|
||||
playwright-report/news-real/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload JavaScript size report
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
@@ -106,26 +181,3 @@ jobs:
|
||||
path: build-reports/
|
||||
if-no-files-found: warn
|
||||
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-env.d.ts
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.tsbuildinfo
|
||||
src/generated/
|
||||
# Runtime avatar/badge imaging disk cache
|
||||
@@ -52,3 +54,7 @@ blob-report/
|
||||
.env.install.*
|
||||
build-reports/
|
||||
!/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/
|
||||
@@ -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,38 +1,50 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
||||
FROM node:26.8.2-alpine AS migrations
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
WORKDIR /app
|
||||
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
|
||||
# re-downloaded on every build.
|
||||
|
||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||
# 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 \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g pnpm@11.25.0
|
||||
# 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
|
||||
# downloaded dependency store is shared and reused instead of being copied into
|
||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
||||
# 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).
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g pnpm@12.10.1
|
||||
|
||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||
|
||||
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 \
|
||||
pnpm fetch --ignore-scripts
|
||||
# Install offline from the cache-mounted store; the store itself stays in the
|
||||
# build cache between builds.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
|
||||
FROM migrations AS builder
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
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
|
||||
# changed parts.
|
||||
|
||||
# The build runs the webpack builder (see the `build` script in package.json).
|
||||
# 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 \
|
||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||
@@ -40,7 +52,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
||||
pnpm run build \
|
||||
&& 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"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
WORKDIR /app
|
||||
@@ -60,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
|
||||
USER nextjs
|
||||
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 \
|
||||
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", "--"]
|
||||
|
||||
@@ -10,14 +10,21 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
|
||||
| Component | Version | Notes |
|
||||
| --------------- | -------------- | ---------------------------------------- |
|
||||
| Node.js | 26.8.1 | Current release pinned in `.nvmrc` |
|
||||
| pnpm | >= 11.25.0 | Recommended package manager |
|
||||
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
|
||||
| pnpm | 12.10.1 | Pinned via `packageManager` |
|
||||
| npm | >= 11.x | Supported alternative |
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| 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
|
||||
@@ -102,6 +109,10 @@ pnpm build && pnpm start # or: npm run build && npm start
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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,
|
||||
it stops before replacing the current container; run the same command after CI
|
||||
succeeds. Existing application rollback remains available on a failed cutover;
|
||||
and at the saved public URL. Existing application rollback remains available on a failed cutover;
|
||||
database migrations are not reversed.
|
||||
|
||||
`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
|
||||
profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work.
|
||||
|
||||
To publish from Gitea:
|
||||
|
||||
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).
|
||||
Container publication is disabled. CI builds, checks and deploys the CMS, but it does not log in to a registry or upload container images.
|
||||
|
||||
### 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)
|
||||
|
||||
- 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`
|
||||
|
||||
@@ -682,7 +631,11 @@ The container runs `restart: unless-stopped` and ships a healthcheck that probes
|
||||
curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l'
|
||||
```
|
||||
|
||||
A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which takes the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`).
|
||||
A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which used to take the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`).
|
||||
|
||||
### Automatic upstream fallback
|
||||
|
||||
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
|
||||
|
||||
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
|
||||
|
||||
@@ -743,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.
|
||||
|
||||
#### 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
|
||||
pnpm build
|
||||
pm2 start pnpm --name "next" -- start
|
||||
pm2 save
|
||||
Two layers work together to keep the origin alive during misuse, floods, and
|
||||
repeat-offender attacks:
|
||||
|
||||
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
|
||||
git pull
|
||||
pnpm install
|
||||
pnpm build
|
||||
pm2 restart next
|
||||
npx next build # ✗ refused before it allocates anything
|
||||
```
|
||||
|
||||
`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
|
||||
@@ -769,10 +1050,22 @@ pm2 restart next
|
||||
| Command | Description |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| `pnpm dev` | Start development server (hot reload) |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm build` | Production build (memory-capped) |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `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:status` | Show migration status |
|
||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
||||
@@ -782,11 +1075,16 @@ pm2 restart next
|
||||
| `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 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 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.
|
||||
>
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -911,7 +1209,7 @@ The CMS automatically translates furniture names and descriptions to **13 langua
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm typecheck # Type check all files
|
||||
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
|
||||
```
|
||||
|
||||
@@ -928,3 +1226,5 @@ pnpm biome:check # Lint and format
|
||||
## License
|
||||
|
||||
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
||||
test line
|
||||
// CI monitor test 1791828076
|
||||
@@ -28,6 +28,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.test.ts", "**/*.test.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
|
||||
@@ -3,7 +3,7 @@ set -Eeuo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
case "${1:-help}" in
|
||||
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
||||
update) shift; [[ $# = 0 ]] || { echo "Usage: bash cms update" >&2; exit 1; }; exec bash "$DIR/scripts/docker-update.sh" ;;
|
||||
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using the saved installation settings' ;;
|
||||
update) shift; exec bash "$DIR/scripts/docker-update.sh" "$@" ;;
|
||||
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' ;;
|
||||
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Handmatige release uitvoeren.
|
||||
#
|
||||
# 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
|
||||
|
||||
deploy_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$deploy_dir"
|
||||
|
||||
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
|
||||
sed -n '2,20p' "$deploy_dir/deploy.sh" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
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,9 @@
|
||||
# Opt in through COMPOSE_FILE in the clone's .env; see docs/operations/docker-installation.md.
|
||||
# The base service uses Linux host networking: bind the process, do not add ports.
|
||||
services:
|
||||
cms:
|
||||
environment:
|
||||
HOSTNAME: 127.0.0.1
|
||||
PORT: "3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
@@ -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,43 @@
|
||||
# Include at nginx http scope (for example /etc/nginx/conf.d/cms.conf).
|
||||
# Mode: browsers connect directly to this nginx, on the CMS Docker host.
|
||||
# Replace EVERY hotel.example and both certificate paths before nginx -t.
|
||||
# Requires ngx_http_realip_module: $realip_remote_addr keeps the socket peer
|
||||
# even when an unrelated global real_ip configuration rewrites $remote_addr.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name hotel.example;
|
||||
if ($host != hotel.example) { return 444; }
|
||||
return 308 https://hotel.example$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name hotel.example;
|
||||
if ($host != hotel.example) { return 444; }
|
||||
ssl_certificate /etc/letsencrypt/live/hotel.example/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hotel.example/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# Studio's authenticated attachment endpoints accept at most 52 MiB.
|
||||
# This ingress allowance includes multipart overhead; application caps remain.
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host hotel.example;
|
||||
proxy_set_header X-Forwarded-Host hotel.example;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $realip_remote_addr;
|
||||
proxy_set_header X-Real-IP $realip_remote_addr;
|
||||
proxy_set_header CF-Connecting-IP "";
|
||||
proxy_set_header X-Real-Client-IP "";
|
||||
proxy_set_header Forwarded "";
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_cache off;
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@
|
||||
# ALTERNATIVE to nginx-direct.example.conf; never enable both for the same host.
|
||||
# Remote edge -> TLS -> this nginx on the CMS host -> loopback CMS.
|
||||
# Replace hotel.example/certificate paths and BOTH occurrences of 203.0.113.10/32.
|
||||
# The example peer is reserved documentation space, so it permits no real edge.
|
||||
# Requires ngx_http_realip_module. The remote edge MUST overwrite X-Forwarded-For
|
||||
# with one verified client IP and enforce the public HTTPS/Host configuration.
|
||||
geo $realip_remote_addr $cms_trusted_edge {
|
||||
default 0;
|
||||
203.0.113.10/32 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name hotel.example;
|
||||
if ($host != hotel.example) { return 444; }
|
||||
if ($cms_trusted_edge = 0) { return 403; }
|
||||
ssl_certificate /etc/letsencrypt/live/hotel.example/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hotel.example/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
client_max_body_size 64m;
|
||||
|
||||
set_real_ip_from 203.0.113.10/32;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host hotel.example;
|
||||
proxy_set_header X-Forwarded-Host hotel.example;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header CF-Connecting-IP "";
|
||||
proxy_set_header X-Real-Client-IP "";
|
||||
proxy_set_header Forwarded "";
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_cache off;
|
||||
}
|
||||
}
|
||||
|
||||
# Cloudflare variant: use this same restricted-edge mode, NOT the direct mode.
|
||||
# Replace the documentation peer in BOTH geo/set_real_ip_from lists with the
|
||||
# current verified Cloudflare IPv4 AND IPv6 CIDRs, then change real_ip_header to
|
||||
# CF-Connecting-IP. Use Full (strict) TLS and review authenticated origin pulls.
|
||||
# CF-Connecting-IP is still removed before forwarding to the CMS: nginx sends
|
||||
# only its normalized, trusted result in X-Forwarded-For and X-Real-IP.
|
||||
@@ -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,37 +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:
|
||||
cms:
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
network: host
|
||||
<<: *cms
|
||||
container_name: epicnext-cms
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
volumes:
|
||||
- ./public/nitro-assets:/app/public/nitro-assets
|
||||
- ./public/swf:/app/public/swf
|
||||
- ./storage:/app/storage
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
- PORT=3002
|
||||
|
||||
# ── Resource limits aangepast voor 24 GB RAM ──
|
||||
# Verwijderd: mem_limit, memswap_limit en cpus. Next.js mag onbeperkt presteren.
|
||||
pids_limit: 1024 # Verhoogd van 512 zodat Node.js/Next.js oneindig veel async requests aankan
|
||||
cms-green:
|
||||
<<: *cms
|
||||
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:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
@@ -39,71 +63,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- 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
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
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
|
||||
start_period: 30s
|
||||
@@ -0,0 +1,93 @@
|
||||
# Backup and isolated restore drill
|
||||
|
||||
This opt-in operator tool creates one MariaDB logical dump and copies explicitly selected persistent files. It never runs during install, update or CI deployment. The only restore operation is a **disposable drill**: there is no production restore command, database target, destination directory or overwrite option.
|
||||
|
||||
## Scope and prerequisites
|
||||
|
||||
Use the existing project Node toolchain and Docker CLI/Engine on a Linux host. Creation uses a short-lived `mariadb:11.4.5` client on the Docker host network, so `127.0.0.1` means that host. Use a local Docker Engine/context with the same filesystem; remote Docker daemons and Docker Desktop are not supported for creation. The image must already be available or downloadable through the operator's normal image policy. No packages are installed by this tool.
|
||||
|
||||
Choose the single application database explicitly. Its tables must use InnoDB; empty databases, system schemas and unsupported engines are rejected. The backup account needs access to every application table, view, trigger, routine and event being exported. Account/grant provisioning belongs to the operator; the tool does not change privileges. Restore compatibility is checked with the pinned MariaDB image, not guaranteed across arbitrary server versions, plugins, collations or external schema dependencies.
|
||||
|
||||
Before starting, pause **every database/file writer** and schema changer for the whole creation command: CMS requests that write, workers, schedulers, emulator processes, MariaDB events, import jobs and other tools using these resources. Keep them paused until the command exits. `--writers-quiesced` records your acknowledgement; it does not stop services or prove that they are stopped. No DDL may run while dumping. InnoDB's transaction snapshot alone cannot make independently copied files consistent with rows or coordinate other services. The before/after table inventory and second source-file hash pass detect many concurrent changes, but cannot replace quiescing.
|
||||
|
||||
The dump explicitly uses `--single-transaction --quick --skip-lock-tables --routines --events --triggers --hex-blob --tz-utc`. It retains schema/data and named SQL objects while streaming rows. See [MariaDB dump snapshot and object options](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump). This is a logical application backup, not point-in-time recovery: binlogs, server accounts/grants, server configuration, Redis state and unrelated databases are outside its scope.
|
||||
|
||||
## Private configuration
|
||||
|
||||
Create a private JSON file **outside the clone and every selected source directory**, for example `/etc/epicnext/backup.json`. Use a directory accessible only to the operator and file mode `0600`. Edit it with the host's normal private configuration workflow; do not put a password in a shell command or commit the file.
|
||||
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "REPLACE_WITH_BACKUP_ACCOUNT",
|
||||
"password": "REPLACE_PRIVATELY",
|
||||
"database": "REPLACE_WITH_APPLICATION_DATABASE"
|
||||
},
|
||||
"roots": {
|
||||
"storage": "/srv/epicnext/storage",
|
||||
"nitro": "/srv/epicnext/public/nitro-assets",
|
||||
"swf": "/srv/epicnext/public/swf",
|
||||
"gamedata": "/var/www/Gamedata"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace the example clone path and database settings. `storage`, `nitro` and `swf` are required existing directories, including when empty. `gamedata` is optional; omit its key only when those files are independently backed up or not used. All paths must be absolute, distinct, non-overlapping and free of `..` and symlink/junction components. Links, hardlinked files, special files and secret configuration filenames such as `.env`, `.docker-install` and `persistent.path` inside a selected root cause rejection. The configuration itself may not be inside any source root. Keep writers and directory ownership controlled for the duration; this is not a filesystem snapshot resistant to hostile concurrent renames.
|
||||
|
||||
The tool reads only this explicit JSON. It does not source `.env`, inspect a running application's environment or inherit its secrets into Docker. The MariaDB password is written to a random private temporary directory/file (`0700`/`0600`) and read through a read-only container mount with `--defaults-file` as the first client option. It is absent from process arguments and tool logs; source configuration and absolute source paths are absent from the manifest. Temporary credentials are removed on ordinary success/failure. See [MariaDB option-file handling](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump#defaults-file-name).
|
||||
|
||||
Only Docker connection/runtime environment variables are passed to child processes. Do not point `DOCKER_HOST`/`DOCKER_CONTEXT` at another host or enable shell tracing around private configuration work.
|
||||
|
||||
## Create and verify
|
||||
|
||||
Provision a private backup parent directory, with adequate free space, outside all source roots. Choose a **new** absolute artifact directory for each run. After pausing the writers described above, run from the clone root:
|
||||
|
||||
```sh
|
||||
node scripts/backup/cli.mjs create \
|
||||
--config /etc/epicnext/backup.json \
|
||||
--output /srv/epicnext-backups/2026-09-13T200000Z \
|
||||
--writers-quiesced
|
||||
```
|
||||
|
||||
The date is an example; use a new name for the actual run. Existing directories are refused, including earlier incomplete attempts. A successful artifact contains:
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
database.sql
|
||||
files/storage/...
|
||||
files/nitro/...
|
||||
files/swf/...
|
||||
files/gamedata/... (only when selected)
|
||||
```
|
||||
|
||||
`manifest.json` is written last and marks the format complete. It records each relative file path, byte count and SHA-256, empty directories, selected logical roots, creation time and database inventory. The inventory includes table row counts and MariaDB extended table checksums plus names/types of views, triggers, routines and events. These checksums validate restored table contents; they are not cryptographic signatures or a substitute for application-level checks. See [MariaDB CHECKSUM TABLE semantics and version limits](https://mariadb.com/docs/server/reference/sql-statements/table-statements/checksum-table).
|
||||
|
||||
On a caught failure the newly created artifact is removed; an interrupted process can leave an incomplete directory, which verification refuses. Source roots and existing backup directories are never overwritten. Files are copied and hashed as streams, then source hashes are checked again across the dump interval. This performs multiple full reads of the assets and table data; allow sufficient time and disk capacity during the maintenance window.
|
||||
|
||||
After creation you may resume writers. Copy the completed artifact to the designated protected backup location according to the operator's retention/encryption policy. SQL and uploaded files contain application data and may themselves contain sensitive values. Hashes detect corruption against the manifest, not an attacker who can replace both. Keep the manifest and artifact under trusted access control. Secrets, TLS material and deployment configuration excluded from this artifact need their separate recovery procedure.
|
||||
|
||||
## Isolated restore drill
|
||||
|
||||
Run the drill against a trusted completed artifact:
|
||||
|
||||
```sh
|
||||
node scripts/backup/cli.mjs drill \
|
||||
--artifact /srv/epicnext-backups/2026-09-13T200000Z
|
||||
```
|
||||
|
||||
The drill first rejects missing, extra, changed or unsafe paths/files. It copies the persistent files into a new private temporary directory and verifies their contents. It creates a randomly named MariaDB container with **no network and no published ports**, a fresh password supplied by file, and a disposable database volume. SQL is imported through the container's standard input; there is no connection to the source database. The event scheduler stays off. The resulting table counts/checksums and object inventory must match the manifest. This proves dump importability and the recorded contents, not a full CMS/emulator startup or external-service recovery.
|
||||
|
||||
On ordinary completion/failure it removes the container, its anonymous volume and temporary files. Cleanup failure makes the drill fail. A host crash or forced process termination can interrupt cleanup; inspect only resources labeled `cms.backup-drill=true` and private `cms-backup-private-*` temporary directories from that run, and review their ownership before manual removal. Never substitute an existing database/container into this procedure.
|
||||
|
||||
A real production recovery remains a separate, reviewed procedure with its own deployment configuration, credentials, downtime and application checks. This tool deliberately cannot perform it.
|
||||
|
||||
## Repository verification
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --coverage.enabled=false scripts/backup
|
||||
pnpm exec vitest run --config vitest.integration.config.ts integration/backup.test.ts
|
||||
```
|
||||
|
||||
The local suite exercises real file copies, empty directories, SQL/file tampering, manifest traversal, links, secret-file rejection, overwrites, cleanup and changes during backup. The integration suite requires Docker: failure to start MariaDB fails the suite. It uses a real database with Unicode text, large unsigned identifiers, a foreign key, view, trigger, procedure and event; it creates an artifact, restores it to a second disposable server and checks both byte corruption and a SQL content change with a recomputed file hash. A passing local file suite alone is not evidence that the MariaDB drill ran.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Docker and news checks before merging
|
||||
|
||||
Push work to a `codex/**` branch and open a pull request targeting `main` or `master`. CI runs the existing `check` job first. After it passes, the new `preflight` job builds the production Dockerfile and runs the isolated news browser suite against that exact image. Review both results before merging; repository branch protection can require `check` and `preflight` for pull requests.
|
||||
|
||||
Each execution uses `epicnext-cms:preflight-<commit>-<random suffix>`, including retries and separate push/PR runs of the same commit. The full checked-out commit is passed as `NEXT_DEPLOYMENT_ID` and `NEWS_E2E_RELEASE`; `NEWS_E2E_IMAGE` identifies that execution's image. Failures in dependency/browser setup, Docker build, news tests or cleanup fail the job. News browser artifacts are uploaded even when the gate fails.
|
||||
|
||||
The script installs dependencies with the frozen lockfile and installs Chromium on the CI runner. The real Docker build uses the existing Dockerfile's fixture build settings. It never copies or sources a deployment `.env`, connects to a VPS, runs live migrations, updates live containers, publishes a registry image or changes release tags. The existing isolated news runner owns its disposable MariaDB, Redis and application containers. Cleanup removes only the preflight tag and its empty private temporary directory; it does not prune Docker resources.
|
||||
|
||||
The `deploy` and `publish-container` conditions remain restricted to pushes on `main`/`master`. Deployment still runs its own news gate before live migrations/cutover. A successful branch preflight supplies earlier evidence; the deployed commit is independently checked again.
|
||||
|
||||
On a Linux development or CI host with the project toolchain, Docker Engine and normal browser prerequisites, the same gate can be run from a clean checkout:
|
||||
|
||||
```sh
|
||||
bash scripts/ci-preflight.sh
|
||||
```
|
||||
|
||||
Shell orchestration is covered by `pnpm exec vitest run --coverage.enabled=false src/lib/ci-preflight.test.ts`. Those tests execute the real shell script with external command boundaries simulated; they prove ordering, failure propagation, unique tags and cleanup scope. They do not build an image or run the news browser suite. The branch/PR CI job provides that Docker/browser evidence.
|
||||
@@ -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;
|
||||
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||
|
||||
Renovate uses separate development/UI/Vitest groups with manual merge and a
|
||||
three-day release age. The existing external Gitea bot configuration is retained.
|
||||
Node/pnpm upgrades remain coordinated with Docker and the runner toolchain.
|
||||
Dependency updates are manual: Renovate has been removed, so there is no bot
|
||||
opening upgrade pull requests. Node/pnpm upgrades remain coordinated with
|
||||
Docker and the runner toolchain.
|
||||
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Install a clone and choose an update
|
||||
|
||||
## Requirements and first installation
|
||||
|
||||
Use a Linux host with Docker Engine, the Compose plugin, Git and `flock`. This Compose file uses host networking and port 3002. Provide an existing compatible Habbo MariaDB database, reachable Redis, persistent storage and an HTTP(S) reverse proxy. The installer does not provision the emulator, a database, TLS, or a registry account.
|
||||
|
||||
Clone this repository from the Gitea URL supplied by your administrator, enter the clone, then run:
|
||||
|
||||
```sh
|
||||
bash cms install
|
||||
```
|
||||
|
||||
The wizard asks for the public URL and delivery mode. **Source** (the default for a new installation) builds the locked source locally and only needs repository access plus access to public build dependencies. **Prebuilt** downloads the application and migrations from the repository's `docker-image.txt`; a private package needs a separate registry login with package-read permission. Git access alone may not grant package access. Existing saved mode and `.env` are preserved. `bash cms install --configure-only` saves configuration without starting containers.
|
||||
|
||||
Credentials are entered on the host, never in the dashboard. Do not paste `.env` into support tickets. Installation prepares `public/nitro-assets`, `public/swf`, `storage`, and `/var/www/Gamedata` for UID/GID 33 without recursively taking ownership of existing files. Existing nested assets may still need operator permission repair. The updater tests access to those mounts as the candidate container user before migrations; this checks directory access, not every nested file or filesystem capacity.
|
||||
|
||||
After startup, visit `/admin/devops/installation` with the appropriate permission. Verify database, Redis, storage and migration status. Worker heartbeat is a separate runtime signal: a healthy HTTP endpoint does not prove an import worker is processing jobs. Check the worker status and investigate a missing/stale heartbeat before scheduling imports. The dashboard is read-only and cannot start Docker, upgrade the host or grant registry access.
|
||||
|
||||
## Client IP trust at the reverse proxy
|
||||
|
||||
The application validates and normalizes client addresses from `cf-connecting-ip`, the first `x-forwarded-for` entry, then `x-real-ip`. It never accepts `x-real-client-ip`; that legacy derived header is also stripped by the Next.js proxy. Missing or invalid addresses resolve to `0.0.0.0` for rate limits and audit records. API routes use the same resolver even though they do not run through the Next.js proxy.
|
||||
|
||||
These headers are trustworthy only when the ingress sanitizes them. Configure the reverse proxy to discard client-supplied forwarding/derived headers and replace the accepted address from a verified connection or a specifically trusted upstream proxy. Do not append an untrusted incoming `x-forwarded-for` chain and then treat its first entry as authoritative. Forward `cf-connecting-ip` only after verifying that it came through your trusted CDN path; otherwise remove it.
|
||||
|
||||
Restrict direct access to the application port so requests must pass through that ingress. The provided Compose file uses host networking with `HOSTNAME=0.0.0.0`; it does not enforce this restriction or provision nginx/Traefik trust rules. Verify the host firewall and actual reverse-proxy configuration before relying on client IPs for blocking, auditing or abuse limits. Repository tests prove rejection of the derived-header bypass and malformed addresses; they do not certify the deployed forwarding trust chain.
|
||||
|
||||
## Opt-in profile: Nginx on the same host
|
||||
|
||||
Use this profile for a new Linux Compose clone whose public HTTPS endpoint is Nginx on that same host. It leaves `docker-compose.yml` and CI-managed production deployments unchanged. Nginx must include `ngx_http_realip_module`; check `nginx -V` before using the templates. The CMS remains on the existing host network for database/Redis connectivity, but its HTTP process listens on `127.0.0.1:3002`. Host networking shares the host network namespace; a `ports:` mapping would not provide the restriction. See [Docker host networking](https://docs.docker.com/engine/network/drivers/host/).
|
||||
|
||||
1. Run `bash cms install --configure-only` and choose the intended public HTTPS URL. Obtain a valid certificate for that hostname using the host's existing certificate-management process. The templates do not issue certificates or configure renewal.
|
||||
2. In the clone's existing `.env`, add or replace this single setting, preserving all other values:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:deployment/proxy/compose.loopback.yml
|
||||
```
|
||||
|
||||
Keep `APP_URL`, `AUTH_URL` and the saved installer public URL on the same canonical `https://` hostname. The profile pins the existing port 3002 as well as the loopback address. Do not place `COMPOSE_FILE` in `.docker-install`; that file accepts only `MODE` and `PUBLIC_URL`.
|
||||
3. Copy [nginx-direct.example.conf](../../deployment/proxy/nginx-direct.example.conf) into the host's Nginx configuration directory, outside this Git clone. Replace **every** `hotel.example` and both certificate paths. Load it at `http` scope, for example through `/etc/nginx/conf.d/cms.conf`. Review any existing virtual host for that hostname to avoid two competing configurations. The example handles only CMS HTTP traffic; emulator WebSocket and other hotel services need their own reviewed ingress.
|
||||
4. Validate the effective Nginx configuration with `nginx -t`, then enable/reload it through the host's normal service-management procedure. Prepare this endpoint before starting the installer, because installation verifies the saved public URL. It can return an upstream-unavailable response until the CMS starts.
|
||||
5. From the clone root, remove conflicting Compose overrides from the deployment shell and validate the model:
|
||||
|
||||
```sh
|
||||
unset COMPOSE_FILE COMPOSE_PATH_SEPARATOR COMPOSE_ENV_FILES COMPOSE_DISABLE_ENV_FILE
|
||||
docker compose config --quiet
|
||||
bash cms install
|
||||
```
|
||||
|
||||
These `unset` commands remove shell overrides; they do not remove the `COMPOSE_FILE` line in `.env`. Do not set `COMPOSE_DISABLE_ENV_FILE=1`, use an alternate `--env-file`, or supply a competing shell `COMPOSE_FILE` for this workflow. Environment values can override `.env` selection. Do not print or paste the full rendered Compose configuration, because it contains runtime credentials. See [Compose predefined variables and precedence](https://docs.docker.com/compose/how-tos/environment-variables/envvars/).
|
||||
6. Confirm the running configuration without dumping the environment:
|
||||
|
||||
```sh
|
||||
docker compose exec -T cms node -e 'if(process.env.HOSTNAME!=="127.0.0.1"||process.env.PORT!=="3002")process.exit(1);console.log("CMS configured for 127.0.0.1:3002")'
|
||||
ss -lnt '( sport = :3002 )'
|
||||
curl --fail --silent --show-error https://hotel.example/api/health
|
||||
```
|
||||
|
||||
The listener must be `127.0.0.1:3002`, not `0.0.0.0:3002` or `[::]:3002`. From another machine, the host's public IP on port 3002 must be unreachable. Check both address families when the host has IPv6. Loopback isolation covers the CMS process only: other containers and host services, including Byparr, retain their existing bindings.
|
||||
|
||||
The direct template uses the original socket peer (`$realip_remote_addr`), overwrites the two accepted forwarding headers, and removes incoming `CF-Connecting-IP`, `X-Real-Client-IP` and `Forwarded`. It fixes forwarded host/protocol to the configured HTTPS origin. An unrelated inherited real-IP rule cannot turn a caller-supplied header into the forwarded client address in this mode. See [Nginx original-peer variables](https://nginx.org/en/docs/http/ngx_http_realip_module.html) and [header replacement/removal](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header).
|
||||
|
||||
Response buffering is disabled for progress streams, and this example adds no proxy response cache or CORS policy. Its 64 MiB ingress body cap accommodates the existing 52 MiB Studio attachment limit; route and Server Action limits remain authoritative and may be lower. TLS 1.2/1.3 are configured explicitly. See [Nginx buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) and [TLS protocols](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_protocols).
|
||||
|
||||
### Why the profile survives an update
|
||||
|
||||
The installer preserves an existing `.env`. The installer invokes `docker-update.sh`, and the updater's config, build, candidate run, cutover and rollback paths all call **bare `docker compose` from the clone root**, without `-f`. Compose therefore reads the saved file list on each invocation. The base file appears first so its relative mount/build paths remain rooted in the clone; the later profile overrides only the CMS environment and healthcheck. No installer/updater patch is required for this selection. See [Compose merge order and relative paths](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/).
|
||||
|
||||
Keep the profile selected for every routine `bash cms update` and selected-release update. A one-off `docker compose -f ... up` does not persist selection for later installer/updater commands. Do not add an untracked `docker-compose.override.yml`: it makes the clone dirty unless separately excluded and is bypassed when `COMPOSE_FILE` selects an explicit list. Before selecting an older release, verify that `deployment/proxy/compose.loopback.yml` exists in that release; a missing selected file fails configuration rather than silently using the public bind.
|
||||
|
||||
This profile does not change `scripts/ci-deploy.sh`, which owns a separate `docker run` deployment and currently sets its own public binding. Do not use the clone installer to take over a host managed by CI. Restricting that deployment's listener requires a separate compatibility review and rollout.
|
||||
|
||||
### Separate mode: verified remote edge or Cloudflare
|
||||
|
||||
A remote proxy cannot connect to the CMS loopback listener directly. The supported topology here is **remote edge → TLS → Nginx on the CMS host → loopback CMS**, retaining the same Compose profile. Use [nginx-trusted-proxy.example.conf](../../deployment/proxy/nginx-trusted-proxy.example.conf) instead of the direct template. Do not enable both templates for one hostname.
|
||||
|
||||
For your own remote edge, replace `203.0.113.10/32` in both the `geo` peer allowlist and `set_real_ip_from` with the exact approved connection source addresses. The reserved example address deliberately permits no real edge. Require the edge to overwrite `X-Forwarded-For` with one verified client IP, use the configured hostname, enforce public HTTPS, and validate this origin's TLS certificate. The origin checks the original socket peer before accepting the rewritten address. Never use `0.0.0.0/0`, `::/0` or arbitrary client networks as trusted proxies. See [Nginx real-IP trust configuration](https://nginx.org/en/docs/http/ngx_http_realip_module.html).
|
||||
|
||||
For Cloudflare, use that same restricted-edge mode and replace both peer lists with the **current verified IPv4 and IPv6 Cloudflare ranges**, maintained by the operator; use `real_ip_header CF-Connecting-IP` instead of `X-Forwarded-For`. Configure Full (strict) TLS and review authenticated origin pulls. Obtain ranges from [Cloudflare's official IP list](https://www.cloudflare.com/ips/) and follow its [visitor-IP restoration guidance](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/). Do not copy a historical range list from a support ticket or trust `CF-Connecting-IP` merely because it is present. This setup still removes the CF header before the CMS and forwards only Nginx's normalized result in XFF/X-Real-IP.
|
||||
|
||||
The direct template intentionally records the CDN/edge socket address when placed behind an unconfigured CDN; it does not silently trust an upstream header. After configuring the restricted-edge mode, verify with requests from an allowed edge and a disallowed direct client, including forged CF/XFF headers, and check the recorded client address. Neither the repository nor the installer changes the host firewall or certifies another proxy's header behavior.
|
||||
|
||||
### Local verification and deployment boundary
|
||||
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
|
||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||
|
||||
## Routine and selected-release updates
|
||||
|
||||
```sh
|
||||
bash cms update
|
||||
```
|
||||
|
||||
This requires a clean clone and fast-forwards its configured Git upstream, then builds/pulls artifacts for that exact commit. It validates the application and migration revision labels, validates runtime configuration and storage, runs migrations and verifies the recreated CMS locally and through the saved public URL.
|
||||
|
||||
To install a specific published version, fetch it and select its commit on the host first:
|
||||
|
||||
```sh
|
||||
git fetch origin
|
||||
git switch --detach <reviewed-commit-or-tag>
|
||||
bash cms update --skip-pull
|
||||
```
|
||||
|
||||
`--skip-pull` deliberately uses the checked-out commit, including detached HEAD. For routine updates again, switch back to your tracked deployment branch. The script does not invent version-to-schema compatibility or automatically change branches.
|
||||
|
||||
In prebuilt mode you can additionally require immutable artifacts from the configured repository:
|
||||
|
||||
```sh
|
||||
bash cms update --skip-pull --app-digest sha256:<64-lowercase-hex> --migrations-digest sha256:<64-lowercase-hex>
|
||||
```
|
||||
|
||||
Replace both placeholders with publisher-provided digests. Both are mandatory together. Revision labels must match the checked-out commit; a digest alone does not establish schema compatibility. Older migration images without a revision label must be republished from matching source, or the same checkout can be installed in source mode. No migration runs when the artifact or candidate validation fails.
|
||||
|
||||
## Compatibility and recovery
|
||||
|
||||
Before upgrading, read the selected release's migration changes and take a database backup with a tested restore procedure. Preserve `.env`, persistent assets and the previous release identifier. The current tooling does not provide a validated matrix of supported source/target database versions. Pinning an old commit is therefore not a supported database downgrade procedure.
|
||||
|
||||
On a failure after container replacement, the updater attempts to restore the previous image and checks it locally. **Image rollback does not reverse database migrations.** A migration may partially apply or make the old application incompatible, including when migration fails before container replacement. Recover the database only through the reviewed backup/restore procedure and coordinate downtime; do not assume restarting the old image recovers it. First installation has no prior image to restore. Host logs remain in `logs/docker-update.log` and may contain application/database diagnostics; restrict access.
|
||||
|
||||
Local Git Bash tests cover selection validation and mocked failure paths. They do not establish Linux container startup, runtime filesystem permissions, registry availability or real MariaDB upgrade compatibility.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Personal API token scopes
|
||||
|
||||
Public API bearer authentication accepts only tokens owned by the exact `App\Models\User` model. The owner ID must be a positive, safely representable user ID, and the token must satisfy its existing expiration check. Both plaintext tokens and the existing `{id}|{plaintext}` request format remain supported; only the SHA-256 hash is looked up in the database.
|
||||
|
||||
The `abilities` column must contain a non-empty JSON array of non-empty strings. Null, malformed JSON, non-array JSON, empty arrays, non-string entries, and entries with surrounding whitespace are rejected. A valid `"*"` entry grants access to all existing bearer-protected endpoints. Other permissions match exactly: there is no `tickets:*` expansion, implicit read/write inheritance, or fallback to unrestricted access.
|
||||
|
||||
| Ability | Endpoint access |
|
||||
| --- | --- |
|
||||
| `tickets:read` | `GET /api/tickets`, `GET /api/tickets/{id}` |
|
||||
| `tickets:write` | `POST /api/tickets`, `POST /api/tickets/{id}/reply` |
|
||||
| `articles:write` | `POST /api/articles/{slug}/comment` |
|
||||
| `radio:read` | `GET /api/radio/points` |
|
||||
| `radio:write` | `POST /api/radio/shouts` |
|
||||
| `badges:read` | Personal viewer data in `GET /api/badges/leaderboard` |
|
||||
|
||||
For example, `["tickets:read","radio:read"]` allows reading the owner's tickets and radio points. It cannot create tickets, send replies, post article comments, or send radio shouts. Endpoint ownership checks and rate limits still apply after scope authorization.
|
||||
|
||||
Required-token endpoints return the existing generic `401 Unauthorized` response when authorization fails. The badge leaderboard remains public: a denied bearer token receives the anonymous view, without personal viewer data. When an Authorization header is present, this endpoint does not use a session cookie to bypass a denied token. Session-only requests continue to personalize the leaderboard normally.
|
||||
|
||||
## Compatibility and maintenance
|
||||
|
||||
Existing valid wildcard tokens remain compatible. The existing session-authenticated `POST /api/tokens` endpoint continues issuing `["*"]`; this change does not add token-creation options or alter stored tokens. Legacy null, malformed, empty, differently cased model names, and unrelated model tokens are intentionally denied. Review and replace affected tokens with explicit intended scopes, or reissue through the existing token endpoint when full access is appropriate.
|
||||
|
||||
Every new bearer-authenticated endpoint must pass its required abilities to `bearerUserId`. Multiple required abilities use AND semantics. Omitting the requirements, or passing an empty list, requires a wildcard token rather than granting arbitrary scoped tokens access.
|
||||
|
||||
No plaintext token or stored hash is added to error responses or logs by these checks. The existing issuance endpoint returns plaintext once by design.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Security report verification — 2026-09-13
|
||||
|
||||
The supplied review describes commit `baeb54ae` plus a separate port for another hotel. Its “Fixed” labels were not evidence that the changes existed in EpicNext-Cms. This verification inspected canonical `main` at `52f6d149` and the corrective changes prepared here. No exploit or authenticated mutation was performed against production.
|
||||
|
||||
| Supplied finding | Verified state in baseline | Correction / remaining boundary |
|
||||
| --- | --- | --- |
|
||||
| 1. Logo authorization/upload | Confirmed missing action permission and per-file validation | Require settings edit before input or storage access; bounded decoded raster uploads |
|
||||
| 2. Favicon authorization/delete | Confirmed missing action permission; SVG accepted | Same permission boundary for create/delete, bounded raster/ICO validation |
|
||||
| 3. Active uploaded SVG | Confirmed SVG served inline without route CSP | Route CSP sandbox and nosniff on success/errors; existing SVG served as attachment |
|
||||
| 4. Client IP spoofing | Confirmed direct trust in caller-controlled `x-real-client-ip` | Shared validated resolver ignores that header. Forwarded headers still require trusted ingress that overwrites them and prevents direct public origin access |
|
||||
| 5. Email token action exports | Confirmed token helpers in a `use server` module | Move token creation/validation and delivery to a server-only module. Registration and verification call it internally |
|
||||
| 6. Locale cookie | Confirmed missing allowlist | Supported locales only, validate before reading/writing cookies |
|
||||
| 7. Email header injection | Confirmed unsanitized values in sendmail headers | Reject control characters before any mail transport or file fallback; includes configured sender |
|
||||
| 8. Gateway CORS | Supplied gateway path is outside this repository | Read-only GET to our `/api/health` with an unrelated Origin returned a fixed `https://epicnabbo.nl` allow-origin and no allow-credentials. This does not reproduce the report on that route, nor certify every host/route |
|
||||
| 9. Token abilities | Confirmed abilities and owner type not checked by bearer authentication | Enforce User owner type and explicit endpoint abilities; existing wildcard user tokens remain supported |
|
||||
| 10. Broad script CDN | Confirmed unrestricted jsDelivr script source, without a source-code consumer | Remove the broad script source; retain required captcha/analytics sources and nonce |
|
||||
|
||||
The additional `withNitroStaff` code and its tests mentioned in the supplied port do not exist in this checkout; they were not assumed to have been reviewed or imported.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Regression tests exercise authorization before I/O, actual file decoding, SVG/error response headers, token-boundary exports, token abilities, forged derived-IP headers across consumers, locale values, and mail header control characters.
|
||||
- An updated `pnpm audit --json` reported zero known advisories. This is a dependency database result, not proof that application code has no vulnerabilities.
|
||||
- Next.js treats exported Server Actions as public endpoints; unused actions can also be removed by the compiler. The email refactor removes the action boundary entirely instead of relying on whether a specific build exports an unused helper. See [Next.js data security](https://nextjs.org/docs/app/guides/data-security).
|
||||
- The framework also has its own Server Action body limit. The logo defect was absence of application-level file validation, not evidence of literally unlimited bytes through every deployment layer.
|
||||
- No live database, user accounts, uploaded files, or gateway configuration were modified during verification. These changes do not constitute a penetration test or an audit of the emulator, host, or all CMS endpoints.
|
||||
- No nginx/Traefik ingress configuration is versioned here. The deployment guide records the forwarding-header trust requirement. That external boundary remains unverified.
|
||||
|
||||
## Follow-up identified during verification
|
||||
|
||||
The separate comment review is now implemented: both the website form and REST API use one submission service, require a published article whose publication time is due, apply the same moderation, and share a five-attempt/30-second per-user quota. The publication check locks the current article in the insertion transaction. Regression tests cover both entrypoints; real MariaDB/Redis coverage includes publication eligibility, word filtering and alternating submissions. Moderation retains its existing fail-open behavior on service outages. Form input beyond 255 characters is now rejected instead of truncated, and temporary API storage failures return 503. Real integration execution remains a required CI check.
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Security and operational reliability implementation plan
|
||||
|
||||
Goal: finish the five approved follow-ups with independently verified commits.
|
||||
Architecture: share comment policy between session and bearer entrypoints; opt-in same-host proxy configuration; run real news browser checks against the already-built candidate in disposable services; correlate existing diagnostics with deliveries; verify database and persistent-file backup restoration in isolation.
|
||||
Stack: existing Next, MariaDB, Redis, Playwright, Testcontainers and Docker; no new dependencies.
|
||||
Design: user-approved numbered proposal in this task, 2026-09-13.
|
||||
|
||||
Global constraints: preserve current public/HK UX and ACL; no production test content or proxy/firewall changes; no credentials in output; root owns Git on canonical main. Complete each block's checks before an exact-file commit and push. Confirm final CI, container publication and live release.
|
||||
|
||||
1. Comments — src/actions/article-comments.ts, API comment route and shared policy/tests. Add regression cases for hidden/future articles, moderation, cross-channel limit and safe failures; reproduce them, implement, run focused and integration checks. Publicly available article predicate is checked on both entrypoints.
|
||||
2. Proxy — deployment/proxy templates and installation guide/tests. Override must survive installer/update/rollback, force loopback and replace incoming identity headers. Validate the merged Compose config and Nginx syntax; do not apply to the host.
|
||||
3. Real news — e2e/news-real runner/fixture plus ci-deploy gate and harness tests. Start only disposable MariaDB/Redis and the local candidate image; real staff login, draft, preview, publish and anonymous read. Fail before live migration/cutover on any error, clean all fixture resources. Require successful CI execution.
|
||||
4. Diagnostics — carry persisted operation/delivery identifiers into error records; link filtered deliveries and diagnostics with permission checks. Preserve request correlation separately. Tests cover exact matching, hostile IDs, permissions and retry outcomes.
|
||||
5. Recovery — backup creation and isolated restore drill for database plus explicit persistent directories. Keep credentials off argv/logs, reject unsafe paths and incomplete/tampered artifacts. Test real database restore and file checksums with disposable data, record limits for cross-service consistency. Never overwrite production during a drill.
|
||||
|
||||
Status: all five blocks implemented and locally checked. Required final gates: CI real database/proxy/backup suites, candidate news browser journey, deployment/container completion and live release verification. Extra scheduler deadlock discovered in the real concurrency test is fixed with bounded transaction retries. Evidence and boundaries accompany each delivered block.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Real database integration tests
|
||||
|
||||
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
||||
|
||||
Sixteen database tests exercise the production database commands, news actions, public article query and delivery worker against MariaDB 11.4.5 and Redis 7.4.2:
|
||||
|
||||
- Migration CLI replay/status, committed catalog bulk edits and undo history, complete rollback after an audit insert fails, and competing catalog previews.
|
||||
- Real Redis expiry metadata and cache-key isolation, concurrent request idempotency, operation/outbox rollback, and exclusive delivery claims.
|
||||
- Concurrent duplicate draft creation and publication produce one article, one revision, one audit update and one effect per operation. The public query changes from cached absence to the full published content, including Unicode and a body larger than a TEXT column.
|
||||
- A database trigger rejects the publication effect after the article, revision and audit writes. The transaction restores all preceding state; the identical request can then retry successfully without duplicate history.
|
||||
- A trigger rejects the second scheduled-publication effect. Both article updates and both operations roll back, including the first queued effect.
|
||||
- Competing scheduler ticks publish each due article once, preserve future articles and drafts, retain an unsigned bigint ID beyond JavaScript's safe integer range, and attribute a legacy authorless article to the system actor.
|
||||
- A real Redis client disconnect leaves a publication committed and readable directly from MariaDB. The worker records a pending failed attempt. Reconnecting retains the stale cached absence until a successful outbox retry rotates the cache revision; the public query then returns the published article. The test advances only the queued retry timestamp to avoid sleeping.
|
||||
|
||||
Each execution starts disposable containers with random exposed ports and generated passwords. No production URLs, volumes or credentials are used. The real migration CLI is copied beneath a temporary isolated fixture root so its environment loader cannot read the checkout environment file. Cleanup attempts all connections and containers even if a previous cleanup fails. The fixture inspection connection reads timestamps as UTC; the application keeps its production connection settings and host timezone. Each test receives a fresh news-cache revision, and the disconnect test reconnects its client in a `finally` block.
|
||||
|
||||
Only authorization/session lookup, translation lookup, Next.js revalidation/redirects, and the external publication webhook are mocked for the news actions. MariaDB, Drizzle, transactions, article revisions, history, operation deduplication, outbox claims/retries, Redis caching, the publication scheduler, public article lookup and the news delivery handler use their production implementations.
|
||||
|
||||
The fixture models the emulator's catalog/audit baseline plus the legacy article columns. Real migrations 0025-0029 and 0031 supply publication, editorial recovery, catalog packages, history and operations tables. This does not certify every historical emulator schema or migration.
|
||||
|
||||
These are application-service integration tests, not browser or HTTP end-to-end tests: they do not start a Next.js server, render the news page, verify login/ACL behavior, or send external webhooks. The cache outage test closes and restores the actual application Redis connection; it does not stop the Redis server or model a multi-host network partition. Passing TypeScript or unit tests without Docker is not a passing result for this suite.
|
||||
|
||||
Public comment pagination and reaction aggregation are covered by unit tests using the production Drizzle query builder with a substituted database transport, plus server-rendered page tests with substituted service results. This integration fixture does not create users or article-reactions tables and does not execute the public pagination and aggregation queries against MariaDB; its article-comments table is used for submission tests. Comment submission now additionally uses real MariaDB and Redis to verify publication eligibility, filtering and the shared quota across the actual form/API handlers, with authentication replaced at the boundary. The article-cache upgrade test verifies that legacy cached absence is ignored under the versioned key; it is a unit test, separate from the real Redis publication and delivery checks above.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Real news browser gate
|
||||
|
||||
Run `pnpm test:news:real` with `NEWS_E2E_IMAGE=epicnext-cms:<candidate-tag>`. Docker CLI, a working Docker daemon, the OpenSSL CLI with `-addext` support, installed project dependencies and Playwright Chromium are required. The runner deliberately fails when the image or Docker is unavailable. It never silently skips the browser journey.
|
||||
|
||||
`NEWS_E2E_RELEASE=<full-commit-sha>` additionally verifies the image revision label. The runner resolves the local image to its immutable ID before starting it. It does not build or pull the application image. MariaDB 11.4.5 and Redis 7.4.2 Alpine are disposable Testcontainers dependencies and may be pulled when absent.
|
||||
|
||||
The deployment script runs this gate after building the candidate and extracting its performance report, before live database migrations and container cutover. The general Playwright suite excludes `e2e/news-real`; this suite has its own configuration and requires the runner.
|
||||
|
||||
## What the browser proves
|
||||
|
||||
One Chromium journey exercises the candidate's normal Docker entrypoint and standalone Next server:
|
||||
|
||||
1. An anonymous request to the staff editor reaches the login page.
|
||||
2. The browser signs in through the actual login form, credential precheck and Auth.js handler. The test verifies the real Secure/HttpOnly session cookie and database login record.
|
||||
3. A rank 7 editor, below an occupied rank 9 owner, accesses news through three explicit ACL grants: `admin.dashboard`, `admin.news.view` and `admin.news.edit`.
|
||||
4. The browser types Unicode content into the bundled TinyMCE editor and saves a draft through the real server action. The persisted HTML must equal what the form submitted.
|
||||
5. An independent anonymous browser sees the not-found screen; the public articles API returns an empty list. Redis contains the cached null article. Next can stream a not-found screen with HTTP 200, so this check verifies the rendered 404 screen as well as absence from the API.
|
||||
6. The editor reopens and previews the saved draft in the real preview iframe. Its title, summary and rendered body match; it remains a draft with no article revision created by previewing.
|
||||
7. Publishing through the editor produces one article, a publication timestamp, one previous-draft revision, a before/after audit entry, two completed operation results and two news-refresh outbox entries.
|
||||
8. The anonymous page and API immediately show the article, using a new shared Redis cache revision. The browser remains signed out.
|
||||
|
||||
No authentication, application HTTP responses, mutations, database calls or cache calls are mocked. The browser blocks resources outside the local fixture origin, such as external avatars. This does not replace any application response. A local HTTPS edge passes requests to Next and sets trusted forwarding headers, allowing the production Secure-cookie behavior to run normally.
|
||||
|
||||
The fresh browser contexts retain normal application service-worker registration. The application worker does not cache article or authentication responses. Playwright's worker-blocking injection is avoided because it throws inside the sandboxed preview; browser errors are still checked without filtering. The preview is closed through its Close button, since keyboard events inside its sandbox do not reach the parent dialog.
|
||||
|
||||
## Isolation and cleanup
|
||||
|
||||
- Each run creates a random Docker network and fresh MariaDB, Redis and candidate containers. All mapped ports are allocated dynamically. The HTTPS listener binds only to `127.0.0.1`.
|
||||
- No production container, database, volume or credentials are reused. Container configuration is explicit. Child processes receive a small environment whitelist; checkout `.env` and installation service credentials are not forwarded.
|
||||
- The database uses the current ORM column definitions for 29 tables needed by login, site/admin layouts and news. Unique constraints and composite primary keys are preserved. The emulator-owned `permission_ranks` fixture provides the rank authority columns this flow queries. Real CMS migrations 0026 and 0031 create the recovery/revision and operation/outbox tables. This is a focused fixture, not a replacement for the emulator's complete schema or migration coverage.
|
||||
- Passwords, Redis credentials and the Auth.js secret are generated per run. The owner has an unknown random password and is never used to bypass ACL checks. Email verification is enabled with a verified fixture staff account. CAPTCHA and forced staff 2FA use their ordinary disabled installation settings; their challenges are outside this flow.
|
||||
- OpenSSL generates a fresh localhost certificate and private key in the OS temporary directory for each run. The certificate lasts one day and covers `127.0.0.1` and `localhost`. Playwright trusts this self-signed endpoint only in this suite. No certificate or key is committed or copied into build artifacts; cleanup removes both with the temporary credentials.
|
||||
- Credentials used by the test worker are stored in an OS temporary directory with a mode-0600 file, then removed. Cleanup stops only containers and the network created by this runner; Testcontainers also registers them with its resource reaper.
|
||||
- Failure artifacts are under `test-results/news-real` and `playwright-report/news-real`. Server logs redact generated passwords/secrets. Playwright traces can contain the short-lived fixture login/session data; all associated services are destroyed after the run.
|
||||
|
||||
This browser gate checks the synchronous editor/publication path and durable delivery intent. Scheduler concurrency, rollback, duplicate requests, worker delivery and Redis outage/recovery remain covered by `pnpm test:integration`. It does not claim to test emulator connectivity, external notifications, CAPTCHA/2FA challenges or production data.
|
||||
|
||||
The local workstation currently has no working Docker daemon. Type checks, lint and fixture/bootstrap checks can run there; a passing real browser result must come from the Docker-capable CI gate.
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS cms_operations (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
actor_id INT NOT NULL,
|
||||
kind VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_key CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
result_json MEDIUMTEXT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE KEY operation_request (actor_id,kind,request_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS cms_outbox (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
operation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
lease_until DATETIME(3) NULL,
|
||||
lease_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
last_error VARCHAR(255) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE KEY operation_effect (operation_id,topic),
|
||||
KEY delivery_pending (status,available_at),
|
||||
KEY delivery_lease (status,lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS `website_notification_reads` (
|
||||
`user_id` int NOT NULL,
|
||||
`event_key` varchar(128) NOT NULL,
|
||||
`read_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`, `event_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CREATE TABLE IF NOT EXISTS `website_notification_preferences` (
|
||||
`user_id` int NOT NULL,
|
||||
`support` boolean NOT NULL DEFAULT true,
|
||||
`friends` boolean NOT NULL DEFAULT true,
|
||||
`events` boolean NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,154 @@
|
||||
-- 0033_referrals_daily_rewards.sql
|
||||
-- Referral attribution tables (mirror the live-DB shape used by the existing
|
||||
-- referral claim flow in src/actions/referral.ts) plus the CMS-owned daily
|
||||
-- login reward schedule and claim ledger. All CREATE statements are idempotent
|
||||
-- (IF NOT EXISTS) so fresh installs get the tables and existing hotels keep
|
||||
-- whatever rows they already have.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_referrals` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`referrals_total` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NULL,
|
||||
`updated_at` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user_referrals_user_idx` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `referrals` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`referred_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`referred_user_ip` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP NULL,
|
||||
`updated_at` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `referrals_user_idx` (`user_id`),
|
||||
KEY `referrals_referred_user_idx` (`referred_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `claimed_referral_logs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`ip_address` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP NULL,
|
||||
`updated_at` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `claimed_referral_logs_user_idx` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Daily login reward schedule: one row per streak day. The cycle repeats after
|
||||
-- the highest day (day 1 of the cycle is used for any missing day).
|
||||
CREATE TABLE IF NOT EXISTS `website_daily_rewards` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`day` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`currency` VARCHAR(20) NOT NULL DEFAULT 'credits',
|
||||
`amount` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `website_daily_rewards_day_uk` (`day`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Daily reward claims ledger; one row per user per claim date.
|
||||
CREATE TABLE IF NOT EXISTS `website_daily_reward_claims` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`claim_date` DATE NOT NULL,
|
||||
`streak` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`reward_day` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`currency` VARCHAR(20) NOT NULL DEFAULT 'credits',
|
||||
`amount` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `daily_claim_user_date_uk` (`user_id`, `claim_date`),
|
||||
KEY `daily_claim_created_idx` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Seed a sensible 7-day reward schedule only when the table is still empty.
|
||||
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
|
||||
SELECT 1, 'duckets', 60
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `website_daily_rewards`);
|
||||
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
|
||||
SELECT 3, 'credits', 50
|
||||
FROM DUAL
|
||||
WHERE (SELECT COUNT(*) FROM `website_daily_rewards`) = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `website_daily_rewards` WHERE `day` = 3);
|
||||
INSERT INTO `website_daily_rewards` (`day`, `currency`, `amount`)
|
||||
SELECT 5, 'diamonds', 2
|
||||
FROM DUAL
|
||||
WHERE (SELECT COUNT(*) FROM `website_daily_rewards`) = 2
|
||||
AND NOT EXISTS (SELECT 1 FROM `website_daily_rewards` WHERE `day` = 5);
|
||||
|
||||
-- Referral + daily reward configuration defaults (never overwrite an existing
|
||||
-- value — operators tune these keys in the admin panel).
|
||||
INSERT INTO `website_settings` (`key`, `value`, `comment`)
|
||||
SELECT 'referrals_block_same_ip', '1', 'Block referral attribution when the new account shares the inviter IP'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `website_settings` WHERE `key` = 'referrals_block_same_ip');
|
||||
INSERT INTO `website_settings` (`key`, `value`, `comment`)
|
||||
SELECT 'daily_reward_enabled', '1', 'Enable the daily login reward claims on /me'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `website_settings` WHERE `key` = 'daily_reward_enabled');
|
||||
|
||||
-- New ACL slugs backing the /admin/referrals + /admin/daily-rewards modules.
|
||||
INSERT INTO `acl_permissions` (`slug`, `title`) VALUES
|
||||
('admin.referrals.view', 'View referrals'),
|
||||
('admin.referrals.edit', 'Edit referrals'),
|
||||
('admin.dailyrewards.view', 'View daily login rewards'),
|
||||
('admin.dailyrewards.edit', 'Edit daily login rewards')
|
||||
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
|
||||
|
||||
-- Grant the new slugs to the same roles that already open the admin panel.
|
||||
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM `acl_roles` ar
|
||||
JOIN `acl_permissions` ap ON ap.slug IN (
|
||||
'admin.referrals.view', 'admin.referrals.edit',
|
||||
'admin.dailyrewards.view', 'admin.dailyrewards.edit'
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM `acl_model_permissions` amp
|
||||
JOIN `acl_permissions` apdash ON apdash.id = amp.permission_id
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND apdash.slug = 'admin.dashboard'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `acl_model_permissions` amp2
|
||||
WHERE amp2.model_type = 'Role'
|
||||
AND amp2.model_id = ar.id
|
||||
AND amp2.permission_id = ap.id
|
||||
);
|
||||
|
||||
-- Safety net matching the 0018 seed: ranks >= 6 view, ranks >= 7 edit.
|
||||
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM `permission_ranks` pr
|
||||
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN `acl_permissions` ap ON ap.slug IN ('admin.referrals.view', 'admin.dailyrewards.view')
|
||||
WHERE pr.id >= 6
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `acl_model_permissions` amp
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
);
|
||||
|
||||
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM `permission_ranks` pr
|
||||
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN `acl_permissions` ap ON ap.slug IN ('admin.referrals.edit', 'admin.dailyrewards.edit')
|
||||
WHERE pr.id >= 7
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `acl_model_permissions` amp
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Repair the escalation introduced by 0018's rule 1 ("has admin.dashboard gets
|
||||
-- ALL admin.*"). Migrating 0011 grants admin.dashboard to every rank >= 6 so
|
||||
-- that the sidebar opens, which meant rank 6 silently acquired
|
||||
-- admin.permissions.manage, admin.rcon.execute, admin.settings.edit,
|
||||
-- admin.users.edit, admin.users.reset_password, admin.room.delete, ...
|
||||
--
|
||||
-- Rule 1 is narrowed to `admin.%.view` (read-only, all the sidebar needs) in
|
||||
-- both the migration set and the runtime repair action. This migration undoes
|
||||
-- the over-grant on databases that already ran 0018: every role below the top
|
||||
-- rank keeps dashboard + *.view and loses every other admin.* grant. Ranks
|
||||
-- that legitimately hold tools keep them, because rule 3 only targets
|
||||
-- rank >= 7 and those roles are not touched here.
|
||||
--
|
||||
-- Note on the rank extraction: `acl_roles.slug` looks like `rank_7`, and
|
||||
-- MySQL's SUBSTRING is 1-based, so the digits start at position 6 — right
|
||||
-- after the 5-character `rank_`. Reading from position 7 truncates the first
|
||||
-- digit, which turns rank_10 into 0 and rank_7 into an empty string, i.e. both
|
||||
-- would compare as < 7 and lose grants this migration is supposed to preserve.
|
||||
-- The REGEXP guard below guarantees the remainder really is all digits.
|
||||
|
||||
DELETE `amp`
|
||||
FROM `acl_model_permissions` `amp`
|
||||
JOIN `acl_roles` `ar`
|
||||
ON `ar`.`id` = `amp`.`model_id`
|
||||
AND `amp`.`model_type` = 'Role'
|
||||
JOIN `acl_permissions` `ap`
|
||||
ON `ap`.`id` = `amp`.`permission_id`
|
||||
WHERE `ap`.`slug` LIKE 'admin.%'
|
||||
AND `ap`.`slug` NOT LIKE '%.view'
|
||||
AND `ar`.`slug` REGEXP '^rank_[0-9]+$'
|
||||
AND CAST(SUBSTRING(`ar`.`slug`, 6) AS UNSIGNED) < 7;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 0035_users_mail_index.sql
|
||||
-- Index on users.mail.
|
||||
--
|
||||
-- The authentication paths all look an account up by mail: password reset,
|
||||
-- e-mail verification, duplicate-address detection and the verify/resend
|
||||
-- cooldown all resolve a single user from a submitted address. Without an index
|
||||
-- each of those is a full table scan of `users`, which grows with every
|
||||
-- registration.
|
||||
--
|
||||
-- Deliberately NOT unique. Legacy rows predate the duplicate-address handling
|
||||
-- and can legitimately contain the same address more than once, so a unique
|
||||
-- index would fail to apply on an existing database. The lookup is made
|
||||
-- deterministic by ordering on `id` (see requestReset / the verify page), which
|
||||
-- is stable without the index and correct with it.
|
||||
--
|
||||
-- The column is VARCHAR(500), which exceeds the 767-byte InnoDB prefix limit on
|
||||
-- older row formats, hence an explicit 191-character prefix: enough to make the
|
||||
-- lookup selective and still indexable everywhere.
|
||||
CREATE INDEX IF NOT EXISTS `users_mail_index` ON `users` (`mail`(191));
|
||||
@@ -0,0 +1,305 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { type BrowserContext, expect, test } from "@playwright/test";
|
||||
import Redis from "ioredis";
|
||||
import mysql, {
|
||||
type ConnectionOptions,
|
||||
type RowDataPacket,
|
||||
} from "mysql2/promise";
|
||||
|
||||
interface Fixture {
|
||||
username: string;
|
||||
userId: number;
|
||||
password: string;
|
||||
database: ConnectionOptions;
|
||||
redis: { host: string; port: number; password: string };
|
||||
}
|
||||
|
||||
async function onlyLocalResources(context: BrowserContext, origin: string) {
|
||||
// External avatars/telemetry are outside this isolated hotel. All application
|
||||
// documents, scripts, forms, API requests and auth responses remain untouched.
|
||||
await context.route("**/*", (route) => {
|
||||
if (new URL(route.request().url()).origin === origin)
|
||||
return route.continue();
|
||||
return route.abort("blockedbyclient");
|
||||
});
|
||||
}
|
||||
|
||||
test("staff signs in, saves a draft, previews it and publishes to anonymous readers", async ({
|
||||
page,
|
||||
context,
|
||||
browser,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
if (!baseURL || !process.env.NEWS_E2E_FIXTURE)
|
||||
throw Error("Disposable fixture is required");
|
||||
const fixture = JSON.parse(
|
||||
await readFile(process.env.NEWS_E2E_FIXTURE, "utf8"),
|
||||
) as Fixture;
|
||||
const database = await mysql.createConnection({
|
||||
...fixture.database,
|
||||
timezone: "Z",
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
const redis = new Redis({
|
||||
...fixture.redis,
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 5_000,
|
||||
});
|
||||
const anonymous = await browser.newContext({
|
||||
baseURL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const reader = await anonymous.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
reader.on("pageerror", (error) => errors.push(error.message));
|
||||
const rows = async (sql: string, params: string[] = []) =>
|
||||
(await database.query<RowDataPacket[]>(sql, params))[0];
|
||||
const title = "Notizia browser: città e novità 🎉";
|
||||
const publicTitle = reader.locator(
|
||||
".content-card:has(.article-body) .content-card-title",
|
||||
);
|
||||
const slug = "browser-news-real";
|
||||
const summary =
|
||||
"Una notizia creata e pubblicata attraverso il pannello reale.";
|
||||
const body = "È una prova reale: caffè, città e 🎉. Salvata dal browser.";
|
||||
let articleId = "";
|
||||
let submittedHtml = "";
|
||||
let draftRevision: string | null = null;
|
||||
try {
|
||||
await onlyLocalResources(context, baseURL);
|
||||
await onlyLocalResources(anonymous, baseURL);
|
||||
await redis.ping();
|
||||
await test.step("real authentication and staff ACL", async () => {
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page).toHaveURL(/\/login(?:\?|$)/);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await page
|
||||
.locator('input[autocomplete="username"]')
|
||||
.fill(fixture.username);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.fill(fixture.password);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.press("Enter");
|
||||
// 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
|
||||
.get("/api/auth/session")
|
||||
.then((response) => response.json());
|
||||
expect(session.user).toMatchObject({
|
||||
id: String(fixture.userId),
|
||||
name: fixture.username,
|
||||
rank: 7,
|
||||
});
|
||||
expect(
|
||||
(await context.cookies()).some(
|
||||
(cookie) =>
|
||||
cookie.name.startsWith("__Secure-authjs.session-token") &&
|
||||
cookie.secure &&
|
||||
cookie.httpOnly,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toEqual([
|
||||
expect.objectContaining({ user_id: fixture.userId }),
|
||||
]);
|
||||
// The editor is below the highest occupied rank: access requires real ACL grants.
|
||||
expect(
|
||||
(await rows("SELECT MAX(rank) AS highest FROM users"))[0].highest,
|
||||
).toBe(9);
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator('input[name="title"]')).toBeVisible();
|
||||
});
|
||||
await test.step("save the draft using the real rich text editor and server action", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('input[name="title"]').fill(title);
|
||||
await form.locator('input[name="slug"]').fill(slug);
|
||||
await form.locator('[name="shortStory"]').fill(summary);
|
||||
await form
|
||||
.locator('input[name="image"]')
|
||||
.fill("/assets/images/EnterHubbly.png");
|
||||
await form.locator('select[name="status"]').selectOption("draft");
|
||||
const editor = form
|
||||
.frameLocator("iframe.tox-edit-area__iframe")
|
||||
.locator('body[contenteditable="true"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill(body);
|
||||
await editor.press("End");
|
||||
await expect(form.locator('textarea[name="fullStory"]')).toHaveValue(
|
||||
/Salvata dal browser/,
|
||||
);
|
||||
submittedHtml = await form
|
||||
.locator('textarea[name="fullStory"]')
|
||||
.inputValue();
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
title,
|
||||
slug,
|
||||
short_story: summary,
|
||||
status: "draft",
|
||||
user_id: fixture.userId,
|
||||
published_at: null,
|
||||
publish_at: null,
|
||||
});
|
||||
expect(articles[0].full_story).toBe(submittedHtml);
|
||||
articleId = String(articles[0].id);
|
||||
expect(await rows("SELECT kind, actor_id FROM cms_operations")).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "news.create",
|
||||
actor_id: fixture.userId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
await test.step("anonymous readers and the shared cache still see no published article", async () => {
|
||||
const response = await reader.goto(`/news/${slug}`);
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
// Next can stream not-found markup with HTTP 200; assert the actual 404 screen.
|
||||
await expect(reader.locator(".error-screen-code")).toHaveText("404");
|
||||
await expect(publicTitle).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toEqual([]);
|
||||
expect(listing.meta.total).toBe(0);
|
||||
draftRevision = await redis.get("cms:news:revision");
|
||||
expect(draftRevision).not.toBeNull();
|
||||
expect(
|
||||
await redis.get(`news:${draftRevision}:article:v2:slug:${slug}`),
|
||||
).toBe("null");
|
||||
});
|
||||
await test.step("preview the persisted draft without publishing it", async () => {
|
||||
await page
|
||||
.locator(`a[href="/admin/articles/${articleId}"]`)
|
||||
.first()
|
||||
.click();
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await expect(form.locator('input[name="title"]')).toHaveValue(title);
|
||||
await expect(form.locator('select[name="status"]')).toHaveValue("draft");
|
||||
await expect(
|
||||
form.frameLocator("iframe.tox-edit-area__iframe").locator("body"),
|
||||
).toContainText(body);
|
||||
await form.getByRole("button", { name: "Preview", exact: true }).click();
|
||||
const preview = page.getByRole("dialog").frameLocator("iframe");
|
||||
await expect(
|
||||
preview.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(preview.locator("body")).toContainText(summary);
|
||||
await expect(preview.locator("body")).toContainText(body);
|
||||
expect(await rows("SELECT status FROM website_articles")).toEqual([
|
||||
{ status: "draft" },
|
||||
]);
|
||||
expect(
|
||||
await rows("SELECT id FROM website_article_revisions"),
|
||||
).toHaveLength(0);
|
||||
// Preview autofocus enters its sandboxed iframe, whose Escape event cannot reach the dialog.
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
await test.step("publish once and persist revision, audit and delivery intent", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('select[name="status"]').selectOption("published");
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
status: "published",
|
||||
user_id: fixture.userId,
|
||||
slug,
|
||||
title,
|
||||
});
|
||||
expect(articles[0].published_at).toBeInstanceOf(Date);
|
||||
const revisions = await rows(
|
||||
"SELECT article_id, user_id, payload FROM website_article_revisions",
|
||||
);
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(String(revisions[0].article_id)).toBe(articleId);
|
||||
expect(Number(revisions[0].user_id)).toBe(fixture.userId);
|
||||
expect(JSON.parse(revisions[0].payload)).toMatchObject({
|
||||
title,
|
||||
status: "draft",
|
||||
});
|
||||
const audit = await rows(
|
||||
"SELECT user_id, `before`, `after` FROM admin_audit_log WHERE target='news'",
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].user_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(audit[0].before).status).toBe("draft");
|
||||
expect(JSON.parse(audit[0].after).status).toBe("published");
|
||||
const operations = await rows(
|
||||
"SELECT kind, actor_id, result_json FROM cms_operations ORDER BY kind",
|
||||
);
|
||||
expect(operations.map((operation) => operation.kind)).toEqual([
|
||||
"news.create",
|
||||
"news.update",
|
||||
]);
|
||||
for (const operation of operations) {
|
||||
expect(operation.actor_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(operation.result_json)).toMatchObject({ ok: true });
|
||||
}
|
||||
const effects = await rows("SELECT topic FROM cms_outbox");
|
||||
expect(effects).toEqual([
|
||||
{ topic: "news.refresh" },
|
||||
{ topic: "news.refresh" },
|
||||
]);
|
||||
expect(await redis.get("cms:news:revision")).not.toBe(draftRevision);
|
||||
});
|
||||
await test.step("anonymous pages and API read the newly published content", async () => {
|
||||
const response = await reader.reload();
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(publicTitle).toHaveText(title);
|
||||
await expect(publicTitle).toBeVisible();
|
||||
await expect(reader.locator(".article-body")).toContainText(body);
|
||||
await expect(reader.locator(".error-screen-code")).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toHaveLength(1);
|
||||
expect(listing.data[0]).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
shortStory: summary,
|
||||
});
|
||||
expect(listing.meta.total).toBe(1);
|
||||
const revision = await redis.get("cms:news:revision");
|
||||
const cached = await redis.get(
|
||||
`news:${revision}:article:v2:slug:${slug}`,
|
||||
);
|
||||
expect(JSON.parse(cached ?? "null")).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
});
|
||||
expect(
|
||||
(await anonymous.cookies()).some((cookie) =>
|
||||
cookie.name.includes("session-token"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
} finally {
|
||||
if (errors.length)
|
||||
await testInfo.attach("browser-errors", {
|
||||
body: JSON.stringify(errors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await anonymous.close().catch(() => {});
|
||||
await database.end();
|
||||
redis.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
if (!process.env.NEWS_E2E_FIXTURE || !process.env.NEWS_E2E_BASE_URL)
|
||||
throw Error(
|
||||
"Run this suite with pnpm test:news:real; it requires disposable services.",
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "news.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 240_000,
|
||||
expect: { timeout: 15_000 },
|
||||
outputDir: "../../test-results/news-real",
|
||||
reporter: [
|
||||
["list"],
|
||||
[
|
||||
"html",
|
||||
{ outputFolder: "../../playwright-report/news-real", open: "never" },
|
||||
],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.NEWS_E2E_BASE_URL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
launchOptions: {
|
||||
executablePath: process.env.UI_TEST_BROWSER_PATH || undefined,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium-real-news", use: { ...devices["Desktop Chrome"] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { createServer, type Server } from "node:https";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import mysql, { type Connection } from "mysql2/promise";
|
||||
import {
|
||||
GenericContainer,
|
||||
Network,
|
||||
type StartedNetwork,
|
||||
type StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
import { STAFF_ID, STAFF_USERNAME, seedDatabase } from "./schema";
|
||||
|
||||
const root = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const execute = promisify(execFile);
|
||||
const image = process.env.NEWS_E2E_IMAGE;
|
||||
const release = process.env.NEWS_E2E_RELEASE;
|
||||
if (!image)
|
||||
throw Error(
|
||||
"NEWS_E2E_IMAGE is required: build the candidate image before running this test. Docker is mandatory.",
|
||||
);
|
||||
if (!/^epicnext-cms:[a-zA-Z0-9_.-]+$/.test(image))
|
||||
throw Error("NEWS_E2E_IMAGE must name a local epicnext-cms candidate tag.");
|
||||
if (release && !/^[0-9a-f]{40}$/.test(release))
|
||||
throw Error("NEWS_E2E_RELEASE must be a full commit SHA.");
|
||||
|
||||
// Never propagate checkout .env, NODE_OPTIONS, installation DB URLs or service tokens.
|
||||
const inherited = (names: string[]): NodeJS.ProcessEnv => ({
|
||||
NODE_ENV: "test",
|
||||
...Object.fromEntries(
|
||||
names.flatMap((name) =>
|
||||
process.env[name] === undefined ? [] : [[name, process.env[name]]],
|
||||
),
|
||||
),
|
||||
});
|
||||
const hostEnv = inherited([
|
||||
"PATH",
|
||||
"Path",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"LOCALAPPDATA",
|
||||
]);
|
||||
const dockerEnv = {
|
||||
...hostEnv,
|
||||
...inherited([
|
||||
"DOCKER_HOST",
|
||||
"DOCKER_CONTEXT",
|
||||
"DOCKER_CONFIG",
|
||||
"DOCKER_CERT_PATH",
|
||||
"DOCKER_TLS_VERIFY",
|
||||
]),
|
||||
};
|
||||
const secrets = Array.from({ length: 4 }, () =>
|
||||
randomBytes(32).toString("hex"),
|
||||
);
|
||||
const [databasePassword, redisPassword, staffPassword, authSecret] = secrets;
|
||||
const redact = (text: string) =>
|
||||
secrets.reduce(
|
||||
(value, secret) => value.replaceAll(secret, "[fixture secret]"),
|
||||
text,
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const onSignal = () => abort.abort();
|
||||
process.once("SIGINT", onSignal);
|
||||
process.once("SIGTERM", onSignal);
|
||||
let network: StartedNetwork | undefined;
|
||||
let maria: StartedTestContainer | undefined;
|
||||
let redis: StartedTestContainer | undefined;
|
||||
let app: StartedTestContainer | undefined;
|
||||
let database: Connection | undefined;
|
||||
let proxy: Server | undefined;
|
||||
let temporary: string | undefined;
|
||||
let logs = "";
|
||||
|
||||
try {
|
||||
// Resolve the existing image before Testcontainers; starting by immutable ID cannot pull a tag.
|
||||
const inspected = await execute(
|
||||
"docker",
|
||||
["image", "inspect", image, "--format", "{{json .}}"],
|
||||
{ env: dockerEnv, timeout: 15_000 },
|
||||
);
|
||||
const candidate = JSON.parse(inspected.stdout) as {
|
||||
Id: string;
|
||||
Config: { Labels?: Record<string, string> };
|
||||
};
|
||||
if (!/^sha256:[0-9a-f]{64}$/.test(candidate.Id))
|
||||
throw Error("Candidate image identity is invalid");
|
||||
if (
|
||||
release &&
|
||||
candidate.Config.Labels?.["org.opencontainers.image.revision"] !== release
|
||||
)
|
||||
throw Error("Candidate image revision does not match NEWS_E2E_RELEASE");
|
||||
abort.signal.throwIfAborted();
|
||||
temporary = await mkdtemp(join(tmpdir(), "epicnext-news-e2e-"));
|
||||
// Fresh local-only TLS material never enters the repository or build artifacts.
|
||||
await execute(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
join(temporary, "localhost.key"),
|
||||
"-out",
|
||||
join(temporary, "localhost.crt"),
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=IP:127.0.0.1,DNS:localhost",
|
||||
],
|
||||
{ cwd: temporary, env: hostEnv, timeout: 30_000, signal: abort.signal },
|
||||
);
|
||||
console.log("News browser gate: starting isolated MariaDB and Redis");
|
||||
network = await new Network().start();
|
||||
const services = await Promise.allSettled([
|
||||
new GenericContainer("mariadb:11.4.5")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("news-db")
|
||||
.withEnvironment({
|
||||
MARIADB_ROOT_PASSWORD: randomBytes(32).toString("hex"),
|
||||
MARIADB_DATABASE: "news_e2e",
|
||||
MARIADB_USER: "news_e2e",
|
||||
MARIADB_PASSWORD: databasePassword,
|
||||
})
|
||||
.withExposedPorts(3306)
|
||||
.withHealthCheck({
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
||||
interval: 1000,
|
||||
timeout: 5000,
|
||||
retries: 60,
|
||||
startPeriod: 1000,
|
||||
})
|
||||
.withWaitStrategy(Wait.forHealthCheck())
|
||||
.withStartupTimeout(120_000)
|
||||
.start()
|
||||
.then((container) => {
|
||||
maria = container;
|
||||
}),
|
||||
new GenericContainer("redis:7.4.2-alpine")
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("news-redis")
|
||||
.withCommand(["redis-server", "--requirepass", redisPassword])
|
||||
.withExposedPorts(6379)
|
||||
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
|
||||
.withStartupTimeout(60_000)
|
||||
.start()
|
||||
.then((container) => {
|
||||
redis = container;
|
||||
}),
|
||||
]);
|
||||
for (const service of services)
|
||||
if (service.status === "rejected") throw service.reason;
|
||||
if (!maria || !redis) throw Error("Disposable services did not start");
|
||||
abort.signal.throwIfAborted();
|
||||
database = await mysql.createConnection({
|
||||
host: maria.getHost(),
|
||||
port: maria.getMappedPort(3306),
|
||||
user: "news_e2e",
|
||||
password: databasePassword,
|
||||
database: "news_e2e",
|
||||
charset: "utf8mb4",
|
||||
timezone: "Z",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
await seedDatabase(database, staffPassword);
|
||||
await database.end();
|
||||
database = undefined;
|
||||
|
||||
let upstream: URL | undefined;
|
||||
let origin = "";
|
||||
proxy = createServer(
|
||||
{
|
||||
cert: await readFile(join(temporary, "localhost.crt")),
|
||||
key: await readFile(join(temporary, "localhost.key")),
|
||||
},
|
||||
(request, response) => {
|
||||
if (!upstream || request.headers.host !== new URL(origin).host) {
|
||||
response.writeHead(503);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
// A real local TLS edge, with the same forwarded-host/proto contract as deployment.
|
||||
const headers = { ...request.headers };
|
||||
delete headers["cf-connecting-ip"];
|
||||
delete headers["x-real-client-ip"];
|
||||
headers["x-forwarded-for"] = "127.0.0.1";
|
||||
headers["x-real-ip"] = "127.0.0.1";
|
||||
headers["x-forwarded-host"] = new URL(origin).host;
|
||||
headers["x-forwarded-proto"] = "https";
|
||||
const forwarded = httpRequest(
|
||||
{
|
||||
hostname: upstream.hostname,
|
||||
port: upstream.port,
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
},
|
||||
(received) => {
|
||||
response.writeHead(received.statusCode ?? 502, received.headers);
|
||||
received.pipe(response);
|
||||
},
|
||||
);
|
||||
forwarded.on("error", () => {
|
||||
if (!response.headersSent) response.writeHead(502);
|
||||
response.end();
|
||||
});
|
||||
request.on("aborted", () => forwarded.destroy());
|
||||
request.pipe(forwarded);
|
||||
},
|
||||
);
|
||||
proxy.listen(0, "127.0.0.1");
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
if (!address || typeof address === "string")
|
||||
throw Error("Local TLS listener is unavailable");
|
||||
origin = `https://127.0.0.1:${address.port}`;
|
||||
console.log("News browser gate: starting the production candidate image");
|
||||
app = await new GenericContainer(candidate.Id.slice("sha256:".length))
|
||||
.withNetwork(network)
|
||||
.withEnvironment({
|
||||
NODE_ENV: "production",
|
||||
HOSTNAME: "0.0.0.0",
|
||||
PORT: "3002",
|
||||
DATABASE_URL: `mysql://news_e2e:${databasePassword}@news-db:3306/news_e2e`,
|
||||
REDIS_URL: `redis://:${redisPassword}@news-redis:6379/0`,
|
||||
HOTEL_NAME: "News browser fixture",
|
||||
AUTH_SECRET: authSecret,
|
||||
APP_URL: origin,
|
||||
NEXT_PUBLIC_APP_URL: origin,
|
||||
AUTH_URL: origin,
|
||||
RCON_HOST: "127.0.0.1",
|
||||
RCON_PORT: "9",
|
||||
RCON_TIMEOUT_MS: "100",
|
||||
RCON_MAX_RETRIES: "1",
|
||||
IMAGING_UPSTREAM_URL: "http://127.0.0.1:9",
|
||||
LOG_LEVEL: "warn",
|
||||
})
|
||||
.withExposedPorts(3002)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health", 3002).forStatusCode(200))
|
||||
.withStartupTimeout(120_000)
|
||||
.withLogConsumer((stream) =>
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
logs = (logs + chunk.toString()).slice(-2_000_000);
|
||||
}),
|
||||
)
|
||||
.start();
|
||||
upstream = new URL(`http://${app.getHost()}:${app.getMappedPort(3002)}`);
|
||||
abort.signal.throwIfAborted();
|
||||
const health = (await fetch(new URL("/api/health", upstream), {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).then((response) => response.json())) as {
|
||||
status?: string;
|
||||
database?: boolean;
|
||||
redis?: boolean;
|
||||
};
|
||||
if (
|
||||
health.status !== "ok" ||
|
||||
health.database !== true ||
|
||||
health.redis !== true
|
||||
)
|
||||
throw Error("Candidate health does not confirm both disposable services");
|
||||
const fixturePath = join(temporary, "fixture.json");
|
||||
await writeFile(
|
||||
fixturePath,
|
||||
JSON.stringify({
|
||||
username: STAFF_USERNAME,
|
||||
userId: STAFF_ID,
|
||||
password: staffPassword,
|
||||
database: {
|
||||
host: maria.getHost(),
|
||||
port: maria.getMappedPort(3306),
|
||||
user: "news_e2e",
|
||||
password: databasePassword,
|
||||
database: "news_e2e",
|
||||
},
|
||||
redis: {
|
||||
host: redis.getHost(),
|
||||
port: redis.getMappedPort(6379),
|
||||
password: redisPassword,
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
console.log(
|
||||
"News browser gate: login, draft, preview, publish and anonymous read",
|
||||
);
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
resolve(root, "node_modules/@playwright/test/cli.js"),
|
||||
"test",
|
||||
"--config",
|
||||
"e2e/news-real/playwright.config.ts",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...hostEnv,
|
||||
...inherited([
|
||||
"DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"PLAYWRIGHT_BROWSERS_PATH",
|
||||
"UI_TEST_BROWSER_PATH",
|
||||
"CI",
|
||||
]),
|
||||
NEWS_E2E_FIXTURE: fixturePath,
|
||||
NEWS_E2E_BASE_URL: origin,
|
||||
},
|
||||
stdio: "inherit",
|
||||
signal: abort.signal,
|
||||
},
|
||||
);
|
||||
const [code] = (await once(child, "exit")) as [number | null];
|
||||
if (code !== 0)
|
||||
throw Error(`Real news browser suite failed (exit ${code ?? "signal"})`);
|
||||
console.log("News browser gate passed");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
redact(
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
// Stop only objects created by this run. Testcontainers' resource reaper also owns them.
|
||||
const cleanups: Array<[string, () => Promise<unknown>]> = [
|
||||
[
|
||||
"TLS proxy",
|
||||
async () => {
|
||||
proxy?.closeAllConnections();
|
||||
if (proxy)
|
||||
await new Promise<void>((done) => proxy?.close(() => done()));
|
||||
},
|
||||
],
|
||||
["candidate", async () => app?.stop({ timeout: 10_000 })],
|
||||
["database connection", async () => database?.end()],
|
||||
["Redis", async () => redis?.stop()],
|
||||
["MariaDB", async () => maria?.stop()],
|
||||
["network", async () => network?.stop()],
|
||||
[
|
||||
"temporary credentials",
|
||||
async () => {
|
||||
if (!temporary) return;
|
||||
if (
|
||||
dirname(resolve(temporary)) !== resolve(tmpdir()) ||
|
||||
!basename(temporary).startsWith("epicnext-news-e2e-")
|
||||
)
|
||||
throw Error(
|
||||
"Temporary credentials path escaped the fixture directory",
|
||||
);
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [name, cleanup] of cleanups) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error(`Cleanup failed for ${name}: ${redact(String(error))}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
await mkdir(resolve(root, "test-results/news-real"), { recursive: true });
|
||||
await writeFile(
|
||||
resolve(root, "test-results/news-real/server.log"),
|
||||
redact(logs),
|
||||
);
|
||||
process.removeListener("SIGINT", onSignal);
|
||||
process.removeListener("SIGTERM", onSignal);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { is, SQL } from "drizzle-orm";
|
||||
import {
|
||||
getTableConfig,
|
||||
MySqlDialect,
|
||||
type MySqlTable,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { bcrypt } from "hash-wasm";
|
||||
import mysql, { type Connection } from "mysql2/promise";
|
||||
import { splitSqlStatements } from "../../scripts/sql-statements";
|
||||
import * as schema from "../../src/db/schema";
|
||||
|
||||
export const STAFF_ID = 7;
|
||||
export const STAFF_USERNAME = "NewsEditor";
|
||||
|
||||
// Use the actual ORM definitions for the emulator tables this browser journey reads.
|
||||
// CMS recovery/outbox tables below come from the shipped migrations themselves.
|
||||
const tables: MySqlTable[] = [
|
||||
schema.User,
|
||||
schema.Ban,
|
||||
schema.WebsiteSetting,
|
||||
schema.WebsiteLanguages,
|
||||
schema.AclRole,
|
||||
schema.AclPermission,
|
||||
schema.AclModelRole,
|
||||
schema.AclModelPermission,
|
||||
schema.WebsiteArticles,
|
||||
schema.WebsiteArticleComments,
|
||||
schema.WebsiteArticleReactions,
|
||||
schema.WebsiteLoginLogs,
|
||||
schema.AdminAuditLog,
|
||||
schema.StaffActivities,
|
||||
schema.WebsiteIpBlacklist,
|
||||
schema.WebsiteIpWhitelist,
|
||||
schema.AlertLogs,
|
||||
schema.ThemeScope,
|
||||
schema.ThemeScopeValue,
|
||||
schema.UsersCurrency,
|
||||
schema.MessengerFriendrequests,
|
||||
schema.MessengerFriendships,
|
||||
schema.MessengerOffline,
|
||||
schema.Rooms,
|
||||
schema.UsersBadges,
|
||||
schema.UsersSettings,
|
||||
schema.UserReferrals,
|
||||
schema.WebsiteEvent,
|
||||
schema.WebsiteEventType,
|
||||
];
|
||||
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
|
||||
|
||||
export function fixtureStatements(): string[] {
|
||||
const dialect = new MySqlDialect();
|
||||
return tables.map((table) => {
|
||||
const config = getTableConfig(table);
|
||||
// Fail on new constraints instead of silently reducing the fixture's integrity.
|
||||
if (
|
||||
config.foreignKeys.length ||
|
||||
config.checks.length ||
|
||||
config.uniqueConstraints.length
|
||||
)
|
||||
throw Error(`Fixture requires explicit constraints for ${config.name}`);
|
||||
const columns = config.columns.map((column) => {
|
||||
let ddl = `${identifier(column.name)} ${column.getSQLType()}`;
|
||||
ddl += column.notNull ? " NOT NULL" : " NULL";
|
||||
if ("autoIncrement" in column && column.autoIncrement)
|
||||
ddl += " AUTO_INCREMENT";
|
||||
if (column.primary) ddl += " PRIMARY KEY";
|
||||
if (column.isUnique) ddl += " UNIQUE";
|
||||
if (column.default !== undefined) {
|
||||
if (is(column.default, SQL)) {
|
||||
const query = dialect.sqlToQuery(column.default);
|
||||
if (query.params.length)
|
||||
throw Error(
|
||||
`Fixture requires parameterized default for ${config.name}.${column.name}`,
|
||||
);
|
||||
ddl += ` DEFAULT ${query.sql}`;
|
||||
} else if (
|
||||
typeof column.default === "string" ||
|
||||
typeof column.default === "number" ||
|
||||
typeof column.default === "boolean" ||
|
||||
column.default === null
|
||||
)
|
||||
ddl += ` DEFAULT ${mysql.escape(column.default)}`;
|
||||
else
|
||||
throw Error(
|
||||
`Fixture requires explicit default for ${config.name}.${column.name}`,
|
||||
);
|
||||
} else if (!column.notNull) ddl += " DEFAULT NULL";
|
||||
return ddl;
|
||||
});
|
||||
for (const key of config.primaryKeys)
|
||||
columns.push(
|
||||
`PRIMARY KEY (${key.columns.map((column) => identifier(column.name)).join(", ")})`,
|
||||
);
|
||||
for (const index of config.indexes) {
|
||||
if (!index.config.unique) continue;
|
||||
const names = index.config.columns.map((column) => {
|
||||
if (is(column, SQL))
|
||||
throw Error(`Fixture requires expression index for ${config.name}`);
|
||||
return identifier(column.name);
|
||||
});
|
||||
columns.push(
|
||||
`UNIQUE KEY ${identifier(index.config.name)} (${names.join(", ")})`,
|
||||
);
|
||||
}
|
||||
return `CREATE TABLE ${identifier(config.name)} (${columns.join(",\n")}) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedDatabase(
|
||||
connection: Connection,
|
||||
staffPassword: string,
|
||||
) {
|
||||
for (const statement of fixtureStatements())
|
||||
await connection.query(statement);
|
||||
// Rank metadata belongs to the emulator and is queried as raw SQL by authorization.
|
||||
await connection.query(
|
||||
"CREATE TABLE permission_ranks (id INT PRIMARY KEY, rank_name VARCHAR(255) NOT NULL) ENGINE=InnoDB",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO permission_ranks (id, rank_name) VALUES (1, 'Member'), (7, 'Editor'), (9, 'Owner')",
|
||||
);
|
||||
for (const migration of [
|
||||
"0026_article_editor_recovery.sql",
|
||||
"0031_operations_outbox.sql",
|
||||
]) {
|
||||
const contents = await readFile(
|
||||
new URL(`../../drizzle/migrations/${migration}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const statement of splitSqlStatements(contents))
|
||||
await connection.query(statement);
|
||||
}
|
||||
const password = await bcrypt({
|
||||
password: staffPassword,
|
||||
salt: randomBytes(16),
|
||||
costFactor: 12,
|
||||
outputType: "encoded",
|
||||
});
|
||||
const ownerPassword = await bcrypt({
|
||||
password: randomBytes(32).toString("hex"),
|
||||
salt: randomBytes(16),
|
||||
costFactor: 12,
|
||||
outputType: "encoded",
|
||||
});
|
||||
await connection.query(
|
||||
"INSERT INTO users (id, username, password, rank, account_created, ip_register, ip_current, mail, mail_verified) VALUES (?, ?, ?, 7, ?, '127.0.0.1', '127.0.0.1', '[email protected]', '1'), (9, 'FixtureOwner', ?, 9, ?, '127.0.0.1', '127.0.0.1', '[email protected]', '1')",
|
||||
[
|
||||
STAFF_ID,
|
||||
STAFF_USERNAME,
|
||||
password,
|
||||
Math.floor(Date.now() / 1000),
|
||||
ownerPassword,
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_roles (id, slug, title) VALUES (1, 'news_editor', 'News editor')",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_model_roles (model_type, model_id, role_id) VALUES ('User', ?, 1)",
|
||||
[STAFF_ID],
|
||||
);
|
||||
for (const [index, slug] of [
|
||||
"admin.dashboard",
|
||||
"admin.news.view",
|
||||
"admin.news.edit",
|
||||
].entries()) {
|
||||
await connection.query(
|
||||
"INSERT INTO acl_permissions (id, slug, title) VALUES (?, ?, ?)",
|
||||
[index + 1, slug, slug],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_model_permissions (model_type, model_id, permission_id) VALUES ('Role', 1, ?)",
|
||||
[index + 1],
|
||||
);
|
||||
}
|
||||
for (const [key, value] of Object.entries({
|
||||
hotel_name: "News browser fixture",
|
||||
captcha_provider: "none",
|
||||
require_email_verification: "1",
|
||||
force_staff_2fa: "0",
|
||||
maintenance_enabled: "0",
|
||||
abuse_guard_enabled: "0",
|
||||
radio_enabled: "0",
|
||||
}))
|
||||
await connection.query(
|
||||
"INSERT INTO website_settings (`key`, value) VALUES (?, ?)",
|
||||
[key, value],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO website_languages (country_code, language) VALUES ('en', 'English')",
|
||||
);
|
||||
}
|
||||
|
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 file = {
|
||||
name: "fixture_chair.nitro",
|
||||
name: "fixture_chair.hab",
|
||||
mimeType: "application/octet-stream",
|
||||
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("fixture_chair");
|
||||
expect(route.request().postData()).toContain(
|
||||
'filename="fixture_chair.nitro"',
|
||||
'filename="fixture_chair.hab"',
|
||||
);
|
||||
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 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(
|
||||
page.getByText("Matching original file attached", { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -94,9 +94,7 @@ for (const scenario of [
|
||||
})
|
||||
: route.abort("failed"),
|
||||
);
|
||||
await page
|
||||
.getByLabel("Choose the original .nitro file")
|
||||
.setInputFiles(file);
|
||||
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
|
||||
await expect(page.getByRole("alert")).toContainText(scenario.message);
|
||||
await expect(
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("deliveries explain outcomes without exposing unauthorized content or raw metadata", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/admin/deliveries-harness");
|
||||
const review = page.getByRole("region", { name: "Delivery review" });
|
||||
const cards = review.getByRole("article");
|
||||
await expect(cards).toHaveCount(6);
|
||||
const news = cards.nth(0);
|
||||
await expect(news.getByRole("heading", { level: 2 })).toContainText(
|
||||
"Community update: SummerFestival",
|
||||
);
|
||||
await expect(news.getByText("Waiting", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
news.getByText(
|
||||
"The content is saved. This update runs in the background.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(news.getByRole("status")).toContainText(
|
||||
"The news cache could not be refreshed.",
|
||||
);
|
||||
await expect(news.getByRole("status")).toContainText(
|
||||
"Next automatic attempt: Jan 1, 2030, 12:05 PM",
|
||||
);
|
||||
await expect(
|
||||
news.getByRole("link", { name: "Open content", exact: true }),
|
||||
).toHaveAttribute("href", "/admin/articles/9007199254740993");
|
||||
await expect(
|
||||
news.getByRole("link", { name: "Service diagnostics", exact: true }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/admin/devops/cms-errors?q=4f3df172-f1ca-4c26-a23e-cb4e6c13e9f2",
|
||||
);
|
||||
const failed = cards.nth(1);
|
||||
await expect(
|
||||
failed.getByRole("heading", { name: "9 catalog offers", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
failed.getByText("Needs attention", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(failed.getByRole("status")).toContainText(
|
||||
"Check the emulator connection.",
|
||||
);
|
||||
await expect(failed.getByRole("status")).toContainText(
|
||||
"Automatic attempts are exhausted.",
|
||||
);
|
||||
await expect(
|
||||
failed.getByRole("link", { name: "Open content", exact: true }),
|
||||
).toHaveAttribute("href", "/admin/catalog");
|
||||
await expect(
|
||||
failed.getByRole("button", { name: "Retry delivery", exact: true }),
|
||||
).toBeVisible();
|
||||
for (const card of [cards.nth(2), cards.nth(3)]) {
|
||||
await expect(
|
||||
card.getByRole("heading", {
|
||||
name: "Content details unavailable",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByRole("link", { name: "Open content", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByRole("button", { name: "Retry delivery", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
const unknown = cards.nth(4);
|
||||
await expect(
|
||||
unknown.getByText("Other background update", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
unknown.getByText("Unknown status", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(unknown).toContainText("Time unavailable");
|
||||
await expect(unknown).toContainText("System");
|
||||
await expect(cards.nth(5)).toContainText(
|
||||
"Check the Git export history for the final result; disabled export does not publish files.",
|
||||
);
|
||||
await expect(review).not.toContainText(
|
||||
/RESTRICTED_NEWS_TITLE|SERIALIZED_METADATA_SECRET|INTERNAL_FILE_PATH_SECRET|UNKNOWN_OPERATION_SECRET|489 catalog offers|resultJson|articleId/,
|
||||
);
|
||||
await expect(review.locator("details[open]")).toHaveCount(0);
|
||||
await expect(
|
||||
news.getByText("Operation: operation-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
news.getByText("Delivery: delivery-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
await news.locator("summary").click();
|
||||
await expect(
|
||||
news.getByText("Operation: operation-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
news.getByText("Delivery: delivery-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await news.locator("summary").click();
|
||||
await expect(
|
||||
news.getByText("Operation: operation-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
const title = news.getByRole("heading", { level: 2 });
|
||||
expect(
|
||||
await title.evaluate((node) => node.scrollWidth <= node.clientWidth),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await testInfo.attach("delivery-cards", {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw Error("Invalid UI_TEST_PORT");
|
||||
const actions = new Set([
|
||||
"@/actions/catalog",
|
||||
"@/actions/import-furni",
|
||||
"@/actions/events",
|
||||
"@/actions/admin-settings",
|
||||
@@ -197,7 +198,9 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
if (
|
||||
[
|
||||
"/notifications",
|
||||
"/admin/unsaved-harness",
|
||||
"/admin/deliveries-harness",
|
||||
"/admin/attachment-harness",
|
||||
"/admin/studio-harness",
|
||||
"/admin/jobs-harness",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DeliveryCard,
|
||||
type DeliveryCardItem,
|
||||
} from "@/features/operations/delivery-card";
|
||||
import { deliveryContext } from "@/features/operations/delivery-context";
|
||||
|
||||
const shared: Omit<DeliveryCardItem, "id" | "operationId" | "context"> = {
|
||||
topic: "news.refresh",
|
||||
status: "pending",
|
||||
attempts: 2,
|
||||
actorId: 7,
|
||||
createdAt: "2030-01-01T11:00:00Z",
|
||||
availableAt: "2030-01-01T12:05:00Z",
|
||||
hasError: true,
|
||||
};
|
||||
const records: DeliveryCardItem[] = [
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-authorized-news-1234567890",
|
||||
errorId: "4f3df172-f1ca-4c26-a23e-cb4e6c13e9f2",
|
||||
operationId: "operation-authorized-news-1234567890",
|
||||
context: deliveryContext(
|
||||
"news.update",
|
||||
JSON.stringify({
|
||||
articleId: "9007199254740993",
|
||||
articleTitle: `Community update: ${"SummerFestival".repeat(14)}`,
|
||||
internalNotes: "SERIALIZED_METADATA_SECRET",
|
||||
lastError: "INTERNAL_FILE_PATH_SECRET",
|
||||
}),
|
||||
{ news: true, catalog: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-failed-catalog-1234567890",
|
||||
operationId: "operation-failed-catalog-1234567890",
|
||||
topic: "catalog.refresh",
|
||||
status: "failed",
|
||||
attempts: 5,
|
||||
context: deliveryContext("catalog.bulk.apply", '{"changedCount":9}', {
|
||||
news: false,
|
||||
catalog: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-restricted-news",
|
||||
operationId: "operation-restricted-news",
|
||||
hasError: false,
|
||||
context: deliveryContext(
|
||||
"news.update",
|
||||
'{"articleTitle":"RESTRICTED_NEWS_TITLE","articleId":"99"}',
|
||||
{ news: false, catalog: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-restricted-catalog",
|
||||
operationId: "operation-restricted-catalog",
|
||||
topic: "catalog.refresh",
|
||||
hasError: false,
|
||||
context: deliveryContext("catalog.bulk.apply", '{"changedCount":489}', {
|
||||
news: true,
|
||||
catalog: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-unknown",
|
||||
operationId: "operation-unknown",
|
||||
topic: "future.topic",
|
||||
status: "future.status",
|
||||
actorId: 0,
|
||||
createdAt: "invalid",
|
||||
context: deliveryContext(
|
||||
"future.operation",
|
||||
'{"articleTitle":"UNKNOWN_OPERATION_SECRET"}',
|
||||
{ news: true, catalog: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-completed-export",
|
||||
operationId: "operation-completed-export",
|
||||
topic: "catalog.export.request",
|
||||
status: "done",
|
||||
hasError: false,
|
||||
context: deliveryContext("catalog.bulk.apply", '{"changedCount":4}', {
|
||||
news: false,
|
||||
catalog: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function DeliveriesHarness() {
|
||||
const t = useTranslations("pages.admin.deliveries");
|
||||
return (
|
||||
<section aria-label="Delivery review" className="min-w-0 space-y-4">
|
||||
<p>{t("scope")}</p>
|
||||
<ul className="space-y-3">
|
||||
{records.map((item) => (
|
||||
<li key={item.id}>
|
||||
<DeliveryCard
|
||||
item={item}
|
||||
retry={
|
||||
item.status === "failed" ? (
|
||||
<Button type="button" variant="outline">
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form";
|
||||
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
|
||||
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
|
||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||
import { NotificationCenter } from "@/features/notifications/notification-center";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { themePaletteCss } from "@/lib/theme-css";
|
||||
import { PRESETS } from "@/lib/theme-presets";
|
||||
import messages from "@/messages/en.json";
|
||||
import { AttachmentHarness } from "./attachment-harness";
|
||||
import { DeliveriesHarness } from "./deliveries-harness";
|
||||
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
|
||||
import { fixtureAction } from "./server-actions";
|
||||
import { UnsavedHarness } from "./unsaved-harness";
|
||||
@@ -25,6 +28,11 @@ theme.textContent = themePaletteCss(
|
||||
);
|
||||
document.head.appendChild(theme);
|
||||
const route = window.location.pathname;
|
||||
if (route.includes("notifications")) {
|
||||
document.documentElement.classList.add("app");
|
||||
document.body.classList.add("site-bg");
|
||||
document.body.style.backgroundColor = "var(--color-background)";
|
||||
}
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw Error("Fixture root missing");
|
||||
const rows = [
|
||||
@@ -96,24 +104,42 @@ createRoot(root).render(
|
||||
now={new Date("2030-01-01T12:00:00Z")}
|
||||
messages={messages}
|
||||
>
|
||||
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
|
||||
<h1 className="mb-5 text-2xl font-bold">
|
||||
<main
|
||||
data-admin={route.includes("notifications") ? undefined : ""}
|
||||
className={
|
||||
route.includes("notifications")
|
||||
? "min-w-0 mx-auto max-w-7xl px-3 py-3 md:px-6 md:py-6"
|
||||
: "min-w-0 mx-auto max-w-6xl p-5"
|
||||
}
|
||||
>
|
||||
<h1
|
||||
hidden={route.includes("notifications")}
|
||||
className="mb-5 text-2xl font-bold"
|
||||
>
|
||||
{route.includes("settings")
|
||||
? "CMS settings"
|
||||
: route.includes("tickets")
|
||||
? "Support desk"
|
||||
: route.includes("articles")
|
||||
? "News editor"
|
||||
: "Furniture synchronization"}
|
||||
: route.includes("deliveries-harness")
|
||||
? "Background updates"
|
||||
: "Furniture synchronization"}
|
||||
</h1>
|
||||
{route.includes("unsaved-harness") ? (
|
||||
{route.includes("notifications") ? (
|
||||
<NotificationCenter userId={7} />
|
||||
) : route.includes("deliveries-harness") ? (
|
||||
<DeliveriesHarness />
|
||||
) : route.includes("unsaved-harness") ? (
|
||||
<UnsavedHarness />
|
||||
) : route.includes("studio-harness") ? (
|
||||
<StudioClient
|
||||
source={buildFurniImportSource("it")}
|
||||
initialTree={[]}
|
||||
defaultTranslate={false}
|
||||
/>
|
||||
<>
|
||||
<OrganizeImportsLauncher />
|
||||
<StudioClient
|
||||
source={buildFurniImportSource("it")}
|
||||
defaultTranslate={false}
|
||||
/>
|
||||
</>
|
||||
) : route.includes("attachment-harness") ? (
|
||||
<AttachmentHarness />
|
||||
) : route.includes("events/recovery") ? (
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { FurnitureJobHistory } from "@/components/admin/studio/furniture-jobs";
|
||||
import { useFurnitureJobs } from "@/components/admin/studio/use-furniture-jobs";
|
||||
import { furnitureJobFixture } from "../../../src/test/furniture-jobs-fixtures";
|
||||
|
||||
export function FurnitureJobsHarness() {
|
||||
const [completions, setCompletions] = useState(0);
|
||||
const [submitted, setSubmitted] = useState<string>("");
|
||||
const history = useFurnitureJobs(
|
||||
() => setCompletions((value) => value + 1),
|
||||
true,
|
||||
7,
|
||||
);
|
||||
return (
|
||||
<section aria-label="Import history hook">
|
||||
<button
|
||||
type="button"
|
||||
disabled={history.busy}
|
||||
onClick={async () =>
|
||||
setSubmitted(
|
||||
String(
|
||||
await history.submit(furnitureJobFixture.items, {
|
||||
translate: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Submit import
|
||||
</button>
|
||||
<output aria-label="Submitted">{submitted}</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -32,3 +32,6 @@ export const deleteImportedFurni = (input: unknown) =>
|
||||
fixtureAction("delete-imported-furni", input);
|
||||
export const setFurnidataTranslateEnabled = (input: unknown) =>
|
||||
fixtureAction("furnidata-translation", input);
|
||||
|
||||
export const organizeImportFurni = (input: unknown) =>
|
||||
fixtureAction("organize-import-furni", input);
|
||||
@@ -143,3 +143,46 @@ test("history shows the current phase and validated recovery source", async ({
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("uncertain import submissions keep their identity after a page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const ids: string[] = [];
|
||||
await page.route("**/api/admin/csrf", (route) =>
|
||||
route.fulfill({ json: { token: "a".repeat(64) } }),
|
||||
);
|
||||
await page.route("**/api/admin/studio/import-jobs**", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||
return;
|
||||
}
|
||||
const body = route.request().postDataJSON();
|
||||
ids.push(body.id);
|
||||
if (ids.length === 1) await route.abort("failed");
|
||||
else
|
||||
await route.fulfill({
|
||||
json: { ok: true, job: { ...furnitureJobFixture, id: body.id } },
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/jobs-harness");
|
||||
await page
|
||||
.getByRole("button", { name: "Submit import", exact: true })
|
||||
.click();
|
||||
await expect(page.getByLabel("Submitted", { exact: true })).toHaveText(
|
||||
"false",
|
||||
);
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("button", { name: "Submit import", exact: true })
|
||||
.click();
|
||||
await expect(page.getByLabel("Submitted", { exact: true })).toHaveText(
|
||||
"true",
|
||||
);
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(ids[1]).toBe(ids[0]);
|
||||
await page
|
||||
.getByRole("button", { name: "Submit import", exact: true })
|
||||
.click();
|
||||
await expect.poll(() => ids.length).toBe(3);
|
||||
expect(ids[2]).not.toBe(ids[0]);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("news editor retries a lost response with the same request across reload and clears it after success", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: Array<Record<string, string>> = [];
|
||||
let succeed = false;
|
||||
await page.route("**/fixture/actions/article", async (route) => {
|
||||
requests.push(route.request().postDataJSON());
|
||||
if (!succeed) await route.abort("failed");
|
||||
else
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, data: {} }),
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||
const save = page.getByRole("button", { name: "Save article", exact: true });
|
||||
await save.click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await save.click();
|
||||
await expect.poll(() => requests.length).toBe(2);
|
||||
expect(requests[1].requestKey).toBe(requests[0].requestKey);
|
||||
await page.reload();
|
||||
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||
await save.click();
|
||||
await expect.poll(() => requests.length).toBe(3);
|
||||
expect(requests[2].requestKey).toBe(requests[0].requestKey);
|
||||
const stored = await page.evaluate(() =>
|
||||
sessionStorage.getItem("cms:news:request:current:new"),
|
||||
);
|
||||
expect(stored).not.toContain("Community update");
|
||||
succeed = true;
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
sessionStorage.getItem("cms:news:request:current:new"),
|
||||
),
|
||||
)
|
||||
.toBeNull();
|
||||
expect(requests[3].requestKey).toBe(requests[0].requestKey);
|
||||
await page.locator('input[name="title"]').fill("Another article");
|
||||
await save.click();
|
||||
await expect.poll(() => requests.length).toBe(5);
|
||||
expect(requests[4].requestKey).not.toBe(requests[0].requestKey);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => {
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
test("notification reads persist, new replies are unread and failed preferences stay unchanged", async ({
|
||||
page,
|
||||
}) => {
|
||||
let key = "support:42";
|
||||
const readKeys = new Set<string>();
|
||||
let fail = false;
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
if (fail)
|
||||
return route.fulfill({ status: 503, json: { error: "Unavailable" } });
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.action === "read") readKeys.add(body.key);
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
key,
|
||||
category: "support",
|
||||
title: "Your account request",
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: "2030-01-01T12:00:00Z",
|
||||
read: readKeys.has(key),
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pages: 1,
|
||||
unread: readKeys.has(key) ? 0 : 1,
|
||||
preferences: { support: true, friends: true, events: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/notifications");
|
||||
const mark = page.getByRole("button", { name: "Mark as read" });
|
||||
await expect(mark).toBeVisible();
|
||||
await mark.focus();
|
||||
await expect(mark).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(mark).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText("No unread notifications", { exact: true }),
|
||||
).toBeVisible();
|
||||
key = "support:43";
|
||||
await page.reload();
|
||||
await expect(mark).toBeVisible();
|
||||
fail = true;
|
||||
await mark.click();
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"The change could not be saved. Please try again.",
|
||||
);
|
||||
await expect(mark).toBeVisible();
|
||||
const support = page.getByRole("checkbox", { name: "Support replies" });
|
||||
await support.click();
|
||||
await expect(support).toBeChecked();
|
||||
expect(await support.evaluate((el) => getComputedStyle(el).color)).not.toBe(
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
expect(
|
||||
await support.evaluate((el) => getComputedStyle(el).backgroundColor),
|
||||
).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.screenshot({
|
||||
path: `test-results/notifications-${test.info().project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("notification service failure offers a retry and recovers to empty state", async ({
|
||||
page,
|
||||
}) => {
|
||||
let failing = true;
|
||||
await page.route("**/api/notifications**", (route) =>
|
||||
route.fulfill(
|
||||
failing
|
||||
? { status: 503, json: { error: "Unavailable" } }
|
||||
: {
|
||||
json: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pages: 1,
|
||||
unread: 0,
|
||||
preferences: { support: true, friends: true, events: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
await page.goto("/notifications");
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"Notifications are temporarily unavailable.",
|
||||
);
|
||||
failing = false;
|
||||
await page.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(
|
||||
page.getByText("No notifications in your enabled categories."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("category changes persist and pagination recovers when filtering shrinks the feed", async ({
|
||||
page,
|
||||
}) => {
|
||||
let enabled = true;
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
enabled = route.request().postDataJSON().preferences.support;
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
const requested = Number(
|
||||
new URL(route.request().url()).searchParams.get("page") ?? 1,
|
||||
);
|
||||
const current = enabled ? Math.min(2, requested) : 1;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: enabled
|
||||
? [
|
||||
{
|
||||
key: `support:${current}`,
|
||||
category: "support",
|
||||
title: `Reply page ${current}`,
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: null,
|
||||
read: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
page: current,
|
||||
pages: enabled ? 2 : 1,
|
||||
unread: enabled ? 21 : 0,
|
||||
preferences: { support: enabled, friends: true, events: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/notifications");
|
||||
await page.getByRole("button", { name: "Next", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "Reply page 2" })).toBeVisible();
|
||||
const support = page.getByRole("checkbox", { name: "Support replies" });
|
||||
await support.click();
|
||||
await expect(support).not.toBeChecked();
|
||||
await expect(
|
||||
page.getByText("No notifications in your enabled categories."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Next", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(support).not.toBeChecked();
|
||||
await support.click();
|
||||
await expect(page.getByRole("link", { name: "Reply page 1" })).toBeVisible();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -33,10 +33,28 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
const errors: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
let metadataRequests = 0;
|
||||
let qualityRequests = 0;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === "/api/admin/studio/nitro-quality") {
|
||||
qualityRequests++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
report: {
|
||||
scales: [32, 64].map((size) => ({
|
||||
size,
|
||||
state: "missing",
|
||||
assets: [],
|
||||
animations: [],
|
||||
directions: [],
|
||||
issues: [],
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/admin/studio/inspect") {
|
||||
const { classnames } = request.postDataJSON() as { classnames: string[] };
|
||||
return route.fulfill({
|
||||
@@ -113,6 +131,17 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
await expect(
|
||||
page.getByRole("button", { name: "View Fixture chair", exact: true }),
|
||||
).toBeVisible();
|
||||
const history = page.getByRole("button", {
|
||||
name: "Refresh history",
|
||||
includeHidden: true,
|
||||
});
|
||||
await expect(history).toHaveCount(0);
|
||||
await page.locator("summary").filter({ hasText: "Import history" }).click();
|
||||
await expect(history).toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "Import history" }).click();
|
||||
await expect(history).toBeHidden();
|
||||
await page.locator("summary").filter({ hasText: "Import history" }).click();
|
||||
await expect(history).toBeVisible();
|
||||
expect(metadataRequests).toBe(0);
|
||||
await page
|
||||
.getByRole("button", { name: "View Fixture chair", exact: true })
|
||||
@@ -138,6 +167,20 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
|
||||
).toBeVisible();
|
||||
expect(metadataRequests).toBe(1);
|
||||
expect(qualityRequests).toBe(0);
|
||||
await editor.getByRole("tab", { name: "32 / 64", exact: true }).click();
|
||||
await expect(
|
||||
editor.getByRole("button", { name: "Preview generation", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor.getByText("Scale 32 · Not included", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(qualityRequests).toBe(1);
|
||||
await editor.getByRole("tab", { name: "Visual", exact: true }).click();
|
||||
await expect(
|
||||
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await page
|
||||
@@ -162,3 +205,114 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
expect(mutations).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("Studio loads organize imports on demand and preserves the dialog on reopen", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
let organizeRequests = 0;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/fixture/actions/**", async (route) => {
|
||||
mutations.push(route.request().url());
|
||||
await route.fulfill({
|
||||
status: 405,
|
||||
json: { error: "No mutations expected" },
|
||||
});
|
||||
});
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== "GET") {
|
||||
mutations.push(`${request.method()} ${url.pathname}`);
|
||||
return route.fulfill({
|
||||
status: 405,
|
||||
json: { error: "No mutations expected" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/admin/import/organize") {
|
||||
organizeRequests++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
groups: [
|
||||
{
|
||||
name: "Fixture imports",
|
||||
icon: 1,
|
||||
layout: "default_3x3",
|
||||
total: 1,
|
||||
moved: 0,
|
||||
added: 1,
|
||||
thumbs: [],
|
||||
items: [
|
||||
{
|
||||
itemId: 1,
|
||||
catalogItemId: null,
|
||||
alreadyPlaced: false,
|
||||
itemName: "fixture_table",
|
||||
publicName: "Fixture Table",
|
||||
spriteId: 0,
|
||||
type: "s",
|
||||
interactionType: "default",
|
||||
sourcePageCaption: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
importRootPageId: null,
|
||||
importRootCaption: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/admin/catalog/tree")
|
||||
return route.fulfill({ json: { pages: [] } });
|
||||
if (url.pathname === "/api/admin/import/furni")
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.searchParams.get("action") === "stats"
|
||||
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
|
||||
: {
|
||||
items: [],
|
||||
meta: { currentPage: 1, lastPage: 1, total: 0, 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 } });
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
json: { error: "Unknown fixture endpoint" },
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/studio-harness");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: "Organize imports",
|
||||
exact: true,
|
||||
});
|
||||
await expect(trigger).toBeVisible();
|
||||
expect(organizeRequests).toBe(0);
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: /^Organize imports/ });
|
||||
await expect(dialog).toBeVisible();
|
||||
const filter = dialog.getByPlaceholder("Filter import groups...");
|
||||
await expect(filter).toBeVisible();
|
||||
await expect(dialog.locator('input[value="Fixture imports"]')).toBeVisible();
|
||||
await filter.fill("Fixture");
|
||||
expect(organizeRequests).toBe(1);
|
||||
await testInfo.attach("organize-imports", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await trigger.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(filter).toHaveValue("Fixture");
|
||||
await expect.poll(() => organizeRequests).toBe(2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
expect(mutations).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import mysql from "mysql2/promise";
|
||||
import {
|
||||
GenericContainer,
|
||||
type StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
import {
|
||||
createBackup,
|
||||
drillBackup,
|
||||
IMAGE,
|
||||
} from "../scripts/backup/database.mjs";
|
||||
|
||||
let maria: StartedTestContainer | undefined;
|
||||
let connection: mysql.Connection | undefined;
|
||||
let directory: string;
|
||||
let artifact: string;
|
||||
let database: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
};
|
||||
let roots: Record<string, string>;
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), "cms-backup-integration-"));
|
||||
const password = randomUUID();
|
||||
// A real Docker failure fails this suite; there is no environment-dependent skip.
|
||||
maria = await new GenericContainer(IMAGE)
|
||||
.withCopyContentToContainer([
|
||||
{
|
||||
content: password,
|
||||
target: "/run/secrets/backup-password",
|
||||
mode: 0o600,
|
||||
},
|
||||
])
|
||||
.withEnvironment({
|
||||
MARIADB_ROOT_PASSWORD_FILE: "/run/secrets/backup-password",
|
||||
MARIADB_DATABASE: "cms_backup_fixture",
|
||||
})
|
||||
.withExposedPorts(3306)
|
||||
.withHealthCheck({
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
||||
interval: 1000,
|
||||
timeout: 5000,
|
||||
retries: 90,
|
||||
startPeriod: 1000,
|
||||
})
|
||||
.withWaitStrategy(Wait.forHealthCheck())
|
||||
.withStartupTimeout(120_000)
|
||||
.start();
|
||||
database = {
|
||||
host: maria.getHost(),
|
||||
port: maria.getMappedPort(3306),
|
||||
user: "root",
|
||||
password,
|
||||
database: "cms_backup_fixture",
|
||||
};
|
||||
connection = await mysql.createConnection({
|
||||
...database,
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
await connection.query(
|
||||
"CREATE TABLE parent (id BIGINT UNSIGNED PRIMARY KEY, title VARCHAR(255)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
);
|
||||
await connection.query(
|
||||
"CREATE TABLE child (id INT PRIMARY KEY, parent_id BIGINT UNSIGNED, FOREIGN KEY (parent_id) REFERENCES parent(id)) ENGINE=InnoDB",
|
||||
);
|
||||
await connection.query("INSERT INTO parent VALUES (?, ?)", [
|
||||
"9007199254740993123",
|
||||
"Caffè 🏨 漢字",
|
||||
]);
|
||||
await connection.query("INSERT INTO child VALUES (1, ?)", [
|
||||
"9007199254740993123",
|
||||
]);
|
||||
await connection.query(
|
||||
"CREATE VIEW parent_titles AS SELECT id, title FROM parent",
|
||||
);
|
||||
await connection.query(
|
||||
"CREATE TRIGGER preserve_title BEFORE UPDATE ON parent FOR EACH ROW SET NEW.title = COALESCE(NEW.title, OLD.title)",
|
||||
);
|
||||
await connection.query(
|
||||
"CREATE PROCEDURE count_parents() SELECT COUNT(*) FROM parent",
|
||||
);
|
||||
await connection.query(
|
||||
"CREATE EVENT future_check ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1",
|
||||
);
|
||||
roots = Object.fromEntries(
|
||||
["storage", "nitro", "swf", "gamedata"].map((key) => [
|
||||
key,
|
||||
path.join(directory, key),
|
||||
]),
|
||||
);
|
||||
for (const root of Object.values(roots)) await mkdir(root);
|
||||
await mkdir(path.join(roots.storage, "empty"));
|
||||
await writeFile(
|
||||
path.join(roots.storage, "fixture.bin"),
|
||||
Buffer.from([0, 128, 255, 42]),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(roots.gamedata, "FurnitureData.json"),
|
||||
'{"caption":"Caffè 🏨 漢字"}',
|
||||
);
|
||||
artifact = path.join(directory, "artifact");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await connection?.end();
|
||||
await maria?.stop();
|
||||
if (directory) await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates and restores a real database with UTF-8, large IDs, relationships and stored SQL objects, then rejects corruption", async () => {
|
||||
const manifest = await createBackup({
|
||||
database,
|
||||
roots,
|
||||
output: artifact,
|
||||
writersQuiesced: true,
|
||||
});
|
||||
expect(manifest.database.tables).toHaveLength(2);
|
||||
expect(manifest.database.objects).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "VIEW", name: "parent_titles" },
|
||||
{ kind: "TRIGGER", name: "preserve_title" },
|
||||
{ kind: "PROCEDURE", name: "count_parents" },
|
||||
{ kind: "EVENT", name: "future_check" },
|
||||
]),
|
||||
);
|
||||
const verified = await drillBackup({ artifact });
|
||||
expect(verified.verified).toBe(true);
|
||||
expect(verified.database).toEqual(manifest.database);
|
||||
expect(verified.files).toBe(2);
|
||||
if (!connection) throw Error("Fixture database is unavailable");
|
||||
const [rows] = await connection.query(
|
||||
"SELECT CAST(parent.id AS CHAR) AS id, title FROM parent JOIN child ON child.parent_id = parent.id",
|
||||
);
|
||||
expect(rows).toEqual([{ id: "9007199254740993123", title: "Caffè 🏨 漢字" }]);
|
||||
const sqlPath = path.join(artifact, "database.sql");
|
||||
const sql = await readFile(sqlPath, "utf8");
|
||||
expect(sql).toContain("Caffè 🏨 漢字");
|
||||
const changed = sql.replace("Caffè 🏨 漢字", "Changed content");
|
||||
await writeFile(sqlPath, changed);
|
||||
await expect(drillBackup({ artifact })).rejects.toThrow();
|
||||
// Even with a recomputed file hash, the restored table checksum must disagree.
|
||||
const sqlEntry = manifest.entries.find(
|
||||
(entry: { path: string }) => entry.path === "database.sql",
|
||||
);
|
||||
if (!sqlEntry) throw Error("SQL manifest entry is missing");
|
||||
sqlEntry.bytes = Buffer.byteLength(changed);
|
||||
sqlEntry.sha256 = createHash("sha256").update(changed).digest("hex");
|
||||
await writeFile(
|
||||
path.join(artifact, "manifest.json"),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
await expect(drillBackup({ artifact })).rejects.toThrow(
|
||||
"Restored database inventory",
|
||||
);
|
||||
}, 240_000);
|
||||
@@ -0,0 +1,154 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { request } from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
GenericContainer,
|
||||
type StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const containers: StartedTestContainer[] = [];
|
||||
let temporary: string;
|
||||
let certificate: Buffer;
|
||||
let key: Buffer;
|
||||
let peer: string;
|
||||
let direct: StartedTestContainer;
|
||||
const spoofed = {
|
||||
Host: "hotel.example",
|
||||
"X-Forwarded-For": "198.51.100.40",
|
||||
"X-Real-IP": "198.51.100.41",
|
||||
"CF-Connecting-IP": "198.51.100.42",
|
||||
"X-Real-Client-IP": "198.51.100.43",
|
||||
Forwarded: "for=198.51.100.44",
|
||||
"X-Forwarded-Proto": "http",
|
||||
"X-Forwarded-Host": "attacker.invalid",
|
||||
};
|
||||
function get(container: StartedTestContainer, headers = spoofed) {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
hostname: container.getHost(),
|
||||
port: container.getMappedPort(443),
|
||||
path: "/",
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: 5000,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("Proxy fixture timeout")));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
async function start(template: string, trustedPeer?: string) {
|
||||
let config = await readFile(`deployment/proxy/${template}`, "utf8");
|
||||
if (trustedPeer)
|
||||
config = config.replaceAll(
|
||||
"203.0.113.10/32",
|
||||
`${trustedPeer}/${isIP(trustedPeer) === 6 ? 128 : 32}`,
|
||||
);
|
||||
config = config
|
||||
.replaceAll(
|
||||
"/etc/letsencrypt/live/hotel.example/fullchain.pem",
|
||||
"/etc/nginx/test.pem",
|
||||
)
|
||||
.replaceAll(
|
||||
"/etc/letsencrypt/live/hotel.example/privkey.pem",
|
||||
"/etc/nginx/test.key",
|
||||
);
|
||||
const inherited = template.includes("direct")
|
||||
? "set_real_ip_from 0.0.0.0/0; real_ip_header X-Real-IP;"
|
||||
: "";
|
||||
const fixture = `${inherited}\n${config}\nserver { listen 127.0.0.1:3002; location / { default_type application/json; return 200 '{"xff":"$http_x_forwarded_for","real":"$http_x_real_ip","cf":"$http_cf_connecting_ip","derived":"$http_x_real_client_ip","forwarded":"$http_forwarded","host":"$http_host","proto":"$http_x_forwarded_proto"}'; } }`;
|
||||
const container = await new GenericContainer("nginx:1.28-alpine")
|
||||
.withCopyContentToContainer([
|
||||
{ content: fixture, target: "/etc/nginx/conf.d/default.conf" },
|
||||
{ content: certificate, target: "/etc/nginx/test.pem" },
|
||||
{ content: key, target: "/etc/nginx/test.key" },
|
||||
])
|
||||
.withExposedPorts(443)
|
||||
.withWaitStrategy(Wait.forLogMessage("start worker processes"))
|
||||
.withStartupTimeout(60000)
|
||||
.start();
|
||||
containers.push(container);
|
||||
return container;
|
||||
}
|
||||
beforeAll(async () => {
|
||||
temporary = await mkdtemp(join(tmpdir(), "cms-proxy-integration-"));
|
||||
await exec(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=hotel.example",
|
||||
"-keyout",
|
||||
join(temporary, "key.pem"),
|
||||
"-out",
|
||||
join(temporary, "cert.pem"),
|
||||
],
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
certificate = await readFile(join(temporary, "cert.pem"));
|
||||
key = await readFile(join(temporary, "key.pem"));
|
||||
direct = await start("nginx-direct.example.conf");
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
await Promise.allSettled(containers.map((container) => container.stop()));
|
||||
if (temporary) await rm(temporary, { recursive: true, force: true });
|
||||
});
|
||||
it("replaces forged forwarding headers with the original peer even with an inherited real-IP rule", async () => {
|
||||
const response = await get(direct);
|
||||
expect(response.status).toBe(200);
|
||||
const headers = JSON.parse(response.body);
|
||||
peer = headers.xff;
|
||||
expect(isIP(peer)).toBeGreaterThan(0);
|
||||
expect(Object.values(spoofed)).not.toContain(peer);
|
||||
expect(headers).toEqual({
|
||||
xff: peer,
|
||||
real: peer,
|
||||
cf: "",
|
||||
derived: "",
|
||||
forwarded: "",
|
||||
host: "hotel.example",
|
||||
proto: "https",
|
||||
});
|
||||
});
|
||||
it("rejects a direct client when the remote edge has not been trusted", async () => {
|
||||
const restricted = await start("nginx-trusted-proxy.example.conf");
|
||||
expect((await get(restricted)).status).toBe(403);
|
||||
});
|
||||
it("accepts the verified client address only through an explicitly trusted peer", async () => {
|
||||
if (!peer) peer = JSON.parse((await get(direct)).body).xff;
|
||||
const trusted = await start("nginx-trusted-proxy.example.conf", peer);
|
||||
const response = await get(trusted);
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
xff: spoofed["X-Forwarded-For"],
|
||||
real: spoofed["X-Forwarded-For"],
|
||||
cf: "",
|
||||
derived: "",
|
||||
forwarded: "",
|
||||
host: "hotel.example",
|
||||
proto: "https",
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,47 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { NextConfig } from "next";
|
||||
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
||||
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 = () => {
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||
@@ -36,7 +76,18 @@ const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
compress: true,
|
||||
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() {
|
||||
return [
|
||||
@@ -163,4 +214,9 @@ const nextConfig: NextConfig = {
|
||||
|
||||
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,
|
||||
"type": "module",
|
||||
"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": {
|
||||
"dev": "pnpm assets:editor && next dev",
|
||||
"build": "pnpm assets:editor && next build",
|
||||
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev",
|
||||
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
|
||||
"start": "next start",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
@@ -16,9 +16,9 @@
|
||||
"format": "biome format --write .",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run --coverage.enabled=false",
|
||||
"test:coverage": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
||||
@@ -30,79 +30,82 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"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",
|
||||
"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:audit": "node scripts/audit-cms-translations.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:ui": "playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"performance:report": "node scripts/performance-report.mjs"
|
||||
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test",
|
||||
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
|
||||
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
|
||||
"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",
|
||||
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
"@base-ui/react": "1.9.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@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",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"@tanstack/react-query": "5.104.2",
|
||||
"@tanstack/react-virtual": "3.14.17",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"croner": "10.0.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.45.4",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"isomorphic-dompurify": "^4.1.0",
|
||||
"isomorphic-dompurify": "^4.5.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.1",
|
||||
"lucide-react": "1.41.0",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.55.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.2.0",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.24.3",
|
||||
"next": "16.3.5",
|
||||
"motion": "14.1.0",
|
||||
"music-metadata": "12.1.1",
|
||||
"mysql2": "3.24.5",
|
||||
"next": "16.4.0",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.2",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.87.0",
|
||||
"resend": "6.26.0",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.90.0",
|
||||
"resend": "6.32.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tinymce": "8.9.0",
|
||||
"zod": "4.5.4"
|
||||
"tailwind-merge": "3.7.0",
|
||||
"tinymce": "8.9.3",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/parser": "7.29.8",
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@babel/parser": "8.0.7",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.4.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.7",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"@types/node": "26.6.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.4",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "2.15.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"msw": "^3.0.3",
|
||||
"pino-pretty": "13.2.0",
|
||||
"postcss": "8.5.29",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.13",
|
||||
"testcontainers": "12.2.0",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
"vite": "8.3.4",
|
||||
"vitest": "5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: "**/ui/**",
|
||||
testIgnore: ["**/ui/**", "**/news-real/**"],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@swc/core': true
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
msw: false
|
||||
msw: true
|
||||
protobufjs: true
|
||||
ssh2: true
|
||||
|
||||
minimumReleaseAge: 60
|
||||
|
||||
overrides:
|
||||
# drizzle-kit still uses an obsolete development-server dependency.
|
||||
"@esbuild-kit/core-utils>esbuild": "^0.25.9"
|
||||
"@types/react": "19.2.18"
|
||||
"@types/react-dom": "19.2.7"
|
||||
glob: '^11.0.0'
|
||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
||||
source-map-js: '1.2.2'
|
||||
@@ -1,5 +1,26 @@
|
||||
const CACHE = "atom-v3";
|
||||
const API_CACHE = "atom-api-v3";
|
||||
// Retired service worker — this file no longer serves anything.
|
||||
//
|
||||
// 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());
|
||||
|
||||
@@ -7,61 +28,13 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== CACHE && k !== API_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
|
||||
.then(() => self.registration.unregister())
|
||||
.then(() =>
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
.then((clients) => {
|
||||
for (const client of clients) client.navigate(client.url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/assets/") ||
|
||||
url.pathname.startsWith("/_next/static/")
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.match(req).then(
|
||||
(hit) =>
|
||||
hit ||
|
||||
fetch(req).then((res) => {
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/api/home") ||
|
||||
url.pathname.startsWith("/api/online") ||
|
||||
url.pathname.startsWith("/api/radio/config")
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.open(API_CACHE).then((cache) =>
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => cache.match(req)),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Scripts Documentation
|
||||
|
||||
This directory contains utility scripts for managing the EpicNext-Cms project.
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
| Script | Description |
|
||||
| :--- | :--- |
|
||||
| `dashboard.sh` | Main interactive menu to run all other scripts. |
|
||||
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
|
||||
| `logs.sh` | View logs for cms and mariadb services. |
|
||||
| `backup.sh` | Creates a database backup in `/backups`. |
|
||||
| `db-restore.sh` | Restores a database backup. |
|
||||
| `db-optimize.sh` | Optimizes database tables. |
|
||||
| `docker-prune.sh` | Cleans up unused Docker resources. |
|
||||
| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. |
|
||||
| `check-env.sh` | Validates `.env` against `.env.example`. |
|
||||
| `check-security.sh` | Runs `pnpm audit` and checks for image updates. |
|
||||
| `monitor.sh` | Shows system disk/container resource usage. |
|
||||
| `perf-report.sh` | Runs performance profiling. |
|
||||
| `setup-dev.sh` | Configures development environment. |
|
||||
| `check-updates.sh` | Checks for package and image updates. |
|
||||
| `maintenance.sh` | Toggles maintenance mode flag. |
|
||||
| `doctor.sh` | Runs a system diagnostic report. |
|
||||
| `setup-cron.sh` | Configures automated cron jobs. |
|
||||
| `alert.sh` | Sends notifications to a configured webhook. |
|
||||
|
||||
## Usage
|
||||
Run any script directly, or use the dashboard:
|
||||
```bash
|
||||
./scripts/dashboard.sh
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Script to send alerts via Webhook
|
||||
# Usage: ./alert.sh "Your message"
|
||||
WEBHOOK_URL="https://discord.com/api/webhooks/your_webhook_id/your_webhook_token" # Replace with actual URL
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./alert.sh 'Message'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -H "Content-Type: application/json" -d "{\"content\": \"$1\"}" $WEBHOOK_URL
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup mariadb-turbo database
|
||||
cd "$(dirname "$0")/.."
|
||||
BACKUP_DIR="backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
|
||||
FILE="$BACKUP_DIR/db_$TIMESTAMP.sql"
|
||||
|
||||
echo "Creating database backup in: $FILE"
|
||||
|
||||
# Use environment variables from .env if possible
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
fi
|
||||
|
||||
# Use the credentials from environment or defaults
|
||||
DB_USER=${MARIADB_USER:-cms}
|
||||
DB_PASS=${MARIADB_PASSWORD:-cms}
|
||||
DB_NAME=${MARIADB_DATABASE:-habbo}
|
||||
|
||||
docker compose exec -T mariadb-turbo mariadbdump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$FILE"
|
||||
|
||||
echo "Backup complete: $FILE"
|
||||
@@ -0,0 +1,95 @@
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { safeDirectory } from "./core.mjs";
|
||||
import { createBackup, drillBackup, validateDatabase } from "./database.mjs";
|
||||
|
||||
export async function loadConfig(file) {
|
||||
if (!path.isAbsolute(file)) throw Error("Use an absolute configuration path");
|
||||
await safeDirectory(path.dirname(file));
|
||||
const stat = await lstat(file);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.nlink !== 1 ||
|
||||
(process.platform !== "win32" && stat.mode & 0o077)
|
||||
)
|
||||
throw Error("Configuration must be a private regular file");
|
||||
const config = JSON.parse(await readFile(file, "utf8"));
|
||||
if (
|
||||
Object.keys(config).some((key) => !["database", "roots"].includes(key)) ||
|
||||
!config.roots
|
||||
)
|
||||
throw Error("Invalid backup configuration");
|
||||
validateDatabase(config.database);
|
||||
for (const root of Object.values(config.roots)) {
|
||||
if (typeof root !== "string" || !path.isAbsolute(root))
|
||||
throw Error("Use absolute source paths");
|
||||
const relative = path.relative(path.resolve(root), file);
|
||||
if (
|
||||
!relative ||
|
||||
(!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== ".." &&
|
||||
!path.isAbsolute(relative))
|
||||
)
|
||||
throw Error("Keep the configuration outside every source root");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
const [command, ...options] = args;
|
||||
if (!["create", "drill"].includes(command))
|
||||
throw Error("Choose create or drill");
|
||||
const schema =
|
||||
command === "create"
|
||||
? {
|
||||
config: { type: "string" },
|
||||
output: { type: "string" },
|
||||
"writers-quiesced": { type: "boolean" },
|
||||
}
|
||||
: { artifact: { type: "string" } };
|
||||
const { values } = parseArgs({
|
||||
args: options,
|
||||
options: schema,
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
if (command === "create") {
|
||||
if (
|
||||
!values.config ||
|
||||
!values.output ||
|
||||
values["writers-quiesced"] !== true ||
|
||||
process.platform !== "linux"
|
||||
)
|
||||
throw Error("Create requires Linux, explicit paths and paused writers");
|
||||
const config = await loadConfig(values.config);
|
||||
await createBackup({
|
||||
...config,
|
||||
output: values.output,
|
||||
writersQuiesced: true,
|
||||
});
|
||||
process.stdout.write(
|
||||
"Backup created and checksums verified. Run the isolated drill before relying on it.\n",
|
||||
);
|
||||
} else {
|
||||
if (!values.artifact) throw Error("Provide an artifact");
|
||||
await drillBackup({ artifact: values.artifact });
|
||||
process.stdout.write(
|
||||
"Isolated restore verified; disposable database and files removed.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
) {
|
||||
main().catch(() => {
|
||||
process.stderr.write(
|
||||
"Backup command failed. Check private configuration, prerequisites, paused writers and artifact integrity. No server output was logged.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { loadConfig } from "./cli.mjs";
|
||||
|
||||
it("does not provide a production restore command or target option", () => {
|
||||
for (const args of [
|
||||
["restore", "--target", "production"],
|
||||
["drill", "--artifact", "/missing", "--target", "production"],
|
||||
]) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["scripts/backup/cli.mjs", ...args],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).not.toContain("production");
|
||||
}
|
||||
});
|
||||
|
||||
it("loads only explicit configuration and refuses to include that configuration in file roots", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "cms-backup-cli-"));
|
||||
try {
|
||||
const root = path.join(directory, "storage");
|
||||
await mkdir(root);
|
||||
const config = {
|
||||
database: {
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
user: "fixture",
|
||||
password: "secret-value",
|
||||
database: "fixture",
|
||||
},
|
||||
roots: {
|
||||
storage: root,
|
||||
nitro: path.join(directory, "nitro"),
|
||||
swf: path.join(directory, "swf"),
|
||||
},
|
||||
};
|
||||
const file = path.join(directory, "private.json");
|
||||
await writeFile(file, JSON.stringify(config), { mode: 0o600 });
|
||||
expect(await loadConfig(file)).toEqual(config);
|
||||
const unsafe = path.join(root, "custom-settings.json");
|
||||
await writeFile(unsafe, JSON.stringify(config), { mode: 0o600 });
|
||||
await expect(loadConfig(unsafe)).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const requiredRoots = ["storage", "nitro", "swf"];
|
||||
const allowedRoots = [...requiredRoots, "gamedata"];
|
||||
const fail = () => {
|
||||
throw Error("Backup file validation failed");
|
||||
};
|
||||
const sortedEntries = (entries) =>
|
||||
[...entries].sort((left, right) =>
|
||||
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
||||
);
|
||||
const inside = (parent, child) => {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
!relative ||
|
||||
(!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== ".." &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
};
|
||||
|
||||
export async function safeDirectory(value) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!path.isAbsolute(value) ||
|
||||
value.split(/[\\/]/).includes("..")
|
||||
)
|
||||
fail();
|
||||
const resolved = path.resolve(value);
|
||||
if (resolved === path.parse(resolved).root) fail();
|
||||
for (
|
||||
let current = resolved;
|
||||
current !== path.dirname(current);
|
||||
current = path.dirname(current)
|
||||
) {
|
||||
const stat = await lstat(current);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) fail();
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function validEntry(entry, roots) {
|
||||
const name = entry.path;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
/[\\:]/.test(name) ||
|
||||
[...name].some((character) => character.charCodeAt(0) < 32) ||
|
||||
name.split("/").some((part) => !part || part === "." || part === "..")
|
||||
)
|
||||
fail();
|
||||
if (
|
||||
name !== "database.sql" &&
|
||||
name !== "files" &&
|
||||
!roots.some(
|
||||
(root) => name === `files/${root}` || name.startsWith(`files/${root}/`),
|
||||
)
|
||||
)
|
||||
fail();
|
||||
if (!["file", "directory"].includes(entry.type)) fail();
|
||||
if (
|
||||
entry.type === "file" &&
|
||||
(!Number.isSafeInteger(entry.bytes) ||
|
||||
entry.bytes < 0 ||
|
||||
!/^[a-f0-9]{64}$/.test(entry.sha256))
|
||||
)
|
||||
fail();
|
||||
}
|
||||
|
||||
async function transfer(source, target) {
|
||||
const before = await lstat(source);
|
||||
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) fail();
|
||||
const input = await open(
|
||||
source,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let output;
|
||||
try {
|
||||
const opened = await input.stat();
|
||||
if (opened.dev !== before.dev || opened.ino !== before.ino) fail();
|
||||
if (target) output = await open(target, "wx", 0o600);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for await (const chunk of input.createReadStream({ autoClose: false })) {
|
||||
hash.update(chunk);
|
||||
bytes += chunk.length;
|
||||
if (output) await output.writeFile(chunk);
|
||||
}
|
||||
const after = await input.stat();
|
||||
if (
|
||||
before.size !== bytes ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
after.ctimeMs !== before.ctimeMs
|
||||
)
|
||||
fail();
|
||||
return { bytes, sha256: hash.digest("hex") };
|
||||
} finally {
|
||||
await input.close();
|
||||
await output?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(directory, prefix, destination, rejectSecrets = false) {
|
||||
await safeDirectory(directory);
|
||||
const entries = [{ path: prefix, type: "directory" }];
|
||||
for (const name of (await readdir(directory)).sort()) {
|
||||
if (
|
||||
rejectSecrets &&
|
||||
(/^\.env(?:\.|$)/i.test(name) ||
|
||||
[".docker-install", "persistent.path", "backup.config.json"].includes(
|
||||
name,
|
||||
))
|
||||
)
|
||||
fail();
|
||||
const source = path.join(directory, name);
|
||||
const relative = `${prefix}/${name}`;
|
||||
const stat = await lstat(source);
|
||||
if (stat.isSymbolicLink()) fail();
|
||||
if (stat.isDirectory()) {
|
||||
if (destination)
|
||||
await mkdir(path.join(destination, name), { mode: 0o700 });
|
||||
entries.push(
|
||||
...(await walk(
|
||||
source,
|
||||
relative,
|
||||
destination && path.join(destination, name),
|
||||
rejectSecrets,
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
const content = await transfer(
|
||||
source,
|
||||
destination && path.join(destination, name),
|
||||
);
|
||||
entries.push({ path: relative, type: "file", ...content });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function createArtifact({ roots, output }, writeDatabase) {
|
||||
if (
|
||||
!roots ||
|
||||
requiredRoots.some((key) => !roots[key]) ||
|
||||
Object.keys(roots).some((key) => !allowedRoots.includes(key))
|
||||
)
|
||||
fail();
|
||||
const sources = await Promise.all(Object.values(roots).map(safeDirectory));
|
||||
if (!path.isAbsolute(output) || output.split(/[\\/]/).includes("..")) fail();
|
||||
output = path.join(
|
||||
await safeDirectory(path.dirname(output)),
|
||||
path.basename(output),
|
||||
);
|
||||
for (let index = 0; index < sources.length; index++) {
|
||||
if (
|
||||
inside(sources[index], output) ||
|
||||
inside(output, sources[index]) ||
|
||||
sources.some(
|
||||
(source, other) =>
|
||||
other !== index &&
|
||||
(inside(source, sources[index]) || inside(sources[index], source)),
|
||||
)
|
||||
)
|
||||
fail();
|
||||
}
|
||||
await mkdir(output, { mode: 0o700 }); // Exclusive reservation; never replace an existing artifact.
|
||||
try {
|
||||
await mkdir(path.join(output, "files"), { mode: 0o700 });
|
||||
const entries = [{ path: "files", type: "directory" }];
|
||||
for (const key of Object.keys(roots).sort()) {
|
||||
const destination = path.join(output, "files", key);
|
||||
await mkdir(destination, { mode: 0o700 });
|
||||
entries.push(
|
||||
...(await walk(roots[key], `files/${key}`, destination, true)),
|
||||
);
|
||||
}
|
||||
const database = await writeDatabase(path.join(output, "database.sql"));
|
||||
const sql = await transfer(path.join(output, "database.sql"));
|
||||
if (!sql.bytes) fail();
|
||||
entries.push({ path: "database.sql", type: "file", ...sql });
|
||||
// Detect source changes across the database dump and the file copy interval.
|
||||
for (const key of Object.keys(roots)) {
|
||||
const current = await walk(roots[key], `files/${key}`, undefined, true);
|
||||
if (
|
||||
JSON.stringify(current) !==
|
||||
JSON.stringify(
|
||||
entries.filter(
|
||||
(entry) =>
|
||||
entry.path === `files/${key}` ||
|
||||
entry.path.startsWith(`files/${key}/`),
|
||||
),
|
||||
)
|
||||
)
|
||||
fail();
|
||||
}
|
||||
entries.sort((left, right) =>
|
||||
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
||||
);
|
||||
const manifest = {
|
||||
format: 1,
|
||||
complete: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
roots: Object.keys(roots).sort(),
|
||||
database,
|
||||
entries,
|
||||
};
|
||||
for (const entry of entries) validEntry(entry, manifest.roots);
|
||||
await writeFile(
|
||||
path.join(output, "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
await verifyArtifact(output);
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
await rm(output, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyArtifact(directory) {
|
||||
await safeDirectory(directory);
|
||||
const manifestPath = path.join(directory, "manifest.json");
|
||||
await transfer(manifestPath); // Reject links before parsing the manifest.
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
if (
|
||||
manifest.format !== 1 ||
|
||||
manifest.complete !== true ||
|
||||
!Array.isArray(manifest.roots) ||
|
||||
requiredRoots.some((root) => !manifest.roots.includes(root)) ||
|
||||
manifest.roots.some((root) => !allowedRoots.includes(root)) ||
|
||||
!Array.isArray(manifest.entries)
|
||||
)
|
||||
fail();
|
||||
const names = new Set();
|
||||
for (const entry of manifest.entries) {
|
||||
validEntry(entry, manifest.roots);
|
||||
if (names.has(entry.path)) fail();
|
||||
names.add(entry.path);
|
||||
}
|
||||
if (
|
||||
!manifest.entries.some(
|
||||
(entry) =>
|
||||
entry.path === "database.sql" &&
|
||||
entry.type === "file" &&
|
||||
entry.bytes > 0,
|
||||
)
|
||||
)
|
||||
fail();
|
||||
const actual = (await walk(directory, "artifact"))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.path !== "artifact" && entry.path !== "artifact/manifest.json",
|
||||
)
|
||||
.map((entry) => ({ ...entry, path: entry.path.slice(9) }));
|
||||
if (
|
||||
JSON.stringify(sortedEntries(actual)) !==
|
||||
JSON.stringify(sortedEntries(manifest.entries))
|
||||
)
|
||||
fail();
|
||||
for (const root of manifest.roots)
|
||||
if (
|
||||
!actual.some(
|
||||
(entry) => entry.path === `files/${root}` && entry.type === "directory",
|
||||
)
|
||||
)
|
||||
fail();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function restoreFiles(artifact, destination) {
|
||||
const manifest = await verifyArtifact(artifact);
|
||||
if (
|
||||
!path.isAbsolute(destination) ||
|
||||
inside(path.resolve(artifact), path.resolve(destination))
|
||||
)
|
||||
fail();
|
||||
await safeDirectory(path.dirname(destination));
|
||||
await mkdir(destination, { mode: 0o700 });
|
||||
try {
|
||||
for (const root of manifest.roots) {
|
||||
const target = path.join(destination, root);
|
||||
await mkdir(target, { mode: 0o700 });
|
||||
const entries = await walk(
|
||||
path.join(artifact, "files", root),
|
||||
`files/${root}`,
|
||||
target,
|
||||
);
|
||||
const expectedEntries = manifest.entries.filter(
|
||||
(entry) =>
|
||||
entry.path === `files/${root}` ||
|
||||
entry.path.startsWith(`files/${root}/`),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(sortedEntries(entries)) !==
|
||||
JSON.stringify(sortedEntries(expectedEntries))
|
||||
)
|
||||
fail();
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { createArtifact, restoreFiles, verifyArtifact } from "./core.mjs";
|
||||
|
||||
let directory;
|
||||
let roots;
|
||||
let output;
|
||||
const database = { database: "fixture", objects: [], tables: [] };
|
||||
const dump = async (target) => {
|
||||
await writeFile(target, "CREATE DATABASE fixture;\n");
|
||||
return database;
|
||||
};
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), "cms-backup-test-"));
|
||||
roots = Object.fromEntries(
|
||||
["storage", "nitro", "swf"].map((key) => [key, path.join(directory, key)]),
|
||||
);
|
||||
for (const root of Object.values(roots)) await mkdir(root);
|
||||
await mkdir(path.join(roots.storage, "empty"));
|
||||
await writeFile(
|
||||
path.join(roots.storage, "image.bin"),
|
||||
Buffer.from([0, 255, 128, 1]),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(roots.nitro, "furniture.json"),
|
||||
'{"caption":"Caffè 🏨"}',
|
||||
);
|
||||
output = path.join(directory, "artifact");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies exact bytes and empty directories, records hashes without source paths, and restores only into a new directory", async () => {
|
||||
const manifest = await createArtifact({ roots, output }, dump);
|
||||
expect(manifest.complete).toBe(true);
|
||||
expect(JSON.stringify(manifest)).not.toContain(directory);
|
||||
expect(
|
||||
manifest.entries.find((entry) => entry.path === "files/storage/image.bin")
|
||||
.sha256,
|
||||
).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(await verifyArtifact(output)).toEqual(manifest);
|
||||
const restored = path.join(directory, "restored");
|
||||
await restoreFiles(output, restored);
|
||||
expect(await readFile(path.join(restored, "storage/image.bin"))).toEqual(
|
||||
Buffer.from([0, 255, 128, 1]),
|
||||
);
|
||||
expect(await readdir(path.join(restored, "storage/empty"))).toEqual([]);
|
||||
await expect(restoreFiles(output, restored)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(["files/storage/image.bin", "database.sql"])(
|
||||
"rejects modified %s before restoring files",
|
||||
async (entry) => {
|
||||
await createArtifact({ roots, output }, dump);
|
||||
await writeFile(path.join(output, entry), "tampered");
|
||||
const restored = path.join(directory, "restored");
|
||||
await expect(restoreFiles(output, restored)).rejects.toThrow();
|
||||
await expect(readdir(restored)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unlisted files and malicious manifest traversal", async () => {
|
||||
await createArtifact({ roots, output }, dump);
|
||||
const extra = path.join(output, "unexpected.txt");
|
||||
await writeFile(extra, "extra");
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
await rm(extra);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(output, "manifest.json"), "utf8"),
|
||||
);
|
||||
manifest.entries[0].path = "../escape";
|
||||
await writeFile(path.join(output, "manifest.json"), JSON.stringify(manifest));
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an incomplete artifact and leaves no final artifact after dump failure", async () => {
|
||||
await expect(
|
||||
createArtifact({ roots, output }, async () => {
|
||||
throw Error("database failed");
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(readdir(output)).rejects.toThrow();
|
||||
await mkdir(output);
|
||||
await writeFile(path.join(output, "database.sql"), "partial");
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses overwrites and output inside a source root", async () => {
|
||||
await mkdir(output);
|
||||
await writeFile(path.join(output, "keep"), "original");
|
||||
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
|
||||
expect(await readFile(path.join(output, "keep"), "utf8")).toBe("original");
|
||||
await expect(
|
||||
createArtifact({ roots, output: path.join(roots.storage, "backup") }, dump),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects omitted roots, overlapping roots, and secret configuration files", async () => {
|
||||
await expect(
|
||||
createArtifact({ roots: { storage: roots.storage }, output }, dump),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
createArtifact({ roots: { ...roots, nitro: roots.storage }, output }, dump),
|
||||
).rejects.toThrow();
|
||||
await writeFile(path.join(roots.storage, ".env"), "DATABASE_URL=secret");
|
||||
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects symbolic links in source trees and artifact trees", async () => {
|
||||
const outside = path.join(directory, "outside");
|
||||
await mkdir(outside);
|
||||
await writeFile(path.join(outside, "secret"), "private");
|
||||
const link = path.join(roots.storage, "linked");
|
||||
await symlink(
|
||||
outside,
|
||||
link,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
|
||||
await rm(link);
|
||||
await createArtifact({ roots, output }, dump);
|
||||
await symlink(
|
||||
outside,
|
||||
path.join(output, "files/storage/linked"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects files changed while the database is being dumped", async () => {
|
||||
await expect(
|
||||
createArtifact({ roots, output }, async (target) => {
|
||||
await writeFile(
|
||||
path.join(roots.storage, "image.bin"),
|
||||
"changed during backup",
|
||||
);
|
||||
return dump(target);
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(readdir(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("restores a directory whose prefix also appears in a sibling filename", async () => {
|
||||
await mkdir(path.join(roots.storage, "foo"));
|
||||
await writeFile(path.join(roots.storage, "foo/file"), "nested");
|
||||
await writeFile(path.join(roots.storage, "foo.json"), "sibling");
|
||||
await createArtifact({ roots, output }, dump);
|
||||
const destination = path.join(directory, "restored");
|
||||
await restoreFiles(output, destination);
|
||||
expect(
|
||||
await readFile(path.join(destination, "storage/foo/file"), "utf8"),
|
||||
).toBe("nested");
|
||||
expect(
|
||||
await readFile(path.join(destination, "storage/foo.json"), "utf8"),
|
||||
).toBe("sibling");
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { chmod, mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { createArtifact, restoreFiles, verifyArtifact } from "./core.mjs";
|
||||
|
||||
export const IMAGE = "mariadb:11.4.5";
|
||||
const failure = () =>
|
||||
Error(
|
||||
"MariaDB backup operation failed; no credentials or server output were logged",
|
||||
);
|
||||
const quote = (value) =>
|
||||
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
const literal = (value) => `'${value.replaceAll("'", "''")}'`;
|
||||
const identifier = (value) => {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
[...value].some((character) => character.charCodeAt(0) < 32)
|
||||
)
|
||||
throw failure();
|
||||
return `\`${value.replaceAll("`", "``")}\``;
|
||||
};
|
||||
|
||||
export function validateDatabase(config) {
|
||||
if (
|
||||
!config ||
|
||||
Object.keys(config).some(
|
||||
(key) => !["host", "port", "user", "password", "database"].includes(key),
|
||||
) ||
|
||||
!Number.isInteger(config.port) ||
|
||||
config.port < 1 ||
|
||||
config.port > 65535
|
||||
)
|
||||
throw failure();
|
||||
for (const key of ["host", "user", "password", "database"])
|
||||
if (
|
||||
typeof config[key] !== "string" ||
|
||||
!config[key] ||
|
||||
[...config[key]].some((character) => character.charCodeAt(0) < 32)
|
||||
)
|
||||
throw failure();
|
||||
if (
|
||||
!/^[a-zA-Z0-9_]+$/.test(config.database) ||
|
||||
["mysql", "sys", "information_schema", "performance_schema"].includes(
|
||||
config.database.toLowerCase(),
|
||||
)
|
||||
)
|
||||
throw failure();
|
||||
return config;
|
||||
}
|
||||
|
||||
export function credentialOptions(config) {
|
||||
validateDatabase(config);
|
||||
return `[client]\nhost=${quote(config.host)}\nport=${config.port}\nuser=${quote(config.user)}\npassword=${quote(config.password)}\nprotocol=tcp\ndefault-character-set=utf8mb4\n`;
|
||||
}
|
||||
|
||||
export function dockerEnvironment(source = process.env) {
|
||||
const keys = [
|
||||
"PATH",
|
||||
"Path",
|
||||
"SystemRoot",
|
||||
"SystemDrive",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"DOCKER_HOST",
|
||||
"DOCKER_CONTEXT",
|
||||
"DOCKER_CONFIG",
|
||||
"DOCKER_TLS_VERIFY",
|
||||
"DOCKER_CERT_PATH",
|
||||
];
|
||||
return Object.fromEntries(
|
||||
keys
|
||||
.filter((key) => source[key] !== undefined)
|
||||
.map((key) => [key, source[key]]),
|
||||
);
|
||||
}
|
||||
|
||||
async function docker(args, { input, output, timeout = 1_800_000 } = {}) {
|
||||
const file = output ? await open(output, "wx", 0o600) : undefined;
|
||||
try {
|
||||
const child = spawn("docker", args, {
|
||||
env: dockerEnvironment(),
|
||||
windowsHide: true,
|
||||
stdio: [input ? "pipe" : "ignore", file ? file.fd : "pipe", "ignore"],
|
||||
});
|
||||
let stdout = "";
|
||||
if (!file)
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
if (stdout.length > 16 * 1024 * 1024) child.kill();
|
||||
});
|
||||
const timer = setTimeout(() => child.kill(), timeout);
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
child.once("error", () => reject(failure()));
|
||||
child.once("close", (code) =>
|
||||
code === 0 ? resolve(stdout) : reject(failure()),
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.all([
|
||||
completion,
|
||||
input
|
||||
? pipeline(createReadStream(input), child.stdin)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
return stdout;
|
||||
} catch {
|
||||
child.kill();
|
||||
throw failure();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} finally {
|
||||
await file?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function credentials(config, use) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "cms-backup-private-"));
|
||||
try {
|
||||
await chmod(directory, 0o700);
|
||||
await writeFile(
|
||||
path.join(directory, "client.cnf"),
|
||||
credentialOptions(config),
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
return await use(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function sourceClient(directory, program, args, options) {
|
||||
const name = `cms-backup-client-${randomUUID()}`;
|
||||
try {
|
||||
return await docker(
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"--network",
|
||||
"host",
|
||||
"--mount",
|
||||
`type=bind,src=${directory},dst=/run/backup,readonly`,
|
||||
"--entrypoint",
|
||||
program,
|
||||
IMAGE,
|
||||
"--defaults-file=/run/backup/client.cnf",
|
||||
...args,
|
||||
],
|
||||
options,
|
||||
);
|
||||
} finally {
|
||||
await docker(["rm", "-f", "-v", name], { timeout: 15_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function inventory(query, database) {
|
||||
const schema = literal(database);
|
||||
const records = await query(
|
||||
`SELECT JSON_OBJECT('kind', TABLE_TYPE, 'name', TABLE_NAME, 'engine', ENGINE) FROM information_schema.TABLES WHERE TABLE_SCHEMA=${schema} UNION ALL SELECT JSON_OBJECT('kind', ROUTINE_TYPE, 'name', ROUTINE_NAME, 'engine', NULL) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA=${schema} UNION ALL SELECT JSON_OBJECT('kind', 'TRIGGER', 'name', TRIGGER_NAME, 'engine', NULL) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=${schema} UNION ALL SELECT JSON_OBJECT('kind', 'EVENT', 'name', EVENT_NAME, 'engine', NULL) FROM information_schema.EVENTS WHERE EVENT_SCHEMA=${schema}`,
|
||||
);
|
||||
const objects = records
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
if (
|
||||
objects.some(
|
||||
(object) => object.kind === "BASE TABLE" && object.engine !== "InnoDB",
|
||||
)
|
||||
)
|
||||
throw Error("Only InnoDB tables are supported by this snapshot workflow");
|
||||
objects.sort((a, b) =>
|
||||
`${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`, "en"),
|
||||
);
|
||||
const tables = [];
|
||||
for (const table of objects.filter(
|
||||
(object) => object.kind === "BASE TABLE",
|
||||
)) {
|
||||
const qualified = `${identifier(database)}.${identifier(table.name)}`;
|
||||
const result = (
|
||||
await query(
|
||||
`SELECT CAST(COUNT(*) AS CHAR) FROM ${qualified}; CHECKSUM TABLE ${qualified} EXTENDED;`,
|
||||
)
|
||||
)
|
||||
.trim()
|
||||
.split("\n");
|
||||
const checksum = result[1]?.split("\t").at(-1)?.trim();
|
||||
if (!/^\d+$/.test(result[0]) || !/^\d+$/.test(checksum ?? ""))
|
||||
throw failure();
|
||||
tables.push({ name: table.name, rows: result[0], checksum });
|
||||
}
|
||||
if (!tables.length)
|
||||
throw Error("The backup database must contain at least one InnoDB table");
|
||||
return {
|
||||
database,
|
||||
objects: objects.map(({ kind, name }) => ({ kind, name })),
|
||||
tables,
|
||||
};
|
||||
}
|
||||
|
||||
const queryArgs = (sql) => [
|
||||
"--batch",
|
||||
"--raw",
|
||||
"--skip-column-names",
|
||||
"--execute",
|
||||
sql,
|
||||
];
|
||||
|
||||
export async function createBackup({
|
||||
database,
|
||||
roots,
|
||||
output,
|
||||
writersQuiesced,
|
||||
}) {
|
||||
if (writersQuiesced !== true)
|
||||
throw Error(
|
||||
"Pause all database and file writers, then pass --writers-quiesced",
|
||||
);
|
||||
validateDatabase(database);
|
||||
return credentials(database, (directory) =>
|
||||
createArtifact({ roots, output }, async (target) => {
|
||||
const query = (sql) => sourceClient(directory, "mariadb", queryArgs(sql));
|
||||
const before = await inventory(query, database.database);
|
||||
await sourceClient(
|
||||
directory,
|
||||
"mariadb-dump",
|
||||
[
|
||||
"--single-transaction",
|
||||
"--quick",
|
||||
"--skip-lock-tables",
|
||||
"--routines",
|
||||
"--events",
|
||||
"--triggers",
|
||||
"--hex-blob",
|
||||
"--tz-utc",
|
||||
"--skip-comments",
|
||||
"--max-allowed-packet=512M",
|
||||
"--databases",
|
||||
database.database,
|
||||
],
|
||||
{ output: target },
|
||||
);
|
||||
const after = await inventory(query, database.database);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after))
|
||||
throw Error(
|
||||
"Database changed during backup; keep every writer paused and retry",
|
||||
);
|
||||
return before;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function drillBackup({ artifact }) {
|
||||
const expected = await verifyArtifact(artifact);
|
||||
const database = expected.database?.database;
|
||||
const password = randomUUID();
|
||||
const config = validateDatabase({
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
user: "root",
|
||||
password,
|
||||
database,
|
||||
});
|
||||
return credentials(config, async (directory) => {
|
||||
const name = `cms-backup-drill-${randomUUID()}`;
|
||||
const query = (sql) =>
|
||||
docker([
|
||||
"exec",
|
||||
name,
|
||||
"mariadb",
|
||||
"--defaults-file=/run/backup/client.cnf",
|
||||
...queryArgs(sql),
|
||||
]);
|
||||
let started = false;
|
||||
try {
|
||||
await restoreFiles(artifact, path.join(directory, "restored"));
|
||||
await writeFile(path.join(directory, "password"), password, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
await docker(
|
||||
[
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--label",
|
||||
"cms.backup-drill=true",
|
||||
"--network",
|
||||
"none",
|
||||
"--mount",
|
||||
`type=bind,src=${directory},dst=/run/backup,readonly`,
|
||||
"-e",
|
||||
"MARIADB_ROOT_PASSWORD_FILE=/run/backup/password",
|
||||
IMAGE,
|
||||
"--character-set-server=utf8mb4",
|
||||
"--collation-server=utf8mb4_unicode_ci",
|
||||
"--event-scheduler=OFF",
|
||||
"--max-allowed-packet=512M",
|
||||
],
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
started = true;
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 90; attempt++) {
|
||||
try {
|
||||
await docker(
|
||||
[
|
||||
"exec",
|
||||
name,
|
||||
"mariadb-admin",
|
||||
"--defaults-file=/run/backup/client.cnf",
|
||||
"ping",
|
||||
"--silent",
|
||||
],
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
ready = true;
|
||||
break;
|
||||
} catch {
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
if (!ready) throw failure();
|
||||
// Import over stdin; no network, published port or production filesystem mount.
|
||||
await docker(
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
name,
|
||||
"mariadb",
|
||||
"--defaults-file=/run/backup/client.cnf",
|
||||
"--binary-mode",
|
||||
],
|
||||
{ input: path.join(artifact, "database.sql") },
|
||||
);
|
||||
const actual = await inventory(query, database);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected.database))
|
||||
throw Error("Restored database inventory does not match the backup");
|
||||
return {
|
||||
verified: true,
|
||||
database: actual,
|
||||
files: expected.entries.filter(
|
||||
(entry) => entry.type === "file" && entry.path.startsWith("files/"),
|
||||
).length,
|
||||
};
|
||||
} finally {
|
||||
const cleanup = docker(["rm", "-f", "-v", name], { timeout: 30_000 });
|
||||
if (started) await cleanup;
|
||||
else await cleanup.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
credentialOptions,
|
||||
dockerEnvironment,
|
||||
validateDatabase,
|
||||
} from "./database.mjs";
|
||||
|
||||
it("quotes credentials without permitting option-file injection", () => {
|
||||
const config = {
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
user: "backup",
|
||||
password: 'quote"slash\\secret',
|
||||
database: "cms",
|
||||
};
|
||||
expect(credentialOptions(config)).toContain(
|
||||
'password="quote\\"slash\\\\secret"',
|
||||
);
|
||||
expect(() =>
|
||||
credentialOptions({ ...config, password: "secret\n[client]" }),
|
||||
).toThrow();
|
||||
expect(() => validateDatabase({ ...config, database: "mysql" })).toThrow();
|
||||
expect(() =>
|
||||
validateDatabase({ ...config, database: "cms; DROP DATABASE other" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("passes only explicit Docker runtime variables to subprocesses", () => {
|
||||
expect(
|
||||
dockerEnvironment({
|
||||
PATH: "/bin",
|
||||
DOCKER_HOST: "unix:///run/docker.sock",
|
||||
DATABASE_URL: "private",
|
||||
MARIADB_PWD: "secret",
|
||||
NODE_OPTIONS: "--inspect",
|
||||
HOME: "/operator",
|
||||
}),
|
||||
).toEqual({
|
||||
PATH: "/bin",
|
||||
DOCKER_HOST: "unix:///run/docker.sock",
|
||||
HOME: "/operator",
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check if .env has all keys from .env.example
|
||||
cd "$(dirname "$0")/.."
|
||||
if [ ! -f .env ]; then echo ".env missing"; exit 1; fi
|
||||
if [ ! -f .env.example ]; then echo ".env.example missing, cannot compare."; exit 0; fi
|
||||
|
||||
# Extract keys and compare
|
||||
env_keys=$(cut -d'=' -f1 .env | sort)
|
||||
ex_keys=$(cut -d'=' -f1 .env.example | sort)
|
||||
|
||||
missing=$(comm -23 <(echo "$ex_keys") <(echo "$env_keys"))
|
||||
if [ -n "$missing" ]; then
|
||||
echo "Missing env keys:"
|
||||
echo "$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "All env keys present."
|
||||
@@ -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")) {
|
||||
assert.equal(
|
||||
process.versions.node,
|
||||
pinnedVersion,
|
||||
"the active Node.js runtime must match .nvmrc",
|
||||
const active = process.versions.node;
|
||||
const upperBound = `${major + 1}.0.0`;
|
||||
assert.ok(
|
||||
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,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Security scan: check dependencies and Docker images
|
||||
cd "$(dirname "$0")/.."
|
||||
echo "--- Running security scan ---"
|
||||
echo "Checking npm dependencies..."
|
||||
pnpm audit --audit-level=high
|
||||
|
||||
echo "Checking outdated images (if any)..."
|
||||
docker compose pull --dry-run
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check for dependency and image updates
|
||||
cd "$(dirname "$0")/.."
|
||||
echo "Checking for package updates..."
|
||||
pnpm outdated
|
||||
|
||||
echo "Checking for newer Docker images..."
|
||||
docker compose pull --dry-run
|
||||
@@ -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
|
||||
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() {
|
||||
local head
|
||||
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
|
||||
@@ -38,18 +67,280 @@ check_current() {
|
||||
}
|
||||
|
||||
healthy() {
|
||||
local port="${1:-$slot_a_port}"
|
||||
local attempt
|
||||
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
|
||||
done
|
||||
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() {
|
||||
local status=$?
|
||||
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
|
||||
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
|
||||
@@ -91,12 +382,40 @@ fi
|
||||
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
|
||||
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 exec playwright install chromium
|
||||
|
||||
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" .
|
||||
check_current
|
||||
# Read reports from the already-built image; do not start an extra application.
|
||||
@@ -115,77 +434,157 @@ if report_container="$(docker create --entrypoint /bin/true "$image")" && [ -n "
|
||||
else
|
||||
echo "Warning: could not extract build performance report" >&2
|
||||
fi
|
||||
# Exercise the candidate with disposable services before any live migration or cutover.
|
||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||
check_current
|
||||
pnpm db:migrate
|
||||
check_current
|
||||
|
||||
# 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
|
||||
# ── Blue/green: bepaal wie live is en waar de kandidaat mag starten ──
|
||||
if detect_blue_green; then
|
||||
blue_green=1
|
||||
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
|
||||
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
|
||||
echo "Live release keeps serving port $old_port; candidate starts on port $new_port"
|
||||
# Rollback-evidence vastleggen voordat er iets wordt veranderd.
|
||||
if docker inspect "$old_container" >/dev/null 2>&1; then
|
||||
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
|
||||
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
|
||||
if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
|
||||
exit 1
|
||||
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
|
||||
# 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
|
||||
# The application writes everything under storage/ as uid 33, but storage is a
|
||||
# host bind so the image's own ownership is irrelevant. Any path that is not
|
||||
# uid 33 makes the write fail with EACCES, and because most of these writes are
|
||||
# inside a try/catch the failure is silent: the avatar cache just never fills
|
||||
# (each avatar becomes a fresh live render) and the catalog export reports
|
||||
# "delivery failed" while the emulator never receives the update. The old code
|
||||
# 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
|
||||
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:latest
|
||||
cutover_started=0
|
||||
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
|
||||
|
||||
|
||||
echo "Deployment verified: $sha"
|
||||
# Een deploy kan de game client, furnidata (gamedata), camera en public API data
|
||||
# verversen. Laat de Cloudflare edge-cache van die tags los (best-effort: alleen
|
||||
# wanneer er een echte token + zone-id geconfigureerd is; no-op anders).
|
||||
if [ -x "$deploy_dir/scripts/cf-purge.sh" ]; then
|
||||
bash "$deploy_dir/scripts/cf-purge.sh" cms-public cms-gamedata cms-client cms-camera || true
|
||||
fi
|
||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||
while IFS= read -r tag; do
|
||||
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and browser-test a branch candidate using only disposable services.
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
sha="$(git rev-parse HEAD)"
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid preflight commit" >&2; exit 1; }
|
||||
temporary="$(mktemp -d "${TMPDIR:-/tmp}/cms-preflight.XXXXXXXXXX")"
|
||||
suffix="${temporary##*.}"
|
||||
image="epicnext-cms:preflight-$sha-$suffix"
|
||||
build_attempted=0
|
||||
|
||||
finish() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if [ "$build_attempted" -eq 1 ]; then
|
||||
# Remove this run's tag only. Never prune, force-remove or touch release tags.
|
||||
if ! docker image rm "$image"; then
|
||||
if [ "$status" -eq 0 ]; then status=1; fi
|
||||
fi
|
||||
fi
|
||||
# The private directory contains no files; never recursively delete a path.
|
||||
if ! rmdir -- "$temporary"; then
|
||||
if [ "$status" -eq 0 ]; then status=1; fi
|
||||
fi
|
||||
if [ "$status" -eq 0 ]; then echo "Branch preflight verified: $sha"; fi
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
[[ "$temporary" = /* && -d "$temporary" && ! -L "$temporary" && "$suffix" =~ ^[a-zA-Z0-9]{10}$ ]] || {
|
||||
echo "Invalid private preflight directory" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install chromium
|
||||
export NEWS_E2E_IMAGE="$image"
|
||||
export NEWS_E2E_RELEASE="$sha"
|
||||
build_attempted=1
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dashboard for quick script access
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
|
||||
echo "=== EpicNext-Cms Dashboard ==="
|
||||
echo "Select a script to run:"
|
||||
echo "1) Deploy (./deploy.sh)"
|
||||
echo "2) Logs (./scripts/logs.sh)"
|
||||
echo "3) Backup (./scripts/backup.sh)"
|
||||
echo "4) Prune (./scripts/docker-prune.sh)"
|
||||
echo "5) Verify (./scripts/verify-deploy.sh)"
|
||||
echo "6) Check Env (./scripts/check-env.sh)"
|
||||
echo "7) Optimize DB (./scripts/db-optimize.sh)"
|
||||
echo "8) Security Scan (./scripts/check-security.sh)"
|
||||
echo "9) Monitor (./scripts/monitor.sh)"
|
||||
echo "10) Perf Report (./scripts/perf-report.sh)"
|
||||
echo "11) Setup Dev (./scripts/setup-dev.sh)"
|
||||
echo "12) Check Updates (./scripts/check-updates.sh)"
|
||||
echo "13) Restore DB (./scripts/db-restore.sh)"
|
||||
echo "14) Maintenance Mode (./scripts/maintenance.sh)"
|
||||
echo "15) Doctor (./scripts/doctor.sh)"
|
||||
echo "16) Setup Cron (./scripts/setup-cron.sh)"
|
||||
echo "17) Send Alert (./scripts/alert.sh)"
|
||||
echo "q) Quit"
|
||||
|
||||
read -p "Choice: " choice
|
||||
|
||||
case $choice in
|
||||
1) ./deploy.sh ;;
|
||||
2) $SCRIPT_DIR/logs.sh ;;
|
||||
3) $SCRIPT_DIR/backup.sh ;;
|
||||
4) $SCRIPT_DIR/docker-prune.sh ;;
|
||||
5) $SCRIPT_DIR/verify-deploy.sh ;;
|
||||
6) $SCRIPT_DIR/check-env.sh ;;
|
||||
7) $SCRIPT_DIR/db-optimize.sh ;;
|
||||
8) $SCRIPT_DIR/check-security.sh ;;
|
||||
9) $SCRIPT_DIR/monitor.sh ;;
|
||||
10) $SCRIPT_DIR/perf-report.sh ;;
|
||||
11) $SCRIPT_DIR/setup-dev.sh ;;
|
||||
12) $SCRIPT_DIR/check-updates.sh ;;
|
||||
13) $SCRIPT_DIR/db-restore.sh ;;
|
||||
14) $SCRIPT_DIR/maintenance.sh ;;
|
||||
15) $SCRIPT_DIR/doctor.sh ;;
|
||||
16) $SCRIPT_DIR/setup-cron.sh ;;
|
||||
17) $SCRIPT_DIR/alert.sh ;;
|
||||
q) exit 0 ;;
|
||||
*) echo "Invalid choice" ;;
|
||||
esac
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Optimize all tables in mariadb-turbo
|
||||
cd "$(dirname "$0")/.."
|
||||
[ -f .env ] && source .env
|
||||
DB_USER=${MARIADB_USER:-cms}
|
||||
DB_PASS=${MARIADB_PASSWORD:-cms}
|
||||
DB_NAME=${MARIADB_DATABASE:-habbo}
|
||||
|
||||
echo "Optimizing database tables..."
|
||||
# Get all tables
|
||||
tables=$(docker compose exec -T mariadb-turbo mariadb -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "SHOW TABLES" -s)
|
||||
# Optimize them
|
||||
for table in $tables; do
|
||||
echo "Optimizing $table..."
|
||||
docker compose exec -T mariadb-turbo mariadb -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "OPTIMIZE TABLE $table"
|
||||
done
|
||||
echo "Optimization complete."
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore database from a backup
|
||||
cd "$(dirname "$0")/.."
|
||||
BACKUP_DIR="backups"
|
||||
|
||||
echo "Available backups:"
|
||||
ls -1 "$BACKUP_DIR"
|
||||
|
||||
read -p "Enter filename to restore: " filename
|
||||
|
||||
if [ -f "$BACKUP_DIR/$filename" ]; then
|
||||
echo "Restoring $filename..."
|
||||
[ -f .env ] && source .env
|
||||
DB_USER=${MARIADB_USER:-cms}
|
||||
DB_PASS=${MARIADB_PASSWORD:-cms}
|
||||
DB_NAME=${MARIADB_DATABASE:-habbo}
|
||||
|
||||
docker compose exec -T mariadb-turbo mariadb -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$BACKUP_DIR/$filename"
|
||||
echo "Restore complete."
|
||||
else
|
||||
echo "File not found."
|
||||
fi
|
||||