1 Commits
Author SHA1 Message Date
remco 4b19c5da8b Add renovate.json
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (pull_request) Failing after 26s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-02 15:00:12 +00:00
528 changed files with 11447 additions and 35374 deletions

No files matched your search

+66 -33
View File
@@ -18,21 +18,6 @@ CACHE_MEMORY_MAX_ENTRIES=2000
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
IMAGING_CACHE_MAX_ENTRIES=20000
# --- CACHE TTL TUNING (seconds; defaults apply when unset) ---
# Higher = less DB load but staler data. Publishing news rotates its cache
# revision immediately, so news TTLs only delay content that bypasses that path.
CMS_HOME_PAYLOAD_TTL_SEC=15
CMS_NEWS_LIST_TTL_SEC=300
CMS_NEWS_ARCHIVE_TTL_SEC=60
CMS_SEARCH_TTL_SEC=30
CMS_ONLINE_COUNT_TTL_SEC=10
CMS_COUNTERS_TTL_SEC=300
CMS_SITE_SETTINGS_TTL_SEC=300
CMS_SITE_SETTINGS_MEMORY_TTL_SEC=60
# Boot-time cache warm-up (primes hot keys after each deploy). Set false on
# tiny hosts where the boot COUNT(*) burst is undesirable.
CMS_WARMUP_ENABLED=true
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
NODE_ENV=production
PORT=3002
@@ -93,17 +78,72 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
# Override for tests/staging (production uses the public endpoint by default).
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
# --- CAPTCHA (env fallback; admin settings win when set) ---
# Enable a captcha on register/login/forgot/resend without touching the DB.
# "none" (default), "turnstile", "recaptcha" or "hcaptcha". Example:
# CAPTCHA_PROVIDER=turnstile
CAPTCHA_PROVIDER=
# Provider keys. The SITE key is public (it ships to every browser), the
# SECRET is server-only and must never be committed — set it on the host or as
# a masked CI/CD variable. Cloudflare Turnstile keys:
# https://dash.cloudflare.com/turnstile
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# --- CROWDSEC API (community reputation auto-block, optional) ---
# Free CTI API key: https://app.crowdsec.net/ → Settings → CTI API Keys.
# When set, the anti-DDoS gate checks the community reputation of repeat
# offenders (CTI GET /smoke/{ip}) and immediately hard-blocks known-bad IPs.
# Lookups only happen for IPs that already tripped a rate bucket and are
# cached in Redis for 1h, so quota usage stays minimal.
CROWDSEC_API_KEY=
# Runtime toggle for reputation-based auto-blocking (also overridable live
# from the admin panel). Requires CROWDSEC_API_KEY.
CROWDSEC_AUTO_BLOCK_ENABLED=true
# Minimum malevolence score 0-5 (CrowdSec scale; 4-5 = "malicious") before an
# IP is treated as known-bad. IPs with false-positive tags are never blocked.
CROWDSEC_BLOCK_SCORE=4
# How long a CrowdSec-confirmed bad IP stays blocked (seconds).
CROWDSEC_BLOCK_TTL_SECONDS=86400
# Endpoint — override only for tests/staging.
CROWDSEC_CTI_BASE_URL=https://cti.api.crowdsec.net/v2
# Daily enrichment-call ceiling (freemium plan ≈ 10k/day). Once today's
# counter reaches it, reputation lookups pause until tomorrow so a spread
# DDoS cannot silently burn the whole quota. 0 = unlimited.
CROWDSEC_CTI_DAILY_QUOTA=10000
# How many new community-reputation blocks within a 5-minute window justify an
# ops alert (quota/backoff/report alerts all use HEALTH_ALERT_COOLDOWN_MIN).
CROWDSEC_ALERT_BLOCK_BURST=10
# --- CROWDSEC SIGNAL PUSH (share our blocks back, optional) ---
# Opt-in: pushes blocked IPs + behaviors to the CrowdSec Central API (CAPI) so
# the community blocklist protects other members too. Set to "true" to enable.
# Requires watcher credentials — either set both CROWDSEC_REPORT_MACHINE_ID
# (48 chars, [A-Za-z0-9]) and CROWDSEC_REPORT_PASSWORD now, or leave them
# unset and let the app generate a stable pair persisted in Redis automatically.
CROWDSEC_REPORT_ENABLED=false
CROWDSEC_REPORT_MACHINE_ID=
CROWDSEC_REPORT_PASSWORD=
# Optional: attachment key from https://app.crowdsec.net → Console settings —
# links our watcher to your account so pushed signals show up there.
CROWDSEC_REPORT_ENROLL_KEY=
# Central API base — override only for tests/staging.
CROWDSEC_CAPI_BASE_URL=https://api.crowdsec.net/v3
# --- CROWDSEC LOCAL (opt-in engine on this Docker host, no proxy changes) ---
# App-layer LAPI bouncer: the anti-DDoS gate asks the local engine per client
# IP (short-cached) and blocks ban/captcha decisions before its own buckets.
# Start everything with `bash cms security`; it writes the key below into .env
# and starts the CrowdSec engine bound to 127.0.0.1. Set to "true" to load the
# bouncer without the local engine (not recommended).
CROWDSEC_LOCAL_ENABLED=false
# Host access-log directory mounted into the engine for detection (Nginx only).
CROWDSEC_NGINX_LOG_DIR=/var/log/nginx
# Change LAPI port AND LAPI URL together when 18080 is already taken.
CROWDSEC_LAPI_PORT=18080
CROWDSEC_LAPI_URL=http://127.0.0.1:18080
# Generated by `bash cms security`; keep in .env, never commit a value.
CROWDSEC_LAPI_API_KEY=
# IP blocklist sync (`bash cms security blocklists`): space-separated URLs, by
# default Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
# blocklist.de, Emerging Threats, abuse.ch Feodo/SSLBL/URLhaus, IPsum,
# Firehol ipsets and Tor exit nodes. Requires internet to fetch; detection and
# blocking stay local.
#CROWDSEC_BLOCKLIST_SOURCES=https://www.spamhaus.org/drop/drop.txt https://example.org/list.txt
# Expiration for each blocklist decision (re-synced keeps them fresh).
#CROWDSEC_BLOCKLIST_DURATION=24h
# Combined cap per sync (safety valve against excessive decisions).
#CROWDSEC_BLOCKLIST_MAX_DECISIONS=1000000
# Comma-separated IPs/CIDRs that a sync must always skip (allowlist).
#CROWDSEC_BLOCKLIST_ALLOW=1.2.3.4,10.0.0.0/8
# --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges
@@ -116,13 +156,6 @@ DB_BACKUP_KEEP=7
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
HEALTH_ALERT_COOLDOWN_MIN=15
# --- FURNITURE ASSETS ---
# Extension the local Nitro client requests for furniture bundles. The CMS
# writes new/imported/repaired bundles with this extension and prefers it when
# resolving existing ones (the other stays readable as a legacy fallback).
# ".hab" (default) or ".nitro".
FURNI_BUNDLE_EXT=.hab
# --- RCON (Low Latency Loop) ---
RCON_HOST=127.0.0.1
RCON_PORT=3003
+33 -87
View File
@@ -8,15 +8,24 @@ on:
branches: [main, master]
workflow_dispatch:
# Reuse the Playwright browsers that ship with the host runner (snapped to
# the root HOME cache instead of a fresh per-job HOME) so `playwright install`
# is a near-instant no-op instead of a ~100s CDN download on every run.
env:
PLAYWRIGHT_BROWSERS_PATH: /opt/ms-playwright
jobs:
# ─────────────────────────────────────────────
# Fast quality gate: toolchain, install, dependabot audit,
# lint, i18n contracts & typecheck. No heavy test suites here.
# Draait op de host (self-hosted) waar Node 26 + pnpm 11
# geïnstalleerd zijn en internet beschikbaar is.
# ─────────────────────────────────────────────
check:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -24,12 +33,6 @@ jobs:
- name: Toolchain check
run: node scripts/check-node-toolchain.mjs
- name: README version sync
run: node scripts/sync-readme-versions.mjs --check
- name: Deploy port-selection tests
run: bash scripts/ci-deploy-ports.test.sh
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -45,12 +48,17 @@ jobs:
- name: Typecheck
run: pnpm typecheck
# ─────────────────────────────────────────────
# Test suites. Parallel jobs (host runner capacity >= 3) so unit,
# integration and UI tests each get a worker instead of running
# back-to-back inside the check job (~2min wall-time saving).
# ─────────────────────────────────────────────
tests-unit:
needs: check
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -75,10 +83,10 @@ jobs:
tests-integration:
needs: check
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -91,10 +99,10 @@ jobs:
tests-ui:
needs: check
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -102,6 +110,7 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Compare against reviewed Linux references; updates are explicit.
- name: Install UI test browser
run: pnpm exec playwright install chromium
@@ -119,82 +128,14 @@ jobs:
test-results/ui/
retention-days: 14
# Cross-browser imaging: the deploy gate only runs chromium, so this verifies
# a real render (decode + APNG animation + visual baseline) across every
# bundled engine against the public site. Blocking on purpose: this is the
# signal that avatar imaging works on every system, so a failure here must
# stop the pipeline. IMAGING_EDGE marks the target as edge-fronted, enabling
# the public-routing smoke and skipping the CMS-only /imaging assertions.
tests-imaging-browsers:
needs: check
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install cross-browser engines
run: pnpm exec playwright install --with-deps chromium firefox webkit
- name: Cross-browser imaging checks
env:
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
IMAGING_EDGE: "1"
run: pnpm test:imaging
# Advisory live-quality checks: broken uploads/theme assets, legacy malformed
# avatar figures and warm-page latency. These depend on live content and data
# rather than the code under test, so they are informational only —
# `continue-on-error` keeps the pipeline green while still surfacing the
# signal in the job log and uploaded report. The deterministic caching contract
# is enforced by the blocking deploy gate instead.
tests-quality:
needs: check
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install quality test browser
run: pnpm exec playwright install chromium
- name: Advisory quality checks
continue-on-error: true
env:
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
run: pnpm test:e2e:quality
- name: Upload quality results
if: always()
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
with:
name: quality-results
path: |
playwright-report/quality/
test-results/quality/
if-no-files-found: warn
retention-days: 14
# Validate branch/PR Docker images before integration into a deployment branch.
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
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -217,13 +158,18 @@ jobs:
if-no-files-found: warn
retention-days: 14
# ─────────────────────────────────────────────
# Docker build & deploy
# Draait op de host (self-hosted) zodat Docker
# toegang heeft tot de daemon en volumes.
# ─────────────────────────────────────────────
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
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
-44
View File
@@ -1,44 +0,0 @@
name: Renovate
on:
schedule:
- cron: "17 4 * * 1-5"
workflow_dispatch:
inputs:
debug:
description: "Debug-loglevel"
type: boolean
default: false
# Niet overlappen met zichzelf; een tweede start wacht tot de eerste klaar is.
concurrency:
group: renovate
cancel-in-progress: false
env:
RENOVATE_VERSION: 44.164.10 # renovate: datasource=npm depName=renovate
jobs:
renovate:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }}
shell: bash
run: |
docker run --rm \
--memory=6g \
--memory-swap=6g \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
-e RENOVATE_TOKEN \
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
-e RENOVATE_AUTODISCOVER=false \
-e RENOVATE_BINARY_SOURCE=install \
-e LOG_LEVEL \
-e RENOVATE_ONBOARDING=true \
-e RENOVATE_REQUIRE_CONFIG=required \
"renovate/renovate:${RENOVATE_VERSION}"
+17
View File
@@ -0,0 +1,17 @@
name: Gitea Actions Runner Test
on: [push]
jobs:
test-job:
runs-on: self-hosted
steps:
- name: Check Host Environment
run: |
echo "The v3.5.0 runner works!"
echo "Current date/time on VPS:"
date
echo "Running kernel version:"
uname -a
- name: Test Bash Command
run: echo "Greetings from the host runner!"
-2
View File
@@ -15,8 +15,6 @@ public/cache/
prod.log
*.log
# Imager health-alert state (last-known up/down + timestamp)
logs/imager-health.state
# Database backups
db_backup_*.sql
+33
View File
@@ -0,0 +1,33 @@
image: node:26
stages:
- test
- build
cache:
paths:
- node_modules/
before_script:
- corepack enable
- pnpm install --frozen-lockfile
lint:
stage: test
script:
- pnpm run lint
typecheck:
stage: test
script:
- pnpm run typecheck
test:
stage: test
script:
- pnpm run test
build:
stage: build
script:
- pnpm run build
-14
View File
@@ -1,14 +0,0 @@
{
"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
}
View File
Whitespace-only changes.
+27 -35
View File
@@ -1,50 +1,40 @@
# syntax=docker/dockerfile:1
# Pin the runtime to the supported engine; update both stages deliberately.
FROM node:26.10.0-alpine AS migrations
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# 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.
# 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.
RUN --mount=type=cache,target=/var/cache/apk \
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"
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).
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
# 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
# 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
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"
# 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
# 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.
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" \
@@ -72,6 +62,8 @@ 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", "--"]
+117 -237
View File
@@ -10,21 +10,14 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- |
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
| pnpm | 12.11.2 | Pinned via `packageManager` |
| Node.js | 26.9.0 | Current release pinned in `.nvmrc` |
| pnpm | >= 11.25.0 | Recommended package manager |
| 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.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.
> Builds must run through the `pnpm` scripts. This host has no swap and
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
> kernel and can take the database and the live release down with it. See
> [Memory-capped commands](#memory-capped-commands).
---
## Quick Start
@@ -109,10 +102,6 @@ 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.
@@ -191,9 +180,7 @@ it does not fetch changes to this Git-built CMS. Use the updater for releases.
A clone does **not** install an automatic scheduler. If desired, explicitly add a
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
directory. Do not configure both CI and Compose updates for the same instance.
On a CI-managed host (either slot container running, or the nginx upstream
pointing at a blue/green slot) the updater skips with exit 0, so a cron run
that legitimately has nothing to do does not look like a failed deployment.
The updater refuses an active `epicnext-cms-app` CI-managed container.
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
failure automatically recreates the CMS with its previous image and verifies its
@@ -596,7 +583,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` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
- 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`.
### Configuration — `/docker/Polaris-imager/.env`
@@ -639,20 +626,9 @@ A production issue that this section documents: **the `avatar-imaging-pixinode`
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
### Figure string validation
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
The URL builder (`src/lib/imager.ts`) and the proxy routes share one rule set
(`src/lib/avatar-figure.ts`): a figure must look like `part-id` segments
separated by dots (`hd-180-1.ch-210-66`). Some clients append extra segments the
renderer still accepts (e.g. a trailing `.0`), so the pattern is deliberately
loose; over-strict validation once turned valid avatars into 400s.
Before a URL is built, the stored look is normalised: anything the imager would
reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a
path-traversal attempt, an over-long value — is replaced with
`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken
avatars without weakening the route, which still answers `400` to a malformed
figure (`src/app/api/imaging/avatar/route.ts`).
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens.
---
@@ -862,76 +838,130 @@ Open **DevOps → Anti-DDoS protection**
---
## Captcha (Cloudflare Turnstile)
## Local CrowdSec Engine (opt-in)
The **register**, **login**, **forgot-password** and **resend-verification**
forms can be protected by a captcha. **Cloudflare Turnstile** is the supported
provider and is free; reCAPTCHA and hCaptcha are also wired up. If you only
read one thing, read the precedence table below.
The repository ships a self-contained CrowdSec engine that runs on the same
Docker host. It runs `crowdsecurity/crowdsec:v1.8.1` in its own Compose project
and exposes **LAPI only** on `127.0.0.1:18080`. When enabled, the app-layer
anti-DDoS gate (`src/lib/crowdsec-local.ts`) asks the local LAPI per client IP
(short-cached) and blocks `ban` / `captcha` decisions before its own rate
buckets run. No reverse-proxy, Traefik, Cloudflare or firewall configuration is
changed.
### Where the captcha is configured (and who wins)
The engine boots in **LAPI-only mode** (`DISABLE_AGENT=true`): it does not
consume the host Nginx access log and needs no outbound access to
`crowdsec.net`, which is often blocked on hardened hosts. Combined with
`DISABLE_ONLINE_API=true` (no CrowdSec Central API) the engine needs no account
and no inbound internet — blocking comes from the imported blocklists
(Step 4) and the app's own rate buckets. Re-enable the agent only on a host
with outbound internet by removing `DISABLE_AGENT: "true"` from
`deployment/crowdsec/compose.crowdsec.yml`.
There are two places, and the **admin panel always wins**:
### Step 1 — Enable the engine and register the bouncer
1. **Admin panel** — *Admin → CMS Settings → Captcha* (stored in the database).
2. **Environment variables** — used **only** as a fallback when the admin panel
has no value.
```bash
bash cms security
```
Resolution order, highest first:
This generates `CROWDSEC_LAPI_API_KEY` (random 64 hex chars), writes the
CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer
against the local LAPI. The engine does **not** enroll into the CrowdSec
Central API (`DISABLE_ONLINE_API=true`) and runs without the agent
(`DISABLE_AGENT=true`), so it never phones home.
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
| --- | --- | --- |
| `turnstile` | anything | **Turnstile** (panel wins) |
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
| *empty / never set* | `turnstile` | **Turnstile** (env fallback) |
| *empty / never set* | *empty / never set* | **Off** |
### Step 2 — Restart the CMS so it loads the bouncer credentials
In short: **enable it in the admin panel and the env vars are ignored; leave the
panel empty and the env vars take over.** An explicit `none` in the panel turns
the captcha **off** even when `CAPTCHA_PROVIDER=turnstile` is set.
A CI-managed `epicnext-cms-app` picks the new `.env` values up on its next
deployment. For a clone running via the updater:
### Option A — configure with environment variables (per deploy)
```bash
bash cms update --skip-pull
```
1. Create a Turnstile widget in the
[Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile):
**Add widget**, name it, and add the hostnames your hotel is served on
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). *Managed* mode is fine. Copy the
**Site Key** and the **Secret Key**.
2. Put them in your real `.env` (never commit this file):
or restart the container directly (`docker compose restart cms`). Without the
restart the gate has not loaded the LAPI URL/key yet.
```dotenv
CAPTCHA_PROVIDER=turnstile
TURNSTILE_SITE_KEY=<your site key> # public — safe to expose
TURNSTILE_SECRET_KEY=<your secret key> # SECRET — server-side only
```
### Step 3 — Verify
- The **site key** is **public**: it is rendered into the page for the
browser.
- The **secret key** is **secret**: only the server uses it to call
Cloudflare's `siteverify` endpoint. **Never commit it** — keep it in the
host `.env`, or a **masked CI/CD variable** if you deploy from CI.
3. Restart the container so the new env is picked up.
```bash
bash cms security status
```
### Option B — configure in the admin panel
Expect `CROWDSEC_LOCAL_ENABLED=yes` and `LAPI health: OK (127.0.0.1:18080)`.
In the admin panel, **DevOps → Anti-DDoS protection** shows live block
statistics split per origin (`community` vs `local`).
**Admin → CMS Settings → Captcha**: pick the provider and paste the site key and
secret. This overrides the env vars and takes effect **without a restart**.
### Step 4 — Stop the engine again (optional)
### Verify
```bash
bash cms security disable
```
- Open `/register`. When active, the Turnstile widget appears above the submit
button.
- The e2e suite can assert the widget contract:
`E2E_EXPECT_CAPTCHA=turnstile pnpm test:e2e` (spec: `e2e/register.spec.ts`).
Stops the container and sets `CROWDSEC_LOCAL_ENABLED=false`. Volumes and the
`.env` key are kept.
### Notes
### Step 5 — Load external IP blocklists (optional)
- **Fail-closed**: if the provider is Turnstile but no secret key is available,
verification fails and the form is blocked — it never silently lets bots in.
- The Content-Security-Policy already allows `https://challenges.cloudflare.com`
(`src/lib/csp.ts`), so no header changes are needed.
- Turnstile does **not** require the Cloudflare proxy (orange cloud); it works
independently of the anti-DDoS layer above.
The engine has no built-in lists, so provide your own via a one-shot sync
(fetches the sources, replaces every previous `cscli-import` decision):
```bash
bash cms security blocklists
```
Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch
Feodo/SSLBL, Darklist, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
(26 sources). URLhaus was removed because its `text_online` feed lists URLs,
not IPs; a malformed token in it could otherwise expand into a bogus
huge CIDR. The validator only accepts whole-line bare IPs or proper CIDRs,
enforces sane prefix bounds and drops reserved/private/loopback space, so a
bad source entry can never block the origin or internal traffic. The largest
commercial/crowdsourced lists (AbuseIPDB, MaxMind, Cisco Talos, AlienVault
OTX) are not included because they require an account or API key; IPsum
already aggregates ~30 additional feeds. No account is needed, but internet
access is — only for fetching; detection and blocking remain local. Sync
hourly as a cron job:
```bash
bash cms security blocklists-install-cron
```
Removal: `bash cms security blocklists-uninstall-cron`. Dry-run without
touching LAPI: `bash cms security blocklists --dry-run`. Override the sources,
duration, a combined cap or an allowlist in `.env`
(`CROWDSEC_BLOCKLIST_SOURCES`, `CROWDSEC_BLOCKLIST_DURATION`,
`CROWDSEC_BLOCKLIST_MAX_DECISIONS`, `CROWDSEC_BLOCKLIST_ALLOW`). Existing
`cscli-import` decisions are replaced on every sync, so removed entries
expire.
### Environment variables
| Variable | Default | Purpose |
| ---------------------------- | ----------------------------- | ------------------------------------ |
| `CROWDSEC_LOCAL_ENABLED` | `false` | Master switch for the local stack |
| `CROWDSEC_LAPI_URL` | `http://127.0.0.1:18080` | LAPI endpoint (loopback only) |
| `CROWDSEC_LAPI_PORT` | `18080` | Host port the engine maps to LAPI |
| `CROWDSEC_LAPI_API_KEY` | — | Bouncer key; required when enabled |
| `CROWDSEC_LAPI_TIMEOUT_MS` | `500` | Per-decision request timeout |
| `CROWDSEC_LAPI_RETRY_MS` | `500` | Backoff before retrying LAPI |
| `CROWDSEC_NGINX_LOG_DIR` | `/var/log/nginx` | Access-log directory for the engine |
### Notes and limitations
- Changing the port means updating `CROWDSEC_LAPI_PORT` **and**
`CROWDSEC_LAPI_URL` together, then re-running `bash cms security`.
- Rotate the key by editing `CROWDSEC_LAPI_API_KEY` in `.env`, running
`bash cms security` again (re-registers the bouncer) and restarting the CMS.
- The gate is **fail-closed at startup** when the feature is enabled without a
key (startup aborts with a clear message). At runtime a LAPI network error
**fails open** (traffic is allowed, decisions paused); a 403 from LAPI
pauses local decisions for 5 minutes.
- This bouncer is **application-layer**: it sheds known-bad IPs at the CMS
process only. It does not drop traffic before the origin, does not protect
other host ports/services, and depends on the client IP being trustworthy at
the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate
limits) for defense before the origin.
---
@@ -971,25 +1001,6 @@ that the CI deploy path deliberately uses `docker run` rather than compose, so
the resource limits are declared in **both** places — limits that only existed
in compose would never apply to a real release.
### Compose on a CI host
Compose and CI both want port 3002, so only one of them can own a host. A stray
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
green slot, and every later release stopped on "Port 3002 is already in use" —
after the build, the migrations and the browser gate. Two guards now prevent
that:
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream, and
reports the skip with exit 0 instead of failing the cron.
- `ci-deploy.sh` retires a compose replica of *this* checkout
(`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx
currently serves. Anything else still fails loudly in `assert_port_free`.
### The nginx upstream is the switch
nginx does not know about container names; it reads a plain list of backends from
@@ -1048,112 +1059,15 @@ branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
---
## Memory-capped commands
This host runs with `vm.overcommit_memory=0` **and no swap**. When a process
asks for more memory than is free, the kernel does not wait — it calls the
OOM-killer immediately, and it picks its victim across the **whole machine**,
not just the offending process. An uncapped build does not merely fail: it can
take the MariaDB process, nginx and the live release down with it.
Every heavy command therefore runs inside its own cgroup with a hard
`MemoryMax`, via `scripts/with-memory-cap.sh`. If the build outgrows its
ceiling, only that cgroup is killed — the build fails, the site keeps serving.
The systemd backend also sets `MemoryHigh` (95% of the ceiling) so the kernel
reclaims and throttles **before** the hard limit, and it clamps the ceiling to
`MemAvailable - reserve` (`CMS_MEMORY_CAP_RESERVE`, default `4g`). That stops a
cap larger than the free memory from dragging the whole host into the global
OOM-killer. `MemorySwapMax=0` is set explicitly (this host has no swap). If
fewer than 1 GB would remain, the script refuses to start.
| Script | Ceiling | Covers |
| --------------------- | ------- | --------------------------------------- |
| `pnpm dev` | 8 GB | Dev server |
| `pnpm build` | 10 GB | Production build |
| `pnpm analyze` | 10 GB | Build + bundle-size report |
| `pnpm test` | 8 GB | Vitest |
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
| `pnpm test:ui` | 8 GB | Playwright |
| `pnpm test:e2e` | 8 GB | Playwright |
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
| `pnpm test:integration` | 8 GB | Vitest integration config |
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
### Running the builder by hand
```bash
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
| Command | Description |
| ------------------------- | -------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build (memory-capped) |
| `pnpm build` | Production build |
| `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 (blocking deploy gate) |
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
| `pnpm test:integration` | Integration tests (own Vitest config) |
| `pnpm test:housekeeping` | Housekeeping feature tests |
| `pnpm lint` | Lint and format check (Biome) |
| `pnpm biome:lint` | Alias of `pnpm lint` |
| `pnpm format` | Format files in place (Biome) |
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
| `pnpm i18n:check` | Audit CMS translation coverage |
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
@@ -1161,19 +1075,13 @@ the headroom left for everything else on the machine; the effective ceiling is
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + report per-route bundle sizes |
| `pnpm performance:report` | Re-render the performance report from a build |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm jobs:worker` | Start background task worker |
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
| `pnpm biome:check` | Lint and format code |
> 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.
---
@@ -1267,13 +1175,6 @@ pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
### Captcha — Cloudflare Turnstile
Protect the register, login, forgot-password and resend-verification forms.
Set `CAPTCHA_PROVIDER=turnstile` plus the Turnstile keys in `.env`, or configure
it in **Admin → CMS Settings**. Full setup, precedence and verification:
[Captcha (Cloudflare Turnstile)](#captcha-cloudflare-turnstile).
### Byparr (Cloudflare Bypass)
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
@@ -1305,29 +1206,10 @@ 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 report per-route bundle sizes
pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format
```
### End-to-end tests
`pnpm test:e2e` is the **blocking deploy gate**. It only contains checks the CMS
can guarantee on its own (anonymous redirects, the register form fields and the
captcha DOM contract, cache-control headers), so a green run means a code deploy
is safe.
Checks that depend on **live content or data** — a missing upload or theme asset
(`/help` article images), a legacy malformed avatar figure, warm-page latency —
live in a separate advisory suite:
```bash
pnpm test:e2e:quality # playwright.quality.config.ts, never blocks a deploy
```
It is run against the public site as a `continue-on-error` CI job, so it still
surfaces a real content problem without turning the pipeline red. Point either
suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
### Contributing
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
@@ -1341,5 +1223,3 @@ suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
## License
CC BY-NC-SA 4.0. See `LICENSE` for details.
test line
// CI monitor test 1791828076
-24
View File
@@ -38,30 +38,6 @@
}
}
}
},
{
"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": {
+3 -2
View File
@@ -4,6 +4,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "${1:-help}" in
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
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 ;;
security) shift; exec bash "$DIR/scripts/crowdsec-setup.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' 'bash cms security Configure the opt-in local CrowdSec stack (enable|status|disable|blocklists)' ;;
*) echo "Unknown command. Use: bash cms install | update | security" >&2; exit 1 ;;
esac
+4
View File
@@ -0,0 +1,4 @@
filenames:
- /var/log/nginx/access.log
labels:
type: nginx
+41
View File
@@ -0,0 +1,41 @@
services:
crowdsec:
image: crowdsecurity/crowdsec:${CROWDSEC_VERSION:-v1.8.1}
container_name: epicnext-crowdsec
restart: unless-stopped
profiles: ["security"]
environment:
DISABLE_AGENT: "true"
BOUNCER_KEY_cms: ${CROWDSEC_LAPI_API_KEY:?CROWDSEC_LAPI_API_KEY must be set}
DISABLE_ONLINE_API: "true"
GID: "${CROWDSEC_GID:-0}"
TZ: "${TZ:-UTC}"
ports:
- "${CROWDSEC_LAPI_BIND_HOST:-127.0.0.1}:${CROWDSEC_LAPI_PORT:-18080}:8080"
volumes:
- ./acquis.d:/etc/crowdsec/acquis.d:ro
- ${CROWDSEC_NGINX_LOG_DIR:-/var/log/nginx}:/var/log/nginx:ro
- crowdsec-config:/etc/crowdsec
- crowdsec-data:/var/lib/crowdsec/data
security_opt:
- no-new-privileges:true
pids_limit: 256
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test:
[
"CMD-SHELL",
"wget -q -O - http://127.0.0.1:8080/health >/dev/null 2>&1",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
crowdsec-config:
crowdsec-data:
+1 -133
View File
@@ -78,23 +78,6 @@ map "$cms_cc_class|$cms_skip_cache" $cms_public_cc {
"~^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;
@@ -179,22 +162,6 @@ server {
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 ───
@@ -393,113 +360,14 @@ server {
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; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ─── Static Next.js Assets ───
location /_next/static/ {
@@ -1,39 +0,0 @@
[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
-19
View File
@@ -1,19 +0,0 @@
[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
+40 -10
View File
@@ -1,22 +1,30 @@
# ─────────────────────────────────────────────────────────────────────────────
# Next.js CMS — blue/green
#
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
#
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
# nieuwe replica, dan blijft de oude gewoon draaien.
#
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
# afwijkende velden opnieuw in.
# ─────────────────────────────────────────────────────────────────────────────
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: host
network_mode: host
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
# volgt. Met 10s werden streams en imports afgekapt.
stop_grace_period: 15s
restart: unless-stopped
env_file:
@@ -28,11 +36,20 @@ x-cms: &cms
- /var/www/Gamedata:/var/www/Gamedata
# ── Resource limits ──
mem_limit: 6g
memswap_limit: 7g
# De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
# gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
# volledig door twee replicas worden opgeëist.
mem_limit: 4g
memswap_limit: 5g
cpus: 2.0
pids_limit: 512
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
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
@@ -41,6 +58,7 @@ x-cms: &cms
start_period: 40s
services:
# Blauwe replica: host-poort 3002.
cms:
<<: *cms
container_name: epicnext-cms
@@ -48,6 +66,8 @@ services:
- HOSTNAME=0.0.0.0
- PORT=3002
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
cms-green:
<<: *cms
container_name: epicnext-cms-green
@@ -56,6 +76,7 @@ services:
- HOSTNAME=0.0.0.0
- PORT=3003
# ── Byparr (Cloudflare bypass for clone sources) ──
byparr:
image: ghcr.io/thephaseless/byparr:latest
container_name: byparr
@@ -63,10 +84,19 @@ 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
# De database draait niet meer in Docker. `mariadb-turbo` is verwijderd: de
# service is nooit gestart, de volume bestond niet, en de echte MariaDB draait
# al als host-proces op 127.0.0.1:3306. De optimalisatie-vlaggen daar stonden
# dus al langer niets meer in beheer.
+3 -12
View File
@@ -56,18 +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.
Dependency updates run through self-hosted Renovate: `renovate.json` plus
`.gitea/workflows/renovate.yaml` run the pinned `renovate/renovate` image on the
host runner every weekday at 04:17 (and via `workflow_dispatch`). Minor and patch
updates are grouped into one PR and automerged only after CI is green
(`platformAutomerge: false`, so Renovate waits on the checks itself); major
updates require approval on the dependency dashboard. The job authenticates with
the `renovate-bot` account token stored as the repo Actions secret
`RENOVATE_TOKEN`; rotation only requires overwriting that secret (the bot
password lives in `/root/.gitea-renovate-bot.env` on the host). `minimumReleaseAge`
is 3 days in `renovate.json` and 60 hours in `pnpm-workspace.yaml`, so neither the
installer nor the bot picks up a fresh release on day zero. 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
+18
View File
@@ -85,6 +85,24 @@ The direct template intentionally records the CDN/edge socket address when place
`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.
## Opt-in: CrowdSec on the same Docker host
A self-contained CrowdSec engine ships in `deployment/crowdsec`. It runs `crowdsecurity/crowdsec:v1.8.1` in its own Compose project in **LAPI-only mode** (`DISABLE_AGENT=true`) and exposes LAPI only on `127.0.0.1:18080`. No reverse-proxy, Traefik, Cloudflare or firewall configuration is changed.
```sh
bash cms security
```
The command generates `CROWDSEC_LAPI_API_KEY`, writes the CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer. The anti-DDoS gate then consults the local LAPI per client IP (short-cached) and blocks `ban`/`captcha` decisions before its own rate buckets. `bash cms security status` reports engine state and `bash cms security disable` stops the engine and flips the toggle off.
The engine does not enroll into the CrowdSec Central API (`DISABLE_ONLINE_API=true`) and runs without the agent (`DISABLE_AGENT=true`), so it needs no account and no outbound access to `crowdsec.net` (often blocked on hardened hosts). Blocking comes from the imported blocklists plus the app's own rate buckets; it does not parse the host Nginx log. The app still has its separate opt-in traffic-sharing channel via `CROWDSEC_REPORT_ENABLED`. Change `CROWDSEC_LAPI_PORT` and `CROWDSEC_LAPI_URL` together when `18080` is already in use. `CROWDSEC_NGINX_LOG_DIR` is honored for when the agent is re-enabled.
This bouncer is application-layer: it sheds known-bad IPs at the CMS process and only for traffic that reaches the Next.js proxy. It does not drop traffic before the origin, does not protect other host ports/services, and depends on the client IP being trustworthy at the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate limits) for defense before the origin.
The running CMS loads the new env values on its next restart or deployment. For a CI-managed `epicnext-cms-app`, the next deploy (which sources `.env`) applies them; for a clone, `bash cms update --skip-pull` restarts it. `.env` now holds the LAPI key — keep its permissions restrictive.
External IP blocklists (Spamhaus, DShield, CINS, blocklist.de, abuse.ch, IPsum, Firehol, Tor exit nodes, …) can be synced into the local LAPI with `bash cms security blocklists`, and hourly with `bash cms security blocklists-install-cron` (no account, but internet to fetch). Configure via `CROWDSEC_BLOCKLIST_*`.
## Routine and selected-release updates
```sh
+12 -22
View File
@@ -1,4 +1,4 @@
# Route JavaScript budgets (enforced)
# Informational route JavaScript budgets
Run after the existing production build; no second build or server is needed:
@@ -12,38 +12,28 @@ 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 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.
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.
- 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 the manifest's chunk lists 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 `entryJSFiles` are excluded.
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
## Initial limits
The limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are size guardrails, not validated speed targets.
Baselines were last recalibrated against a local webpack production build (Next 16.4.1, `next build --webpack`) and cross-checked against the report the Docker image build emits: `/me` 621460/195411, `/news` 616712/193199, `/events` 617121/193450, `/search` 618536/194216, `/admin/catalog` 1049094/330369, `/admin/studio/furni` 1397566/447765 (raw/gzip). The earlier Turbopack numbers (previous revision) were **not** comparable and were dropped.
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
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.
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
| --- | ---: | ---: | ---: | ---: |
| `/me` | 621460 | 195411 | 716800 | 225280 |
| `/news` | 616712 | 193199 | 716800 | 225280 |
| `/events` | 617121 | 193450 | 716800 | 225280 |
| `/search` | 618536 | 194216 | 716800 | 225280 |
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
| `/me` | 767156 | 238571 | 890880 | 276480 |
| `/news` | 765367 | 237599 | 880640 | 276480 |
| `/events` | 765851 | 237964 | 890880 | 276480 |
| `/search` | 765851 | 237964 | 890880 | 276480 |
| `/admin/catalog` | 1654898 | 492619 | 1904640 | 573440 |
| `/admin/studio/furni` | 1241312 | 391541 | 1433600 | 450560 |
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` remains `mode: informational`. Exceeding a limit produces `over-budget` and a warning, with exit code 0. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**, also exit code 0. Malformed budget configuration or an unwritable output directory fails the command. This keeps initial CI reporting non-blocking while preventing invalid configuration from quietly disabling limits.
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
-25
View File
@@ -1,25 +0,0 @@
CREATE TABLE `website_daily_reward_claims` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`claim_date` date NOT NULL,
`streak` int unsigned NOT NULL DEFAULT 1,
`reward_day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
);
--> statement-breakpoint
CREATE TABLE `website_daily_rewards` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -8,13 +8,6 @@
"when": 1788791201804,
"tag": "0000_premium_spirit",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1791828901011,
"tag": "0001_breezy_maestro",
"breakpoints": true
}
]
}
@@ -1,31 +0,0 @@
-- 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;
@@ -1,19 +0,0 @@
-- 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));
-48
View File
@@ -1,48 +0,0 @@
import { expect, test } from "@playwright/test";
import { expectNoApplicationError } from "./helpers";
// Unauthenticated requests to private routes must bounce to the login form —
// never render private content and never 500. Two guard paths exist: the edge
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
// member pages and the mod console redirect from their layouts with a bare
// `/login`.
// Routes where the proxy preserves the requested destination.
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
// Routes guarded by a page/layout `redirect("/login")` with no destination.
const LAYOUT_ROUTES = [
"/settings",
"/me",
"/messages",
"/notifications",
"/friends",
"/redeem",
"/mod",
"/client",
] as const;
for (const route of PROXY_ROUTES) {
test(`${route} redirects to login and keeps the return path`, async ({
page,
}) => {
const response = await page.goto(route);
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[type="password"]').first()).toBeVisible();
await expectNoApplicationError(page);
const from = new URL(page.url()).searchParams.get("from");
expect(from, "login lost the return destination").toBeTruthy();
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
});
}
for (const route of LAYOUT_ROUTES) {
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
const response = await page.goto(route);
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[type="password"]').first()).toBeVisible();
await expectNoApplicationError(page);
});
}
-62
View File
@@ -1,62 +0,0 @@
import { expect, type Page } from "@playwright/test";
/**
* Shared assertions for the read-only public e2e suite. These specs run against
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
* guard the "everything loads, nothing is broken" contract that no unit test
* can see.
*/
/** Fails if Next rendered a runtime/server error boundary into the page. */
export async function expectNoApplicationError(page: Page) {
await expect(page.locator("body")).not.toContainText("Application error");
await expect(page.locator("body")).not.toContainText("Internal Server Error");
}
/**
* Every <img> that finished loading must have decoded pixels. A failed request
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
* that are still pending (`complete === false`) are ignored.
*/
export async function expectNoBrokenImages(page: Page) {
const broken = await page.evaluate(() => {
return Array.from(document.images)
.filter((img) => {
const src = img.currentSrc || img.src;
if (!src || src.startsWith("data:")) return false;
return img.complete && img.naturalWidth === 0;
})
.map((img) => img.currentSrc || img.src);
});
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
}
/** No horizontal scrollbar — the classic mobile layout regression. */
export async function expectNoHorizontalOverflow(page: Page) {
const overflowing = await page.evaluate(() => {
const doc = document.documentElement;
return doc.scrollWidth > window.innerWidth + 1;
});
expect(overflowing, "page scrolls horizontally").toBe(false);
}
/**
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
* chance to start before the broken-image check runs.
*/
export async function scrollThroughPage(page: Page) {
await page.evaluate(async () => {
const step = window.innerHeight;
for (let y = 0; y < document.body.scrollHeight; y += step) {
window.scrollTo(0, y);
await new Promise((resolve) => setTimeout(resolve, 80));
}
window.scrollTo(0, 0);
});
}
/** Skips a spec when the CMS answered with a maintenance redirect. */
export function isMaintenanceRedirect(url: string) {
return /\/maintenance(?:$|[/?])/.test(url);
}
-228
View File
@@ -1,228 +0,0 @@
import { expect, test } from "@playwright/test";
import {
avatarPath,
countBytes,
fetchThroughEdge,
getThroughEdge,
looksLikeImage,
} from "./imaging-target";
// Endpoints exercised here are identical whether the CMS is reached directly
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
// in the cross-browser job. Topology-specific checks live in
// `cms-imaging.spec.ts` (direct only) and `public-routing.spec.ts` (edge only).
const IMAGE_SOURCES = new Set([
"primary",
"fallback",
"cache",
"cache-stale",
"unavailable",
]);
test("renders an animated APNG for a figure with an effect", async ({
request,
}) => {
const response = await getThroughEdge(
request,
avatarPath({ img_format: "apng", effect: "14" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
// `unavailable` means the placeholder was served, i.e. no real render.
expect(response.headers()["x-imager-source"]).not.toBe("unavailable");
const body = await response.body();
expect(body.length).toBeGreaterThan(1000);
// APNG signature chunks: `acTL` announces the animation, `fcTL` marks frames.
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("serves a static PNG when no animation is requested", async ({
request,
}) => {
const response = await getThroughEdge(
request,
avatarPath({ img_format: "png" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(1, 4).toString("ascii")).toBe("PNG");
});
test("advertises a cacheable response and a known render source", async ({
request,
}) => {
const response = await getThroughEdge(
request,
avatarPath({ img_format: "png" }),
);
expect(response.status()).toBe(200);
const source = response.headers()["x-imager-source"];
expect(source, "x-imager-source header").toBeTruthy();
expect(IMAGE_SOURCES.has(source as string)).toBe(true);
const cacheControl = response.headers()["cache-control"];
expect(cacheControl, "cache-control header").toContain("public");
expect(cacheControl).toMatch(/max-age=\d+/);
// Render sources other than the graceful placeholder are worth caching.
if (source !== "unavailable") {
expect(cacheControl).not.toContain("no-cache");
}
});
test("answers conditional requests with 304", async ({ request }) => {
const first = await getThroughEdge(
request,
avatarPath({ img_format: "png" }),
);
expect(first.status()).toBe(200);
const etag = first.headers().etag;
expect(etag).toBeTruthy();
const second = await getThroughEdge(
request,
avatarPath({ img_format: "png" }),
{
headers: { "if-none-match": etag as string },
},
);
expect(second.status()).toBe(304);
expect(second.headers().etag).toBe(etag);
});
test("serves the rendered avatar behind permissive CORS", async ({
request,
}) => {
const response = await getThroughEdge(
request,
avatarPath({ img_format: "png" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
});
test("preflight requests advertise GET and revalidation", async ({
request,
}) => {
const response = await fetchThroughEdge(request, avatarPath(), {
method: "OPTIONS",
});
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
expect(response.headers()["access-control-allow-headers"]).toContain(
"If-None-Match",
);
});
test.describe("rejects malformed figures", () => {
const cases: Array<{ name: string; query: string }> = [
{ name: "missing figure", query: "" },
{ name: "empty figure", query: "?figure=" },
{ name: "path traversal", query: "?figure=../bad" },
{ name: "separator injection", query: "?figure=hd-180-1/../x" },
{
name: "over-long figure",
query: `?figure=hd-180-${"x".repeat(600)}`,
},
{
name: "too many parts",
query: `?figure=hd-180${".a-1".repeat(30)}`,
},
];
for (const { name, query } of cases) {
test(name, async ({ request }) => {
const response = await getThroughEdge(
request,
`/api/imaging/avatar${query}`,
);
expect(response.status()).toBe(400);
});
}
});
test("collapses a burst of concurrent requests for one figure", async ({
request,
}) => {
// Vary the key so this can hit a cold (or at least distinct) render while a
// page-load style burst is in flight. Every response must still be a real
// image — single-flight must not turn contention into 5xx/placeholders.
const path = avatarPath({
img_format: "png",
direction: "6",
head_direction: "1",
});
const responses = await Promise.all(
Array.from({ length: 6 }, () => getThroughEdge(request, path)),
);
for (const response of responses) {
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
}
});
test("the badge endpoint serves an image and revalidates", async ({
request,
}) => {
const first = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
expect(first.status()).toBe(200);
expect(first.headers()["content-type"]).toContain("image/");
expect(first.headers()["access-control-allow-origin"]).toBe("*");
const etag = first.headers().etag;
expect(etag).toBeTruthy();
const second = await getThroughEdge(request, "/api/imaging/badge?code=ADM", {
headers: { "if-none-match": etag as string },
});
expect(second.status()).toBe(304);
});
test("the badge endpoint can resolve from a local source", async ({
request,
}) => {
const response = await getThroughEdge(
request,
"/api/imaging/badge?code=ADM&source=local",
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(response.headers()["x-imager-source"]).toBeTruthy();
});
test.describe("rejects malformed badge codes", () => {
const cases = [
{ name: "missing code", query: "" },
{ name: "empty code", query: "?code=" },
{ name: "path traversal", query: "?code=../x" },
{ name: "too long", query: `?code=${"A".repeat(51)}` },
];
for (const { name, query } of cases) {
test(name, async ({ request }) => {
const response = await getThroughEdge(
request,
`/api/imaging/badge${query}`,
);
expect(response.status()).toBe(400);
});
}
});
test("the badge preflight advertises GET", async ({ request }) => {
const response = await fetchThroughEdge(
request,
"/api/imaging/badge?code=ADM",
{
method: "OPTIONS",
},
);
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
-57
View File
@@ -1,57 +0,0 @@
import { expect, test } from "@playwright/test";
import { avatarPath } from "./imaging-target";
/**
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
* the avatar the imager produces and that it actually animates. The endpoint
* spec covers the HTTP contract; this one covers the client-side decode, where
* an unsupported image format would surface as a blank/broken avatar.
*
* Navigates straight to the image URL (a top-level navigation, which CSP
* `upgrade-insecure-requests` does not rewrite) rather than embedding it in a
* page: over a plain-HTTP host the upgrade directive would otherwise send the
* subresource request to https and break the load, without exercising anything
* about the image itself.
*
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
*/
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
const STATIC = avatarPath({ img_format: "png" });
test("the avatar decodes and animates in the browser", async ({ page }) => {
const response = await page.goto(ANIMATED);
expect(response?.status()).toBe(200);
const image = page.locator("img");
await expect(image).toBeVisible();
const decoded = await image.evaluate((element: HTMLImageElement) => ({
width: element.naturalWidth,
height: element.naturalHeight,
}));
expect(decoded.width).toBeGreaterThan(0);
expect(decoded.height).toBeGreaterThan(0);
// An animated APNG keeps producing new frames; a static/undecodable one
// yields the same pixels every time. Sampling screenshots over a second is
// enough to tell them apart without depending on exact frame timing.
const frames = new Set<string>();
for (let attempt = 0; attempt < 8; attempt += 1) {
frames.add((await image.screenshot()).toString("base64"));
await page.waitForTimeout(180);
}
expect(frames.size).toBeGreaterThan(1);
});
test("the static render matches its committed visual baseline", async ({
page,
}) => {
// Pins the exact pixels of a non-animated render so a renderer/asset drift
// (wrong palette, missing part, changed sizing) is caught even when the
// image still decodes. Baselines are per engine and committed under
// e2e/imaging/__screenshots__/.
const response = await page.goto(STATIC);
expect(response?.status()).toBe(200);
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
animations: "disabled",
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

-78
View File
@@ -1,78 +0,0 @@
import { expect, test } from "@playwright/test";
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
// These assertions only hold when the CMS itself answers `/imaging`. In
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
test.skip(
IS_EDGE,
"The CMS /imaging route is shadowed by the edge proxy in production",
);
const imagerPath = (params: Record<string, string> = {}) =>
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
test("serves the default render as a PNG", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
expect(looksLikeImage(body)).toBe(true);
});
test("transcodes to WebP on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "webp" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/webp");
const body = await response.body();
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
});
test("transcodes to AVIF on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "avif" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/avif");
const body = await response.body();
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
});
test("leaves animated APNG renders untranscoded", async ({ request }) => {
// The renderer emits effected avatars as APNG; libvips here reads only the
// first frame, so the animated original must be served as-is even when a
// transcode is requested — otherwise the avatar would stop animating.
const response = await request.get(
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("advertises long-lived edge caching", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl).toContain("public");
expect(cacheControl).toContain("s-maxage");
expect(cacheControl).toContain("stale-while-revalidate");
});
test("rejects a malformed figure with 400", async ({ request }) => {
const response = await request.get(
`/imaging?figure=${encodeURIComponent("../bad")}`,
);
expect(response.status()).toBe(400);
});
test("the imager preflight advertises GET", async ({ request }) => {
const response = await request.fetch("/imaging", { method: "OPTIONS" });
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
-103
View File
@@ -1,103 +0,0 @@
import type { APIRequestContext, APIResponse } from "@playwright/test";
/**
* Where the imaging specs are pointed.
*
* - Deploy gate (`playwright.config.ts`): hits the CMS directly, so the CMS's
* own `/imaging` route answers and its transcoding variants are reachable.
* - Cross-browser job (`playwright.imaging.config.ts` against the public
* origin): the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
* imager container, so the CMS route is shadowed. Only the endpoints that are
* identical in both topologies may be asserted unconditionally.
*
* Set `IMAGING_EDGE=1` when the base URL is behind the edge proxy.
*/
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
/**
* Failures that mean "the request never reached the app".
*
* Cloudflare answers the 520-527 family when its own handshake to the origin
* fails, and gateways add 502/503/504. Retrying those is not papering over a
* bug: the assertion under test is about the CMS, and a 525 says nothing about
* it. A genuinely broken edge still exhausts the retries and fails the test.
*/
const TRANSIENT_EDGE_STATUSES = new Set([
502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527,
]);
const EDGE_RETRY_ATTEMPTS = 3;
const EDGE_RETRY_DELAY_MS = 500;
async function withEdgeRetry(
send: () => Promise<APIResponse>,
): Promise<APIResponse> {
let response = await send();
for (
let attempt = 1;
attempt < EDGE_RETRY_ATTEMPTS &&
TRANSIENT_EDGE_STATUSES.has(response.status());
attempt += 1
) {
await new Promise((resolve) =>
setTimeout(resolve, EDGE_RETRY_DELAY_MS * attempt),
);
response = await send();
}
return response;
}
/** GET that survives a transient edge/transport failure. */
export function getThroughEdge(
request: APIRequestContext,
url: string,
options: Parameters<APIRequestContext["get"]>[1] = {},
): Promise<APIResponse> {
return withEdgeRetry(() => request.get(url, options));
}
/** POST/OPTIONS/… counterpart of `getThroughEdge`. */
export function fetchThroughEdge(
request: APIRequestContext,
url: string,
options: Parameters<APIRequestContext["fetch"]>[1] = {},
): Promise<APIResponse> {
return withEdgeRetry(() => request.fetch(url, options));
}
// The exact figure that regressed in production: the game client appends a
// trailing `.<n>` segment that the renderer ignores, but the previous
// validation regex rejected with a 400.
export const FIGURE =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
export const avatarPath = (params: Record<string, string> = {}) =>
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Public imager endpoint served through the edge proxy (imager container). */
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
`/imaging/avatarimage?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Counts non-overlapping occurrences of `needle` inside `haystack`. */
export function countBytes(haystack: Buffer, needle: Buffer): number {
let count = 0;
for (
let index = haystack.indexOf(needle);
index !== -1;
index = haystack.indexOf(needle, index + needle.length)
) {
count += 1;
}
return count;
}
/** PNG/JPEG/GIF/WebP/AVIF signatures, so a test can prove a body is an image. */
export function looksLikeImage(body: Buffer): boolean {
if (body.length < 12) return false;
if (body.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")))
return true; // PNG / APNG
if (body.subarray(0, 3).toString("ascii") === "GIF") return true;
if (body.subarray(0, 2).toString("hex") === "ffd8") return true; // JPEG
if (body.subarray(0, 4).toString("ascii") === "RIFF") return true; // WebP
if (body.subarray(4, 12).toString("ascii") === "ftypavif") return true; // AVIF
return false;
}
-39
View File
@@ -1,39 +0,0 @@
import { expect, test } from "@playwright/test";
import { avatarPath } from "./imaging-target";
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
// correctness; this one catches a regression that keeps the image correct but
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
// flag a real regression, not a cold cache or a slow CI network.
const STATIC_MAX_BYTES = 64 * 1024;
const ANIMATED_MAX_BYTES = 512 * 1024;
const WARM_MAX_MS = 3000;
test("the static avatar stays inside its byte budget", async ({ request }) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
});
test("the animated avatar stays inside its byte budget", async ({
request,
}) => {
const response = await request.get(
avatarPath({ img_format: "apng", effect: "14" }),
);
expect(response.status()).toBe(200);
expect((await response.body()).length).toBeLessThanOrEqual(
ANIMATED_MAX_BYTES,
);
});
test("a warm avatar request is served quickly", async ({ request }) => {
const path = avatarPath({ img_format: "png" });
await request.get(path); // warm the disk and edge caches
const started = Date.now();
const response = await request.get(path);
const elapsed = Date.now() - started;
expect([200, 304]).toContain(response.status());
expect(elapsed).toBeLessThan(WARM_MAX_MS);
});
-46
View File
@@ -1,46 +0,0 @@
import { expect, test } from "@playwright/test";
import {
avatarPath,
getThroughEdge,
IMAGER_PATH,
IS_EDGE,
looksLikeImage,
} from "./imaging-target";
// Regression guard for the public topology: the edge proxy must front the
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
// Only meaningful against the public origin, so it is skipped when the CMS is
// reached directly (set `IMAGING_EDGE=1` for the public origin).
test.skip(
!IS_EDGE,
"Public routing is only asserted against the edge-fronted origin",
);
test("the edge serves the imager endpoint as an image", async ({ request }) => {
const response = await getThroughEdge(
request,
IMAGER_PATH({ img_format: "png" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the CMS avatar endpoint is reachable through the edge", async ({
request,
}) => {
const response = await getThroughEdge(
request,
avatarPath({ img_format: "png" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
});
+1 -4
View File
@@ -88,10 +88,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
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(?:\?|$)/);
await expect(page).toHaveURL(/\/me(?:\?|$)/);
const session = await context.request
.get("/api/auth/session")
.then((response) => response.json());
-30
View File
@@ -1,30 +0,0 @@
import { expect, test } from "@playwright/test";
// Caching contract for the anonymous pages. This is deterministic and stays in
// the blocking deploy gate: the perf work relies on the edge/shared cache
// (`src/proxy.ts`) — public content pages advertise a short `s-maxage`, while
// session-dependent pages (login, register, settings) must stay
// `private, no-store`. Timing budgets are advisory and live in
// `e2e/quality/performance.spec.ts`.
test("anonymous content pages advertise a shared cache", async ({
request,
}) => {
for (const route of ["/news", "/staff", "/shop"]) {
const response = await request.get(route);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
}
});
test("session-dependent pages stay private and uncached", async ({
request,
}) => {
for (const route of ["/login", "/register"]) {
const response = await request.get(route);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
"s-maxage",
);
}
});
-58
View File
@@ -1,58 +0,0 @@
import { expect, test } from "@playwright/test";
import {
expectNoApplicationError,
expectNoBrokenImages,
expectNoHorizontalOverflow,
isMaintenanceRedirect,
scrollThroughPage,
} from "../helpers";
// ADVISORY suite — run with `pnpm test:e2e:quality`, not part of the deploy
// gate. These checks depend on live content/upload state and user data (a
// missing upload, a legacy malformed avatar figure, a bad theme asset), so
// they are informational: they tell you the site has a real content problem,
// but they must never block a code deploy. Every assertion the CMS can
// guarantee on its own lives in the blocking specs instead.
const PUBLIC_ROUTES = [
"/",
"/news",
"/staff",
"/shop",
"/search",
"/leaderboard",
"/help",
"/radio",
] as const;
for (const route of PUBLIC_ROUTES) {
test(`${route} loads every image and does not overflow`, async ({ page }) => {
const response = await page.goto(route);
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
await expect(page.locator("main").first()).toBeVisible();
await expectNoApplicationError(page);
await scrollThroughPage(page);
await expectNoBrokenImages(page);
await expectNoHorizontalOverflow(page);
});
}
test("the register page loads its theme/avatar images", async ({ page }) => {
const response = await page.goto("/register");
test.skip(
isMaintenanceRedirect(page.url()) || response?.status() === 404,
"register is not reachable on this deploy",
);
await scrollThroughPage(page);
await expectNoBrokenImages(page);
await expectNoHorizontalOverflow(page);
});
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const response = await page.goto("/");
expect(response?.ok()).toBe(true);
await expectNoApplicationError(page);
await expectNoHorizontalOverflow(page);
});
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from "@playwright/test";
// ADVISORY suite — run with `pnpm test:e2e:quality`. Warm-page latency depends
// on the host, the edge and the shared data caches, so a slow CI hop can trip
// these budgets without anything being wrong. The cache-control *contract*
// (deterministic) lives in the blocking `e2e/performance.spec.ts`; only the
// timing budgets are advisory.
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
for (const route of WARM_ROUTES) {
test(`${route} responds within the warm budget`, async ({ request }) => {
await request.get(route); // warm the render + data caches
const started = Date.now();
const response = await request.get(route);
const elapsed = Date.now() - started;
expect([200, 304]).toContain(response.status());
expect(
elapsed,
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
).toBeLessThan(BUDGET_MS);
});
}
-69
View File
@@ -1,69 +0,0 @@
import { expect, test } from "@playwright/test";
import { expectNoApplicationError, isMaintenanceRedirect } from "./helpers";
// The register form is the gate for every new player, so it gets a deeper
// read-only pass than the generic public-page spec. It also pins the captcha
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
test.describe("register", () => {
test.beforeEach(async ({ page }) => {
const response = await page.goto("/register");
test.skip(
isMaintenanceRedirect(page.url()) || response?.status() === 404,
"register is not reachable on this deploy",
);
});
test("renders every field a new player has to fill in", async ({ page }) => {
await expect(page.locator('input[name="username"]')).toBeVisible();
await expect(page.locator('input[name="mail"]')).toBeVisible();
await expect(page.locator('input[name="password"]').first()).toBeVisible();
await expect(
page.locator('input[name="password_confirmation"]'),
).toBeVisible();
await expect(page.locator('input[name="terms"]')).toBeVisible();
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
await expectNoApplicationError(page);
});
test("exposes exactly one captcha widget with a site key", async ({
page,
}) => {
const widgets = page.locator(CAPTCHA_SELECTOR);
if (EXPECT === "none") {
await expect(widgets).toHaveCount(0);
return;
}
if (EXPECT === "turnstile") {
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
}
const count = await widgets.count();
expect(
count,
`expected at most one captcha provider, found ${count}`,
).toBeLessThanOrEqual(1);
for (let index = 0; index < count; index += 1) {
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
expect(siteKey?.trim().length).toBeGreaterThan(0);
}
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
const script = page.locator(
'script[src*="challenges.cloudflare.com/turnstile"]',
);
await expect(script).toHaveCount(1);
}
});
});
+6 -4
View File
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = {
name: "fixture_chair.hab",
name: "fixture_chair.nitro",
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.hab"',
'filename="fixture_chair.nitro"',
);
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 .hab file").setInputFiles(file);
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
await expect(
page.getByText("Matching original file attached", { exact: true }),
).toBeVisible();
@@ -94,7 +94,9 @@ for (const scenario of [
})
: route.abort("failed"),
);
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await page
.getByLabel("Choose the original .nitro file")
.setInputFiles(file);
await expect(page.getByRole("alert")).toContainText(scenario.message);
await expect(
page.getByRole("button", {
+2 -7
View File
@@ -116,14 +116,9 @@ test("saving while recovery is pending enables recovery for subsequent edits", a
test("unmount flush retains an edit before the autosave interval", async ({
page,
}) => {
// Install the fake clock before navigation so every page timer is faked,
// then jump forward to a fixed pause point. Using wall-clock `new Date()`
// for both calls raced under load: the fake clock ticks in real time
// between the calls and can pass the target ("Cannot fast-forward to the
// past"). The 60s jump only fires no-op persists on a clean form.
await page.clock.install({ time: new Date("2030-01-01T12:00:00Z") });
await page.goto("/admin/articles/recovery");
await page.clock.pauseAt(new Date("2030-01-01T12:01:00Z"));
await page.clock.install();
await page.clock.pauseAt(new Date());
await page.locator('input[name="title"]').fill("Immediate final edit");
await page
.getByRole("button", { name: "Close editor fixture", exact: true })
+14 -112
View File
@@ -1,5 +1,4 @@
import { createHash } from "node:crypto";
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -24,93 +23,15 @@ const actions = new Set([
"@/actions/article-recovery",
"@/actions/admin-media",
]);
const nodeStubs = path.join(root, "e2e/ui/node-stubs.mjs");
const cacheDir = path.join(root, "node_modules/.cache/nexst-ui-fixture");
const startedAt = Date.now();
async function listFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await listFiles(target)));
else files.push(target);
}
return files;
}
async function fixtureCacheKey() {
const files = [
...(await listFiles(path.join(root, "src"))),
...(await listFiles(fixtures)),
nodeStubs,
path.join(root, "e2e/ui/process-shim.mjs"),
fileURLToPath(import.meta.url),
path.join(root, "package.json"),
].sort();
const hash = createHash("sha256");
for (const file of files) {
hash.update(file.slice(root.length));
hash.update(await readFile(file));
}
return hash.digest("hex");
}
async function readCache(key) {
try {
const [js, css] = await Promise.all([
readFile(path.join(cacheDir, `${key}.app.js`), "utf8"),
readFile(path.join(cacheDir, `${key}.styles.css`), "utf8"),
]);
return { js, css };
} catch {
return null;
}
}
async function writeCache(key, js, css) {
await mkdir(cacheDir, { recursive: true });
await Promise.all([
writeFile(path.join(cacheDir, `${key}.app.js`), js),
writeFile(path.join(cacheDir, `${key}.styles.css`), css),
]);
const stale = await readdir(cacheDir);
await Promise.all(
stale
.filter((name) => !name.startsWith(key))
.map((name) => unlink(path.join(cacheDir, name))),
);
}
async function buildFixture() {
const [bundle, globalCss] = await Promise.all([
build({
const bundle = await build({
entryPoints: [path.join(fixtures, "entry.tsx")],
bundle: true,
write: false,
outdir: path.join(root, "test-results/ui-fixture"),
format: "iife",
jsx: "automatic",
alias: {
"@": path.join(root, "src"),
"node:async_hooks": nodeStubs,
"node:child_process": nodeStubs,
"node:crypto": nodeStubs,
"node:dns/promises": nodeStubs,
"node:fs": nodeStubs,
"node:fs/promises": nodeStubs,
"node:http": nodeStubs,
"node:net": nodeStubs,
"node:os": nodeStubs,
"node:path": nodeStubs,
"node:url": nodeStubs,
"node:util": nodeStubs,
},
define: {
"process.env.NODE_ENV": '"production"',
"process.env.SKIP_ENV_VALIDATION": '"1"',
},
inject: [path.join(root, "e2e/ui/process-shim.mjs")],
alias: { "@": path.join(root, "src") },
define: { "process.env.NODE_ENV": '"production"' },
plugins: [
{
name: "server-contracts",
@@ -124,9 +45,7 @@ async function buildFixture() {
return { path: path.join(fixtures, "next-link.tsx") };
if (actions.has(args.path))
return { path: path.join(fixtures, "server-actions.ts") };
throw Error(
`Unexpected server action in UI fixture: ${args.path}`,
);
throw Error(`Unexpected server action in UI fixture: ${args.path}`);
},
);
build.onResolve(
@@ -140,38 +59,21 @@ async function buildFixture() {
},
},
],
}),
postcss([tailwind({ base: root })])
.process(await readFile(path.join(root, "src/app/globals.css"), "utf8"), {
from: path.join(root, "src/app/globals.css"),
})
.then((result) => result.css),
]);
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
const css =
});
const globalCss = (
await postcss([tailwind({ base: root })]).process(
await readFile(path.join(root, "src/app/globals.css"), "utf8"),
{ from: path.join(root, "src/app/globals.css") },
)
).css;
const css =
globalCss +
"\n" +
bundle.outputFiles
.filter((file) => file.path.endsWith(".css"))
.map((file) => file.text)
.join("\n");
return { js, css };
}
const cacheKey = await fixtureCacheKey();
let assets = await readCache(cacheKey);
if (assets) {
console.log(
`[fixture] reusing cached bundle ${cacheKey.slice(0, 12)} (${Date.now() - startedAt}ms)`,
);
} else {
assets = await buildFixture();
await writeCache(cacheKey, assets.js, assets.css);
console.log(
`[fixture] built bundle ${cacheKey.slice(0, 12)} in ${Date.now() - startedAt}ms`,
);
}
const { js, css } = assets;
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
const html =
'<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' +
"a".repeat(64) +
-187
View File
@@ -1,187 +0,0 @@
// Stubs for Node.js built-ins used by server-only modules that leak into the
// UI fixture bundle. These keep esbuild from failing on `node:*` imports
// while providing shape-compatible exports so the bundle stays loadable.
// Nothing here performs real I/O; it only exists so module evaluation in the
// browser does not crash on import of server-only helpers.
export class AsyncLocalStorage {
enterWith() {}
exit() {}
getStore() {
return undefined;
}
run(_store, work) {
return work();
}
}
const cryptoApi = {
createHash: () => ({
update: () => ({
digest: () => "",
}),
}),
randomUUID: () => "00000000-0000-0000-0000-000000000000",
randomBytes: (size) => new Uint8Array(size),
createHmac: () => ({
update: () => ({
digest: () => "",
}),
}),
timingSafeEqual: () => true,
createCipheriv: () => ({
update: () => new Uint8Array(0),
final: () => new Uint8Array(0),
}),
createDecipheriv: () => ({
update: () => new Uint8Array(0),
final: () => new Uint8Array(0),
}),
webcrypto: globalThis.crypto,
};
export const {
createHash,
randomUUID,
randomBytes,
createHmac,
timingSafeEqual,
createCipheriv,
createDecipheriv,
webcrypto,
} = cryptoApi;
export const appendFile = async () => {};
export const mkdir = async () => {};
export const readdir = async () => [];
export const readFile = async () => new Uint8Array(0);
export const rename = async () => {};
export const stat = async () => ({
isDirectory: () => false,
isFile: () => true,
size: 0,
mtimeMs: 0,
});
export const unlink = async () => {};
export const writeFile = async () => {};
export const rm = async () => {};
export const mkdtemp = async () => "/tmp/fixture-stub";
export const promises = {
appendFile,
mkdir,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
rm,
mkdtemp,
};
export const existsSync = () => false;
export const readFileSync = () => new Uint8Array(0);
export const writeFileSync = () => {};
export const readdirSync = () => [];
export const mkdirSync = () => {};
export const rmSync = () => {};
export const mkdtempSync = () => "/tmp/fixture-stub";
export const appendFileSync = () => {};
export const statSync = () => ({
isDirectory: () => false,
isFile: () => true,
size: 0,
});
const pathApi = {
join: (...parts) => parts.join("/"),
resolve: (...parts) => parts.join("/"),
dirname: (value) => value.split("/").slice(0, -1).join("/"),
basename: (value) => value.split("/").pop() ?? "",
extname: (value) => {
const base = value.split("/").pop() ?? "";
const index = base.lastIndexOf(".");
return index > 0 ? base.slice(index) : "";
},
relative: (_from, to) => to,
normalize: (value) => value,
isAbsolute: (value) => value.startsWith("/"),
sep: "/",
delimiter: ":",
};
export const {
join,
resolve,
dirname,
basename,
extname,
relative,
normalize,
isAbsolute,
sep,
delimiter,
} = pathApi;
export const path = pathApi;
const osApi = {
hostname: () => "localhost",
tmpdir: () => "/tmp",
platform: () => "linux",
homedir: () => "/tmp",
};
export const { hostname, tmpdir, platform, homedir } = osApi;
export const promisify = (fn) => fn;
export const isIP = () => 0;
export const lookup = async () => ({ address: "127.0.0.1", family: 4 });
export const execFile = () => {};
export const execFileSync = () => "";
export const spawnSync = () => ({ status: 0, stdout: "", stderr: "" });
export const fileURLToPath = (value) => String(value);
export const createServer = () => ({
listen: () => {},
close: () => {},
on: () => {},
address: () => ({ port: 0 }),
});
// Single default export: all node:* aliases point at this one file, and the
// merged object keeps `import crypto from "node:crypto"`, `import path from
// "node:path"` and `import os from "node:os"` usable after aliasing.
export default {
...cryptoApi,
...pathApi,
...osApi,
appendFile,
mkdir,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
rm,
mkdtemp,
promises,
existsSync,
readFileSync,
writeFileSync,
readdirSync,
mkdirSync,
rmSync,
mkdtempSync,
appendFileSync,
statSync,
promisify,
isIP,
lookup,
execFile,
execFileSync,
spawnSync,
fileURLToPath,
createServer,
};
-25
View File
@@ -1,25 +0,0 @@
// Minimal `process` shim injected into the UI fixture bundle. Server-only
// modules that leak into the bundle reference the `process` global at module
// scope (release ids, paths, etc). esbuild's `inject` rewrites those free
// identifier references to import from this file so evaluation cannot crash
// in the browser.
export const process = {
env: { NODE_ENV: "production" },
platform: "linux",
arch: "x64",
version: "v20.0.0",
versions: { node: "20.0.0" },
argv: [],
cwd: () => "/",
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
on: () => {},
once: () => {},
emit: () => {},
exit: () => {},
hrtime: Object.assign(() => [0, 0], { bigint: () => 0n }),
memoryUsage: () => ({ heapUsed: 0, rss: 0 }),
pid: 0,
};
export default process;
-136
View File
@@ -1,136 +0,0 @@
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();
});
+8 -56
View File
@@ -1,42 +1,23 @@
import AxeBuilder from "@axe-core/playwright";
import { test as base, expect, type Page } from "@playwright/test";
type Fixtures = { visualSnapshots: boolean };
// Visual baselines are committed for Chromium only; Firefox and WebKit are
// excluded because their pixel output is not expected to match the Chromium
// baselines. Functional and accessibility assertions still run everywhere.
const test = base.extend<Fixtures>({
visualSnapshots: async ({ browserName }, use) => {
await use(browserName === "chromium");
},
});
import { expect, type Page, test } from "@playwright/test";
async function accessible(page: Page, sandboxPreview = false) {
// WebKit can briefly report stale colors (e.g. dark-on-dark contrast)
// right after interactions while style invalidation settles, so poll
// until the audit is clean or the timeout expires.
await expect
.poll(
async () => {
const builder = new AxeBuilder({ page });
// A sandbox without allow-scripts cannot run axe; its accessible name is asserted separately.
if (sandboxPreview) builder.exclude("iframe[sandbox]");
const result = await builder
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
return result.violations.map(({ id, impact, nodes }) => ({
expect(
result.violations.map(({ id, impact, nodes }) => ({
id,
impact,
nodes: nodes.map((node) => ({
target: node.target,
summary: node.failureSummary,
})),
}));
},
{ timeout: 15000 },
)
.toEqual([]);
})),
).toEqual([]);
}
test.beforeEach(async ({ page, baseURL, request }) => {
@@ -54,7 +35,6 @@ test.beforeEach(async ({ page, baseURL, request }) => {
test("settings switches have names and remain keyboard operable", async ({
page,
visualSnapshots,
}) => {
await page.goto("/admin/settings");
const toggle = page.getByRole("switch", {
@@ -66,10 +46,8 @@ test("settings switches have names and remain keyboard operable", async ({
await page.keyboard.press("Space");
await expect(toggle).toBeChecked();
await accessible(page);
if (visualSnapshots) {
const group = page.locator("section").filter({ has: toggle });
await expect(group).toHaveScreenshot("settings-switches.png");
}
const response = page.waitForResponse((response) =>
response.url().endsWith("/fixture/actions/settings"),
);
@@ -84,7 +62,6 @@ test("settings switches have names and remain keyboard operable", async ({
test("article form and preview dialog pass accessibility with focus return", async ({
page,
visualSnapshots,
}) => {
await page.goto("/admin/articles/new");
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
@@ -104,47 +81,27 @@ test("article form and preview dialog pass accessibility with focus return", asy
await expect(dialog).toHaveAccessibleName("Article preview");
await expect(dialog.locator("iframe")).toHaveAttribute("title", /.+/);
await accessible(page, true);
if (visualSnapshots) {
await expect(dialog).toHaveScreenshot("article-preview.png");
}
// Keyboard events inside the sandbox do not bubble to the parent dialog.
// Wait until the dialog transferred focus to the preview iframe, then Tab
// must move focus to the dialog's Close button. Firefox can swallow a Tab
// while the sandboxed srcdoc frame is still loading, so retry until the
// focus lands where it belongs instead of racing the frame load.
const previewFrame = dialog.locator("iframe");
await expect(previewFrame).toBeFocused();
const closeButton = dialog.getByRole("button", {
name: "Close",
exact: true,
});
await expect
.poll(async () => {
await page.keyboard.press("Tab");
return closeButton.evaluate((el) => el === document.activeElement);
})
.toBe(true);
await expect(closeButton).toBeFocused();
await expect(
dialog.getByRole("button", { name: "Close", exact: true }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(preview).toBeFocused();
if (visualSnapshots) {
await expect(page.getByRole("main")).toHaveScreenshot("article-form.png");
}
});
test("support table filters and saved-view popover stay accessible", async ({
page,
visualSnapshots,
}) => {
await page.goto("/admin/tickets/desk");
await expect(
page.getByRole("link", { name: "Account assistance" }),
).toBeVisible();
await accessible(page);
if (visualSnapshots) {
await expect(page.getByRole("main")).toHaveScreenshot("ticket-table.png");
}
await page
.getByRole("button", { name: "Assigned to me", exact: true })
.click();
@@ -152,16 +109,13 @@ test("support table filters and saved-view popover stay accessible", async ({
await page.getByRole("button", { name: "Saved views", exact: true }).click();
await expect(page.getByRole("textbox", { name: "View name" })).toBeVisible();
await accessible(page);
if (visualSnapshots) {
await expect(page.getByRole("main")).toHaveScreenshot(
"ticket-saved-views.png",
);
}
});
test("queued synchronization survives reload without duplicate submission", async ({
page,
visualSnapshots,
}) => {
await page.goto("/admin/import/official");
const requests: string[] = [];
@@ -180,9 +134,7 @@ test("queued synchronization survives reload without duplicate submission", asyn
await page.locator("details details > summary").first().click();
await expect(page.getByText("fixture_chair", { exact: true })).toBeVisible();
await accessible(page);
if (visualSnapshots) {
await expect(page.getByRole("main")).toHaveScreenshot("sync-history.png");
}
});
test("article editor fits a narrow mobile card without horizontal overflow", async ({
+2 -17
View File
@@ -156,14 +156,7 @@ beforeAll(async () => {
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
delete process.env.SKIP_ENV_VALIDATION;
delete process.env.OPENAI_API_KEY;
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
// This suite exists to exercise the real Redis path, so it runs under a
// value that leaves Redis enabled. "development" is used because it is the
// only non-production value src/env.ts accepts. Vitest's own environment is
// still configured via vitest.integration.config.ts. Object.assign is used
// because process.env.NODE_ENV is typed read-only.
Object.assign(process.env, { NODE_ENV: "development" });
Object.assign(process.env, { NODE_ENV: "test" });
process.env.HOTEL_NAME = "Integration";
await connection.query(
@@ -387,8 +380,7 @@ describe("Redis application cache", () => {
let fetches = 0;
const fetch = async () => ({ revision: ++fetches });
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
// Second read is served from cache, so the origin is not consulted again.
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
expect(await appRedis?.get(key)).toBe('{"revision":1}');
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
cache.invalidateMemory(key);
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
@@ -409,9 +401,6 @@ describe("Redis application cache", () => {
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
"updated",
);
// `second`'s memory copy was dropped too, but its Redis entry survives, so
// the read is served from the shared cache and never recomputes. This is
// what makes the two entries independent.
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
"second",
);
@@ -629,9 +618,6 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(existing.status).toBe("draft");
expect(existing.publishedAt).toBeNull();
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
// A draft has no public article, so this read is a negative result that
// gets cached. Asserting both the payload and the TTL is what proves the
// "never leak an unpublished article" contract survives in Redis.
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
expect(await appRedis?.get(negativeKey)).toBe("null");
@@ -851,7 +837,6 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
// Cached negative results are stored as the JSON encoding of null.
expect(await redis.get(negativeKey)).toBe("null");
const publish = articleForm({
id: String(existing.id),
+1 -61
View File
@@ -1,47 +1,7 @@
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();
@@ -167,21 +127,6 @@ const nextConfig: NextConfig = {
formats: ["image/avif", "image/webp"],
},
// The SWF/Nitro image pipeline (src/lib/services/swf, src/lib/services/image-*)
// pulls shared helpers into several server chunks that webpack reports as a
// circular dependency between chunks with runtime. The cycle only affects
// server-side module caching of already-loaded chunks and has no runtime
// impact, so suppress the noisy warning instead of refactoring the shared
// helpers (which would risk the hot image path). Revisit if a real deadlock
// appears.
webpack(config) {
config.ignoreWarnings = [
...(config.ignoreWarnings ?? []),
/Circular dependency between chunks with runtime/,
];
return config;
},
async headers() {
return [
{
@@ -229,9 +174,4 @@ const nextConfig: NextConfig = {
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
// 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);
}
export default withNextIntl(nextConfig);
+43 -50
View File
@@ -5,22 +5,20 @@
"engines": {
"node": ">=26.10.0 <27"
},
"packageManager": "pnpm@12.11.2+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9",
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
"scripts": {
"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",
"dev": "pnpm assets:editor && next dev",
"build": "pnpm assets:editor && next build",
"start": "next start",
"toolchain:check": "node scripts/check-node-toolchain.mjs",
"readme:versions": "node scripts/sync-readme-versions.mjs",
"readme:versions:check": "node scripts/sync-readme-versions.mjs --check",
"lint": "biome check .",
"biome:lint": "biome check .",
"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": "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",
"test": "vitest run --coverage.enabled=false",
"test:coverage": "vitest run",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
"db:bulk": "tsx scripts/bulk-import-json.ts",
@@ -29,90 +27,85 @@
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"catalog:sync": "bash scripts/catalog-sync.sh",
"catalog:sync:cli": "node --conditions=react-server --import tsx scripts/catalog-sync.ts",
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.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",
"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",
"assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
"analyze": "next experimental-analyze",
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.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",
"test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts",
"test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots",
"test:e2e:quality": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.quality.config.ts",
"test:e2e": "playwright test",
"test:news:real": "node --import tsx e2e/news-real/run.ts",
"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:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"dependencies": {
"@base-ui/react": "1.9.0",
"@base-ui/react": "1.8.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.21",
"@formatjs/icu-messageformat-parser": "3.5.20",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "3.14.19",
"@tanstack/react-query": "5.104.0",
"@tanstack/react-virtual": "3.14.13",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"croner": "10.0.1",
"drizzle-orm": "0.45.4",
"drizzle-orm": "0.45.3",
"hash-wasm": "4.12.0",
"ioredis": "6.0.0",
"isomorphic-dompurify": "^4.5.0",
"isomorphic-dompurify": "^4.4.0",
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.57.0",
"lucide-react": "1.48.0",
"lzma-wasm": "1.0.7",
"motion": "14.2.0",
"music-metadata": "12.2.1",
"mysql2": "3.24.6",
"next": "16.4.1",
"motion": "13.4.4",
"music-metadata": "11.16.1",
"mysql2": "3.24.4",
"next": "16.3.6",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"next-intl": "4.14.7",
"otplib": "13.5.0",
"pino": "10.4.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "7.90.0",
"resend": "6.33.0",
"pino": "10.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.89.0",
"resend": "6.30.0",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
"tailwind-merge": "3.7.0",
"tinymce": "8.10.0",
"zod": "4.6.7"
"tinymce": "8.9.2",
"zod": "4.6.5"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "1.64.1",
"@babel/parser": "7.29.9",
"@biomejs/biome": "2.5.14",
"@playwright/test": "1.63.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/typography": "0.5.20",
"@types/node": "26.6.5",
"@types/node": "26.6.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "5.0.4",
"@vitest/coverage-v8": "5.0.2",
"drizzle-kit": "0.31.11",
"esbuild": "0.28.2",
"msw": "^3.0.3",
"pino-pretty": "13.2.0",
"postcss": "8.5.30",
"msw": "2.15.0",
"pino-pretty": "13.1.3",
"postcss": "8.5.28",
"tailwindcss": "4.3.3",
"testcontainers": "12.2.1",
"testcontainers": "12.1.0",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "5.0.4"
"vite": "8.3.1",
"vitest": "5.0.2"
}
}
+1 -8
View File
@@ -1,19 +1,12 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
testIgnore: ["**/ui/**", "**/news-real/**"],
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 30000,
reporter: [["list"], ["html", { open: "never" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure",
-41
View File
@@ -1,41 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
// Cross-browser imaging checks. Unlike the main e2e config (chromium only,
// used as the deploy gate), this runs the same specs across every bundled
// engine so an avatar format that only one browser can decode is caught.
//
// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local
// host port). Requires all three engines: `pnpm exec playwright install
// chromium firefox webkit`.
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
export default defineConfig({
testDir: "./e2e/imaging",
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 60000,
expect: {
timeout: 15000,
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
reporter: [
["list"],
["html", { outputFolder: "playwright-report/imaging", open: "never" }],
],
outputDir: "test-results/imaging",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
-29
View File
@@ -1,29 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
// Advisory quality suite. Unlike the main e2e config (the blocking deploy
// gate), this checks things that depend on live content and data — broken
// uploads, legacy malformed avatar figures, warm-page latency. Those are real
// signals but they are not something a code change can guarantee, so they must
// never block a deploy. Point it at a running CMS with PLAYWRIGHT_BASE_URL
// (defaults to the local host port).
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
export default defineConfig({
testDir: "./e2e/quality",
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 60000,
expect: { timeout: 15000 },
reporter: [
["list"],
["html", { outputFolder: "playwright-report/quality", open: "never" }],
],
outputDir: "test-results/quality",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+4 -12
View File
@@ -39,11 +39,11 @@ export default defineConfig({
},
projects: [
{
name: "desktop-chromium",
name: "desktop",
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
},
{
name: "mobile-chromium",
name: "mobile",
use: {
browserName: "chromium",
viewport: { width: 390, height: 844 },
@@ -52,20 +52,12 @@ export default defineConfig({
hasTouch: true,
},
},
{
name: "desktop-firefox",
use: { browserName: "firefox", viewport: { width: 1280, height: 900 } },
},
{
name: "desktop-webkit",
use: { browserName: "webkit", viewport: { width: 1280, height: 900 } },
},
],
webServer: {
command: "node e2e/ui/fixture-server.mjs",
url: `${baseURL}/health`,
timeout: 120000,
reuseExistingServer: true,
timeout: 60000,
reuseExistingServer: false,
env: { UI_TEST_PORT: String(port) },
},
});
+979 -1077
View File
File diff suppressed because it is too large. Load diff
-6
View File
@@ -16,9 +16,3 @@ overrides:
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'
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
# exactly and has no upstream bump. The API subset it uses is unchanged in
# v7, so force the patched major.
postcss-selector-parser: '^7.1.6'
+57 -30
View File
@@ -1,26 +1,5 @@
// 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.
const CACHE = "atom-v3";
const API_CACHE = "atom-api-v3";
self.addEventListener("install", () => self.skipWaiting());
@@ -28,13 +7,61 @@ self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
.then(() => self.registration.unregister())
.then(() =>
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== CACHE && k !== API_CACHE)
.map((k) => caches.delete(k)),
),
)
.then((clients) => {
for (const client of clients) client.navigate(client.url);
}),
.then(() => self.clients.claim()),
);
});
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));
}
});
Binary file not shown.
+1 -67
View File
@@ -1,69 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":timezone(Europe/Amsterdam)",
":semanticCommits",
"workarounds:all"
],
"labels": ["dependencies"],
"dependencyDashboard": true,
"dependencyDashboardOSVVulnerabilitySummary": "all",
"osvVulnerabilityAlerts": true,
"configMigration": true,
"automergeStrategy": "squash",
"schedule": [
"after 4:00 am and before 5:00 am on monday through friday",
"at 4:17 am on monday through friday"
],
"timezone": "Europe/Amsterdam",
"prConcurrentLimit": 5,
"prHourlyLimit": 3,
"minimumReleaseAge": "3 days",
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [
{
"description": "Minor en patch: één groeps-PR, automerge pas na groene CI",
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"groupName": "all non-major dependencies",
"automerge": true,
"platformAutomerge": false
},
{
"description": "Major updates vragen goedkeuring via het dependency-dashboard",
"matchUpdateTypes": ["major"],
"labels": ["major"],
"dependencyDashboardApproval": true
},
{
"description": "Beveiligingsfixes (OSV) niet tegenhouden door de wachttijd en in een eigen PR",
"matchCategories": ["security"],
"minimumReleaseAge": null,
"groupName": "security fixes",
"addLabels": ["security"]
},
{
"description": "Docker-compose draait op :latest; geen digest-pin-ruis",
"matchManagers": ["docker-compose"],
"pinDigests": false
},
{
"description": "Node-upgrades worden handmatig gecoördineerd over .nvmrc, package.json engines, @types/node, de Docker-basisimage en de runner; Renovate mag .nvmrc niet los bumpen (check-node-toolchain faalt anders)",
"matchManagers": ["nvm"],
"enabled": false
},
{
"description": "De Node-basisimage in de Dockerfile hoort bij dezelfde gecoördineerde Node-upgrade als .nvmrc",
"matchManagers": ["dockerfile"],
"matchPackageNames": ["node"],
"enabled": false
},
{
"description": "De pnpm packageManager-pin is afgestemd op Docker en de runner-toolchain; niet automatisch bumpen",
"matchPackageNames": ["pnpm"],
"enabled": false
}
]
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
-82
View File
@@ -20,95 +20,13 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
| `perf-report.sh` | Runs performance profiling. |
| `setup-dev.sh` | Configures development environment. |
| `check-updates.sh` | Checks for package and image updates. |
| `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. |
| `maintenance.sh` | Toggles maintenance mode flag. |
| `doctor.sh` | Runs a system diagnostic report. |
| `setup-cron.sh` | Configures automated cron jobs. |
| `alert.sh` | Sends notifications to a configured webhook. |
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
## Usage
Run any script directly, or use the dashboard:
```bash
./scripts/dashboard.sh
```
### Keeping the README versions in sync
Bumping a dependency (or the pinned Node.js version) leaves `README.md` stale.
Rather than editing it by hand, run:
```bash
pnpm readme:versions # rewrite README.md to match package.json / .nvmrc
pnpm readme:versions:check # verify only; exits 1 when out of date (used in CI)
```
It manages the `pnpm` and `Node.js` rows of the System Requirements table and
the "Core stack" line. The CI `check` job runs the `--check` variant, so a
version change cannot reach `main` with a stale README — the pipeline fails
until you run `pnpm readme:versions`.
### Updating the catalog (`catalog-sync.sh`)
Compares this hotel's catalog against a clone source (Habbo and the retros) and
fixes what is broken: missing furniture icons, missing `.nitro` bundles,
`FurnitureData.json` drift, orphaned/duplicate catalog rows, and items that
exist in `items_base` but have no catalog offer.
```bash
pnpm catalog:sync # interactive menu
./scripts/catalog-sync.sh --list-sources # which sites can be compared
./scripts/catalog-sync.sh --all --sources=default-habbo-com
./scripts/catalog-sync.sh --all --sql --apply-sql # catalogs changes are written
```
The menu asks which sources to compare (Habbo (COM) is the preset) and what may
be written. With no arguments it needs a terminal; with any flag it skips the
menu, so it is usable from cron.
Every step the engine exposes is available as a flag:
| Flag | What it does |
| :--- | :--- |
| `--sources=<a,b>` | Compare against these sources (id or display name) |
| `--icons` | Re-download missing furniture icons |
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) |
| `--structure` | Repair orphaned / duplicate catalog rows |
| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts |
| `--sql` / `--apply-sql` | Generate / apply the catalog SQL |
| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file |
| `--import-new` | Import furniture the source has and this hotel lacks |
| `--only=<a,b>`, `--limit=N`, `--concurrency=N` | Narrow and tune the import |
| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing |
| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift |
| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors |
| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) |
| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) |
| `--everything` | Every repair + import step, but still none of the destructive ones |
`--import-new` is the step that actually adds furniture: `cloneSingleFurni`
downloads the icon and the bundle (sources serve `.nitro`, the local copy is
stored as `.hab`), appends the `FurnitureData.json` entry **and** creates the
catalog page plus the offer, exactly like the app's own import. It skips
anything already present, so it is safe to re-run.
Safety notes:
- **An audit writes nothing** — no repair flag means read-only.
- `--dry-run` together with `--import-new` lists what would be imported and
imports nothing; use it first on a source before importing thousands of items.
- Generating the catalog SQL creates the `catalog_pages` sub-pages it references
(same as the app's import), so a "generate only" run is not entirely
read-only — only the `catalog_items` rows wait for `--apply-sql`. The SQL also
lands in `storage/catalog-sync/*.sql` (gitignored) so it can be reviewed or
replayed by hand.
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
Both only report without it.
- The engine is the same one the admin *Catalog audit* page streams
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
error-severity issues remain, which is what a scheduled check should use.
- Items that no compared source can provide stay missing: the run reports them
instead of inventing an icon or a bundle.
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
ENV_FILE="$DIR/.env"
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
PROJECT_NAME="epicnext-crowdsec"
CONTAINER_NAME="epicnext-crowdsec"
DEFAULT_DURATION="24h"
DEFAULT_MAX_DECISIONS="250000"
DEFAULT_SOURCES=(
# DDoS / abuse stoplists
"https://www.spamhaus.org/drop/drop.txt"
"https://www.spamhaus.org/drop/edrop.txt"
"https://www.dshield.org/block.txt"
"https://cinsscore.com/list/ci-badguys.txt"
"https://blocklist.greensnow.co/greensnow.txt"
"https://www.stopforumspam.com/downloads/toxic_ip_cidr.txt"
"https://www.binarydefense.com/banlist.txt"
# Brute force / credential stuffing
"https://lists.blocklist.de/lists/all.txt"
"https://lists.blocklist.de/lists/ssh.txt"
"https://lists.blocklist.de/lists/apache.txt"
"https://rules.emergingthreats.net/blockrules/compromised-ips.txt"
"https://danger.rulez.sk/projects/bruteforceblocker/blist.php"
# Malware C2 / botnets
"https://feodotracker.abuse.ch/downloads/ipblocklist.txt"
"https://sslbl.abuse.ch/blacklist/sslipblacklist.txt"
"https://www.botvrij.eu/data/ioclist.ip-dst.raw"
# Live SSH/spam attackers (last 48h), bare IPs only
"https://www.darklist.de/raw.php"
# Aggregated threat intel
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt"
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/2.txt"
# Firehol ipsets (security scanners, abusers, proxies, anonymous)
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level2.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_abusers_1d.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_abusers_30d.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_proxies.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_anonymous.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level3.netset"
# Tor exit nodes
"https://check.torproject.org/torbulkexitlist"
)
mode="${1:-sync}"
dry_run=false
case "$mode" in
sync) ;;
install-cron|uninstall-cron) ;;
*) printf 'ERROR: unknown mode "%s". Modes: sync [--dry-run] | install-cron | uninstall-cron\n' "$mode" >&2; exit 1 ;;
esac
[[ "${2:-}" = --dry-run ]] && dry_run=true
umask 077
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
for command in docker curl flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
exec 9>"$DIR/.deploy.lock"
flock -w 30 9 || fail "Another installation, update or sync is running."
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
env_get() {
local key="$1" line
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
esac
done < "$ENV_FILE"
return 1
}
compose_cmd() {
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
}
container_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
}
cscli_exec() {
compose_cmd exec -T crowdsec cscli "$@"
}
fetch_sources() {
local target="$1"
local sources=( "${DEFAULT_SOURCES[@]}" )
IFS=' ' read -r -a parsed <<< "${CROWDSEC_BLOCKLIST_SOURCES:-}"
[[ "${#parsed[@]}" -gt 0 ]] && sources=( "${parsed[@]}" )
local index=0 url
for url in "${sources[@]}"; do
[[ -n "$url" ]] || continue
index=$((index + 1))
if ! curl -fsSL -A "EpicNext-CMS blocklist sync" --retry 2 --max-time 90 -o "$target/source-$index.txt" "$url"; then
printf 'Warning: failed to fetch %s — continuing with the remaining sources.\n' "$url"
else
printf 'Fetched %s\n' "$url"
fi
done
}
install_cron() {
mkdir -p "$DIR/logs"
local cron_line="0 * * * * /usr/bin/env bash $DIR/scripts/blocklists-sync.sh >> $DIR/logs/blocklists-sync.log 2>&1"
if crontab -l 2>/dev/null | grep -Fq "$DIR/scripts/blocklists-sync.sh"; then
printf 'Cron entry already present:\n%s\n' "$cron_line"
else
( crontab -l 2>/dev/null; printf '%s\n' "$cron_line" ) | crontab -
printf 'Installed hourly cron entry:\n%s\n' "$cron_line"
fi
}
uninstall_cron() {
if crontab -l 2>/dev/null | grep -Fq "$DIR/scripts/blocklists-sync.sh"; then
crontab -l 2>/dev/null | grep -Fv "$DIR/scripts/blocklists-sync.sh" | crontab -
printf 'Removed cron entry matching %s.\n' "$DIR/scripts/blocklists-sync.sh"
else
printf 'No cron entry to remove.\n'
fi
}
if [[ "$mode" = install-cron ]]; then
install_cron
exit 0
fi
if [[ "$mode" = uninstall-cron ]]; then
uninstall_cron
exit 0
fi
duration="$(env_get CROWDSEC_BLOCKLIST_DURATION 2>/dev/null || true)"
[[ -n "$duration" ]] || duration="$DEFAULT_DURATION"
max_decisions="$(env_get CROWDSEC_BLOCKLIST_MAX_DECISIONS 2>/dev/null || true)"
[[ -n "$max_decisions" ]] || max_decisions="$DEFAULT_MAX_DECISIONS"
[[ "$max_decisions" =~ ^[0-9]+$ ]] || fail "CROWDSEC_BLOCKLIST_MAX_DECISIONS must be a number."
allowlist="${CROWDSEC_BLOCKLIST_ALLOW:-$(env_get CROWDSEC_BLOCKLIST_ALLOW 2>/dev/null || true)}"
work="$(mktemp -d "$DIR/.blocklists.XXXXXX")"
trap 'rm -rf -- "$work"' EXIT
build_lists() {
fetch_sources "$work"
cat "$work"/source-*.txt 2>/dev/null | awk '{print $1}' \
| grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$|^([0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F:]*[0-9a-fA-F](/[0-9]{1,3})?$' \
| awk '
# Only globally routable attacker space may become a decision. Reserved,
# private, loopback, link-local, CGNAT, test and multicast ranges never
# represent an external attacker and must not be imported (they could
# otherwise block the origin itself or internal traffic).
function isReserved4(prefix, a, b, c) {
if (a == 0 || a == 127 || a >= 224) return 1
if (a == 10) return 1
if (a == 100 && (prefix < 10 || (prefix >= 10 && b >= 64 && b <= 127))) return 1
if (a == 169 && (prefix < 16 || (prefix >= 16 && b == 254))) return 1
if (a == 172 && (prefix < 12 || (prefix >= 12 && b >= 16 && b <= 31))) return 1
if (a == 192 && b == 168) return 1
if (a == 192 && b == 0) return 1
if ((a == 198 && (b == 18 || b == 19)) || (a == 198 && b == 51 && c == 100)) return 1
if (a == 203 && b == 0 && c == 113) return 1
return 0
}
function isReserved6(line, prefix, first, h) {
if (prefix < 32) return 1
if (line ~ /^::/) return 1
first = tolower(line); sub(/^::?/, "", first); sub(/:.*/, "", first)
h = "0x" substr(first, 1, 2)
if (h >= 252) return 1 # ULA fc00::/7, link-local fe80::/10, multicast ff00::/8
return 0
}
{
if (index($0, "/") > 0) {
n = split($0, seg, "/")
if (n != 2 || seg[2] !~ /^[0-9]+$/) next
if (index(seg[1], ":") > 0) {
pref = seg[2] + 0
if (pref < 32 || pref > 128) next
if (isReserved6(seg[1], pref)) next
print
next
}
pref = seg[2] + 0
if (pref < 8 || pref > 32) next
split(seg[1], oct, ".")
ok = 1
for (i = 1; i <= 4; i++) {
if (oct[i] !~ /^[0-9]+$/ || oct[i] + 0 > 255) { ok = 0; break }
if (length(oct[i]) > 1 && oct[i] ~ /^0/) { ok = 0; break }
}
if (!ok) next
if (isReserved4(pref, oct[1] + 0, oct[2] + 0, oct[3] + 0)) next
print
next
}
if (index($0, ":") > 0) {
if (isReserved6($0, 128)) next
print
next
}
n = split($0, part, ".")
if (n != 4) next
ok = 1
for (i = 1; i <= 4; i++) {
if (part[i] !~ /^[0-9]+$/ || part[i] + 0 > 255) { ok = 0; break }
if (length(part[i]) > 1 && part[i] ~ /^0/) { ok = 0; break }
}
if (!ok) next
if (isReserved4(32, part[1] + 0, part[2] + 0, part[3] + 0)) next
print
}' \
| sort -u > "$work/candidates.txt"
if [[ -n "$allowlist" ]]; then
printf '%s\n' "$allowlist" | tr ',' '\n' | while IFS= read -r line; do printf '%s\n' "$line"; done | sort -u > "$work/allow.txt"
comm -23 "$work/candidates.txt" "$work/allow.txt" > "$work/final.txt"
else
cp "$work/candidates.txt" "$work/final.txt"
fi
if [[ "$(wc -l < "$work/final.txt" | tr -d ' ')" -gt "$max_decisions" ]]; then
sort -u "$work/final.txt" | head -n "$max_decisions" > "$work/final.limited.txt" || true
mv "$work/final.limited.txt" "$work/final.txt"
printf 'Note: capped the combined list at %s decisions (CROWDSEC_BLOCKLIST_MAX_DECISIONS).\n' "$max_decisions"
fi
count_total=$(wc -l < "$work/final.txt" | tr -d ' ')
count_ip=$(grep -cv '/' "$work/final.txt" || true)
count_range=$(grep -c '/' "$work/final.txt" || true)
if [[ "$count_total" -lt 1 ]]; then
fail "No valid addresses could be parsed from the configured sources. Configure CROWDSEC_BLOCKLIST_SOURCES."
fi
}
build_lists
printf 'Parsed %s targets (%s IPs, %s ranges).\n' "$count_total" "$count_ip" "$count_range"
if $dry_run; then
printf 'Dry run: would replace the cscli-import decisions with these %s targets.\n' "$count_total"
exit 0
fi
container_running || fail "The CrowdSec engine is not running. Start it first with: bash cms security"
printf 'Removing previous cscli-import decisions...\n'
cscli_exec decisions delete --origin cscli-import >/dev/null 2>&1 || true
{
printf 'duration,scope,value\n'
awk -v d="$duration" '{ if (index($0, "/") > 0) printf "%s,range,%s\n", d, $0; else printf "%s,ip,%s\n", d, $0 }' "$work/final.txt"
} > "$work/import.csv"
printf 'Importing %s decisions into the local LAPI (duration %s)...\n' "$count_total" "$duration"
cscli_exec decisions import -i - --format csv --batch 1000 < "$work/import.csv"
printf 'Done. The app bouncer picks these up within a few seconds.\n'
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env bash
# Update the hotel catalog from a clone source (Habbo, retros, …).
#
# Two ways to use it:
# ./scripts/catalog-sync.sh interactive menu
# ./scripts/catalog-sync.sh --all --sql same flags as the CLI, no menu
#
# The menu asks which sites to compare (Habbo is the preset) and what may be
# written. An audit is read-only; only a repair option touches the catalog, and
# applying the generated catalog SQL always needs its own choice.
cd "$(dirname "$0")/.." || exit 1
CLI="scripts/catalog-sync.ts"
# The react-server export condition resolves `server-only` to its no-op build,
# exactly like `pnpm jobs:worker` — the audit engine imports server modules.
RUN=(node --conditions=react-server --import tsx "$CLI")
# Any flag passed straight through means "no menu", so the same entry point
# works for cron/CI where only the flag path is safe.
if [ $# -gt 0 ]; then
exec "${RUN[@]}" "$@"
fi
if [ ! -t 0 ]; then
echo "Not a terminal. Use the non-interactive form, e.g.:" >&2
echo " $0 --all --sources=default-habbo-com" >&2
exit 1
fi
echo "=== Catalog sync ==="
echo
# ── Step 1: which sites to compare ───────────────────────────────────────────
# Ask the CLI for the configured sources so the menu always matches the live
# `clone_sources` setting (and its defaults) instead of a hardcoded list.
mapfile -t SOURCE_ROWS < <("${RUN[@]}" --list-sources --tsv)
if [ "${#SOURCE_ROWS[@]}" -eq 0 ]; then
echo "No clone sources are configured." >&2
exit 1
fi
IDS=()
NAMES=()
echo "Compare this catalog against:"
for i in "${!SOURCE_ROWS[@]}"; do
IFS=$'\t' read -r SOURCE_ID SOURCE_NAME _SOURCE_URL <<<"${SOURCE_ROWS[$i]}"
IDS+=("$SOURCE_ID")
NAMES+=("$SOURCE_NAME")
printf " %2d) %-24s %s\n" "$((i + 1))" "$SOURCE_NAME" "$SOURCE_ID"
done
echo
echo " a) all sources above"
echo " Enter) preset: ${NAMES[0]}"
read -r -p "Sources [a, 1-${#IDS[@]}, Enter]: " SOURCE_CHOICE
SELECTED_FLAG=""
case "$SOURCE_CHOICE" in
"" | "Habbo" | "habbo" | "habbo.com")
# Habbo is the first default preset (Habbo (COM)).
SELECTED_FLAG="--sources=${IDS[0]}"
SOURCE_LABEL="${NAMES[0]}"
;;
a | A | all)
SOURCE_LABEL="all sources"
;;
*)
SELECTED_IDS=()
IFS=', ' read -r -a CHOICES <<<"$SOURCE_CHOICE"
for CHOICE in "${CHOICES[@]}"; do
[ -z "$CHOICE" ] && continue
case "$CHOICE" in
*[!0-9]* | "")
echo "Invalid choice: '$CHOICE'" >&2
exit 1
;;
*)
if [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#IDS[@]}" ]; then
echo "Out of range: $CHOICE" >&2
exit 1
fi
SELECTED_IDS+=("${IDS[$((CHOICE - 1))]}")
;;
esac
done
if [ "${#SELECTED_IDS[@]}" -eq 0 ]; then
echo "No sources selected." >&2
exit 1
fi
SELECTED_FLAG="--sources=$(IFS=,; echo "${SELECTED_IDS[*]}")"
SOURCE_LABEL="${#SELECTED_IDS[@]} source(s)"
;;
esac
# ── Step 2: what may be changed ──────────────────────────────────────────────
cat <<EOF
What should be done? (comparing with: $SOURCE_LABEL)
1) Audit only — nothing is written
2) Repair furniture icons + nitro bundles
3) 2 + FurnitureData.json + catalog structure
4) 3 + generate the catalog SQL (saved, not applied)
5) 4 + APPLY the catalog SQL to the catalog
6) 5 + import the furniture the source has and this hotel lacks
(downloads icon + bundle, adds FurnitureData.json, creates the catalog
page and the offer for every new item)
7) 6 + reconcile FurnitureData.json / sprite ids / offer ids
8) 7 + align items_base ids and rename legacy .nitro bundles (heaviest)
d) Dry run — only report what 6 would import
q) Cancel
EOF
read -r -p "Action [1]: " ACTION_CHOICE
ACTION_CHOICE="${ACTION_CHOICE:-1}"
ACTIONS=(audit)
CONFIRM=""
# Empty for the audit-only branch on purpose: no repair flags are passed.
ACTION_FLAGS=()
# Prints a warning when the chosen source only serves furnidata (no .nitro
# bundles), so the import step would find nothing to download.
warn_compare_only_sources() {
case "$SOURCE_LABEL" in
"Habbo (COM)" | "Habbo (NL)" | "Habbo (DE)" | "Habbo (FR)" | \
"Habbo (ES)" | "Habbo (FI)" | "Habbo (BR)" | "Habbo (TR)" | \
"Habbo (IT - GitHub mirror)" | "YabboHotel" | "Wibbo" | "Hubba.cc")
echo
echo "Note: ${SOURCE_LABEL} only serves furnidata — there are no .nitro"
echo "bundles to import, so the import step can only COMPARE."
echo "To actually import furniture, pick a source that serves bundles"
echo "(Habblet City, VirtualCity, SodaStudios, Habboon, Leet, Hubbly) or 'a'."
;;
esac
}
case "$ACTION_CHOICE" in
1)
ACTIONS=(audit "[nothing written]")
;;
2)
ACTIONS=(audit "icons + nitro bundles downloaded")
ACTION_FLAGS=(--icons --nitros)
;;
3)
ACTIONS=(audit "icons + nitro bundles + FurnitureData.json + structure")
ACTION_FLAGS=(--all)
;;
4)
ACTIONS=(audit "everything + catalog SQL generated")
ACTION_FLAGS=(--all --sql)
;;
5)
ACTIONS=(audit "everything + catalog SQL APPLIED")
ACTION_FLAGS=(--all --sql --apply-sql)
CONFIRM="This writes to the catalog. Continue? [y/N]: "
;;
6)
ACTIONS=(audit "everything + catalog SQL + import new furniture")
ACTION_FLAGS=(--all --sql --apply-sql --import-new)
CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: "
;;
7)
ACTIONS=(audit "everything + import + reconcile")
ACTION_FLAGS=(
--all --sql --apply-sql --import-new --reconcile --fix-sprite-ids
--rebuild-offers
)
CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: "
;;
8)
ACTIONS=(audit "everything + import + reconcile + id alignment + rename")
ACTION_FLAGS=(
--all --sql --apply-sql --import-new --reconcile --fix-sprite-ids
--rebuild-offers --align-ids --rename-bundles --apply
)
read -r -p " This rewrites items_base ids and renames bundle files. Type RUN to confirm: " REPLY
if [ "$REPLY" != "RUN" ]; then
echo "Cancelled."
exit 0
fi
;;
d | D)
ACTIONS=(audit "report only — nothing is imported")
ACTION_FLAGS=(--import-new --dry-run)
;;
q | Q)
echo "Cancelled."
exit 0
;;
*)
echo "Invalid action: '$ACTION_CHOICE'" >&2
exit 1
;;
esac
echo
printf 'Running: %s against %s\n' "${ACTIONS[1]}" "$SOURCE_LABEL"
case "$ACTION_FLAGS" in
*--import-new*) warn_compare_only_sources ;;
esac
if [ -n "$CONFIRM" ]; then
read -r -p "$CONFIRM" REPLY
if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then
echo "Cancelled."
exit 0
fi
fi
echo
# shellcheck disable=SC2086
"${RUN[@]}" $SELECTED_FLAG "${ACTION_FLAGS[@]}"
-259
View File
@@ -1,259 +0,0 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
canImportBundles,
DEFAULT_CATALOG_SYNC_FLAGS,
describeActions,
parseCatalogSyncArgs,
selectImportCandidates,
writeGeneratedSql,
} from "./catalog-sync";
const entry = (classname: string) =>
({ classname, name: classname, itemType: "s" }) as never;
describe("catalog-sync argument parsing", () => {
it("defaults to a read-only audit with no filters", () => {
expect(parseCatalogSyncArgs([])).toEqual({
...DEFAULT_CATALOG_SYNC_FLAGS,
sourceIds: [],
});
expect(describeActions(parseCatalogSyncArgs([]))).toBe("audit");
});
it("accepts comma-separated source ids", () => {
const flags = parseCatalogSyncArgs([
"--sources=default-habbo-com,default-wibbo",
]);
expect(flags.sourceIds).toEqual(["default-habbo-com", "default-wibbo"]);
});
it("accepts display names, including ones with spaces", () => {
const flags = parseCatalogSyncArgs(["--sources=Habbo (COM), Wibbo"]);
expect(flags.sourceIds).toEqual(["Habbo (COM)", "Wibbo"]);
});
it("trims and drops empty source entries", () => {
expect(parseCatalogSyncArgs(["--sources= a , ,b,"]).sourceIds).toEqual([
"a",
"b",
]);
});
it("turns --all into every repair step", () => {
const flags = parseCatalogSyncArgs(["--all"]);
expect(flags.repairIcons).toBe(true);
expect(flags.repairNitros).toBe(true);
expect(flags.repairFurniData).toBe(true);
expect(flags.repairStructure).toBe(true);
expect(flags.checkFurniDataIds).toBe(true);
// Generating/applying SQL is never implied by a repair.
expect(flags.generateSql).toBe(false);
expect(flags.applySql).toBe(false);
});
it("treats applying SQL as implying generation", () => {
expect(parseCatalogSyncArgs(["--apply-sql"]).generateSql).toBe(true);
expect(parseCatalogSyncArgs(["--organize-sql"]).generateSql).toBe(true);
expect(describeActions(parseCatalogSyncArgs(["--apply-sql"]))).toBe(
"audit + apply-sql",
);
});
it("accepts the alias spellings of the repair flags", () => {
const flags = parseCatalogSyncArgs([
"--repair-icons",
"--repair-nitros",
"--repair-furnidata",
"--repair-structure",
]);
expect(flags.repairIcons).toBe(true);
expect(flags.repairNitros).toBe(true);
expect(flags.repairFurniData).toBe(true);
expect(flags.repairStructure).toBe(true);
});
it("ignores a bare -- separator", () => {
expect(parseCatalogSyncArgs(["--", "--icons"]).repairIcons).toBe(true);
});
it("rejects an unknown flag", () => {
expect(() => parseCatalogSyncArgs(["--nope"])).toThrow(/Unknown flag/);
});
it("rejects --sources without a value", () => {
expect(() => parseCatalogSyncArgs(["--sources"])).toThrow(/needs a value/);
});
});
describe("catalog-sync import selection", () => {
it("skips furniture this hotel already has", () => {
const candidates = selectImportCandidates(
[entry("chair"), entry("table"), entry("") as never],
new Set(["chair"]),
);
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
});
it("is case-insensitive against the present set", () => {
const candidates = selectImportCandidates(
[entry("Chair"), entry("table")],
new Set(["chair"]),
);
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
});
it("honours the --only filter, also case-insensitively", () => {
const candidates = selectImportCandidates(
[entry("Chair"), entry("table"), entry("lamp")],
new Set(),
{ only: ["chair"] },
);
expect(candidates.map((c) => c.classname)).toEqual(["Chair"]);
});
it("caps the list with --limit", () => {
const candidates = selectImportCandidates(
[entry("a"), entry("b"), entry("c")],
new Set(),
{ limit: 2 },
);
expect(candidates.map((c) => c.classname)).toEqual(["a", "b"]);
});
it("returns everything when no limit is given", () => {
expect(
selectImportCandidates([entry("a"), entry("b")], new Set(), {
limit: 0,
}).length,
).toBe(2);
});
});
describe("catalog-sync import feasibility", () => {
it("imports from a source that serves bundles", () => {
expect(
canImportBundles({
id: "default-habblet-city",
name: "Habblet City",
furnidataUrl: "https://www.habblet.city/gamedata/furnidata.json",
nitroBaseUrl: "https://www.habblet.city/nitro/bundled/furniture",
iconBaseUrl: "",
hotel: "com",
} as never),
).toBe(true);
});
// The Habbo presets have no nitro/icon base URL: their furnidata can be
// compared, but there is nothing to download from.
it("does not import from a compare-only source", () => {
expect(
canImportBundles({
id: "default-habbo-com",
name: "Habbo (COM)",
furnidataUrl: "https://www.habbo.com/gamedata/furnidata_json/1",
nitroBaseUrl: "",
iconBaseUrl: "",
hotel: "com",
} as never),
).toBe(false);
});
});
describe("catalog-sync step composition", () => {
it("--everything turns on every step except the writing ones", () => {
const flags = parseCatalogSyncArgs(["--everything"]);
expect(describeActions(flags)).toBe(
[
"audit",
"icons",
"nitros",
"furnidata",
"structure",
"id-check",
"import",
"reconcile",
"sprite-ids",
"offers",
].join(" + "),
);
// Destructive steps stay opt-in even inside --everything.
expect(flags.applySql).toBe(false);
expect(flags.apply).toBe(false);
expect(flags.alignIds).toBe(false);
expect(flags.renameBundles).toBe(false);
});
it("marks an import as a dry run", () => {
expect(
describeActions(parseCatalogSyncArgs(["--import-new", "--dry-run"])),
).toContain("import (dry)");
});
it("marks the child steps as reporting unless --apply is given", () => {
expect(
describeActions(
parseCatalogSyncArgs(["--align-ids", "--rename-bundles"]),
),
).toContain("align-ids");
expect(
describeActions(
parseCatalogSyncArgs(["--align-ids", "--rename-bundles", "--apply"]),
),
).toContain("rename=apply-bundles");
});
it("parses the import tuning flags", () => {
const flags = parseCatalogSyncArgs([
"--import-new",
"--limit=50",
"--concurrency=8",
"--only=chair,table",
]);
expect(flags.importLimit).toBe(50);
expect(flags.importConcurrency).toBe(8);
expect(flags.importOnly).toEqual(["chair", "table"]);
});
it("rejects a non-numeric --limit", () => {
expect(() => parseCatalogSyncArgs(["--limit=abc"])).toThrow(/integer/);
});
it("rejects --dry-run without an import to preview", () => {
expect(() => parseCatalogSyncArgs(["--dry-run"])).toThrow(
/together with --import-new/,
);
});
});
describe("generated catalog SQL output", () => {
let cwd = "";
beforeEach(() => {
cwd = process.cwd();
});
afterEach(() => {
vi.restoreAllMocks();
process.chdir(cwd);
});
it("writes the SQL under storage/catalog-sync with a trailing newline", async () => {
let target = "";
try {
const dir = mkdtempSync(join(tmpdir(), "catalog-sync-sql-"));
target = dir;
process.chdir(dir);
const file = await writeGeneratedSql("INSERT INTO `catalog_items` (1);");
expect(file.startsWith("storage/catalog-sync/")).toBe(true);
expect(file.endsWith(".sql")).toBe(true);
expect(readFileSync(join(dir, file), "utf8")).toBe(
"INSERT INTO `catalog_items` (1);\n",
);
} finally {
if (target) rmSync(target, { recursive: true, force: true });
}
});
});
-815
View File
@@ -1,815 +0,0 @@
// Command-line driver for the catalog audit/repair pipeline.
//
// The same engine the admin "Catalog audit" UI streams from (`runCatalogAudit`),
// for the terminal: audit the hotel catalog against one or more clone sources
// (Habbo and the retros), fix the furniture that is broken, and bring missing
// items into the catalog — optionally writing and applying the catalog SQL.
//
// Usage:
// pnpm catalog:sync # interactive menu (scripts/catalog-sync.sh)
// pnpm exec tsx scripts/catalog-sync.ts --list-sources
// pnpm exec tsx scripts/catalog-sync.ts --sources="Habbo (COM)" # audit only
// pnpm exec tsx scripts/catalog-sync.ts --all --sources=default-habbo-com
// pnpm exec tsx scripts/catalog-sync.ts --all --sql --apply-sql # + write/apply SQL
//
// Flags:
// --list-sources print the configured clone sources and exit
// --tsv with the above, print `id\tname\tfurnidataUrl` rows
// --sources=<a,b> compare against these sources (id or name, comma-separated)
// --icons re-download missing furniture icons from the sources
// --nitros re-download missing .nitro / swf bundles (stored as .hab)
// --furnidata repair FurnitureData.json (add missing, drop duplicates)
// --structure repair orphaned / duplicate catalog rows
// --check-ids check FurnitureData.json for spriteId conflicts
// --sql generate the catalog SQL for items missing an offer
// --apply-sql apply the generated SQL (implies --sql)
// --organize-sql order the generated SQL by catalog page (implies --sql)
// --all --icons --nitros --furnidata --structure --check-ids
// --import-new import furniture the source has and this hotel lacks
// --only=<a,b> restrict the import to these classnames
// --limit=N stop the import after N items
// --concurrency=N parallel imports (default 3)
// --reconcile align FurnitureData.json with items_base after importing
// --fix-sprite-ids correct items_base.sprite_id drift
// --rebuild-offers rebuild catalog offer_id mirrors
// --align-ids move items_base ids onto their furnidata ids (report;
// add --apply to let the script execute it)
// --rename-bundles rename legacy .nitro bundles to .hab (report; --apply runs it)
// --json print one machine-readable JSON report
// --dry-run with --import-new: list what would be imported, import nothing
// --fail-on-errors exit 3 when error-severity issues remain
//
// Audit is read-only by default: nothing is written until one of the repair
// flags is passed. One nuance: generating the catalog SQL also creates the
// `catalog_pages` category sub-pages it references (exactly like the app's own
// import does) — only the `catalog_items` rows wait for `--apply-sql`. Use
// `--organize-sql` to also emit those page INSERTs into the .sql file.
import "./load-env";
import { spawnSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
import type { AuditEvent, AuditSummary } from "@/lib/services/catalog-audit";
import type { SourceFurni } from "@/lib/services/clone-import";
import type { CloneSource } from "@/lib/services/clone-sources";
/** Where generated catalog SQL is written (`storage/` is gitignored). */
function sqlOutputDir(): string {
return join(process.cwd(), "storage", "catalog-sync");
}
export type SourceRef = CloneSource;
export type AuditIssue = NonNullable<AuditEvent["issues"]>[number];
export type MissingFromSource = NonNullable<
AuditEvent["missingFromSources"]
>[number];
export interface AuditReport {
sources: SourceRef[];
summary?: AuditSummary;
issues: AuditIssue[];
missingFromSources: MissingFromSource[];
sqlFile?: string;
imported?: ImportOutcome;
}
export interface ImportOutcome {
source: string;
candidates: number;
imported: number;
skipped: number;
failed: number;
failures: Array<{ classname: string; error: string }>;
/** Sources that were skipped because they cannot serve bundles. */
skippedSources: string[];
}
/**
* Whether a source can actually be imported from.
*
* The clone import downloads `${nitroBaseUrl}/${classname}.nitro` and gives up
* when that is not reachable. Habbo's own entries carry no nitro/icon base URLs
* (their furnidata is served, their assets are not), so they are compare-only:
* perfect for finding out what this hotel lacks, useless for actually fetching
* it. The SWF-based path that works against `images.habbo.com` lives in
* `furni-import.ts`, not in the clone importer this CLI uses.
*/
export function canImportBundles(source: SourceRef): boolean {
return Boolean(source.nitroBaseUrl?.trim());
}
export interface CatalogSyncFlags {
listSources: boolean;
tsv: boolean;
sourceIds: string[];
repairIcons: boolean;
repairNitros: boolean;
repairFurniData: boolean;
repairStructure: boolean;
checkFurniDataIds: boolean;
generateSql: boolean;
applySql: boolean;
organizeSql: boolean;
json: boolean;
failOnErrors: boolean;
importNew: boolean;
dryRun: boolean;
importLimit: number;
importConcurrency: number;
importOnly: string[];
reconcile: boolean;
fixSpriteIds: boolean;
rebuildOffers: boolean;
alignIds: boolean;
renameBundles: boolean;
apply: boolean;
}
export const DEFAULT_CATALOG_SYNC_FLAGS: Omit<CatalogSyncFlags, "sourceIds"> = {
listSources: false,
tsv: false,
repairIcons: false,
repairNitros: false,
repairFurniData: false,
repairStructure: false,
checkFurniDataIds: false,
generateSql: false,
applySql: false,
organizeSql: false,
json: false,
failOnErrors: false,
importNew: false,
dryRun: false,
importLimit: 0,
importConcurrency: 3,
importOnly: [],
reconcile: false,
fixSpriteIds: false,
rebuildOffers: false,
alignIds: false,
renameBundles: false,
apply: false,
};
const BOOLEAN_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--list-sources": "listSources",
"--tsv": "tsv",
"--icons": "repairIcons",
"--repair-icons": "repairIcons",
"--nitros": "repairNitros",
"--repair-nitros": "repairNitros",
"--furnidata": "repairFurniData",
"--repair-furnidata": "repairFurniData",
"--structure": "repairStructure",
"--repair-structure": "repairStructure",
"--check-ids": "checkFurniDataIds",
"--sql": "generateSql",
"--apply-sql": "applySql",
"--organize-sql": "organizeSql",
"--json": "json",
"--fail-on-errors": "failOnErrors",
"--import-new": "importNew",
"--dry-run": "dryRun",
"--reconcile": "reconcile",
"--fix-sprite-ids": "fixSpriteIds",
"--rebuild-offers": "rebuildOffers",
"--align-ids": "alignIds",
"--rename-bundles": "renameBundles",
// Authorises the two heavyweight child steps that rewrite stored data.
"--apply": "apply",
};
const NUMBER_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--limit": "importLimit",
"--concurrency": "importConcurrency",
};
const LIST_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--only": "importOnly",
};
export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
const flags: CatalogSyncFlags = {
...DEFAULT_CATALOG_SYNC_FLAGS,
sourceIds: [],
};
for (const arg of raw) {
if (arg === "--") continue;
if (arg === "--all") {
flags.repairIcons = true;
flags.repairNitros = true;
flags.repairFurniData = true;
flags.repairStructure = true;
flags.checkFurniDataIds = true;
continue;
}
if (arg === "--everything") {
flags.repairIcons = true;
flags.repairNitros = true;
flags.repairFurniData = true;
flags.repairStructure = true;
flags.checkFurniDataIds = true;
flags.importNew = true;
flags.reconcile = true;
flags.fixSpriteIds = true;
flags.rebuildOffers = true;
continue;
}
const separator = arg.indexOf("=");
const name = separator >= 0 ? arg.slice(0, separator) : arg;
const booleanFlag = BOOLEAN_FLAGS[name];
if (booleanFlag) {
flags[booleanFlag] = true as never;
continue;
}
if (name === "--sources" || name === "--source") {
if (separator < 0)
throw new Error(
`${name} needs a value, e.g. --sources=default-habbo-com`,
);
const value = arg.slice(separator + 1);
for (const part of value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean))
flags.sourceIds.push(part);
continue;
}
if (NUMBER_FLAGS[name]) {
if (separator < 0)
throw new Error(`${name} needs a value, e.g. ${name}=10`);
const parsed = Number(arg.slice(separator + 1));
if (!Number.isInteger(parsed) || parsed < 0)
throw new Error(`${name} needs a non-negative integer`);
flags[NUMBER_FLAGS[name]] = parsed as never;
continue;
}
if (LIST_FLAGS[name]) {
if (separator < 0)
throw new Error(`${name} needs a value, e.g. ${name}=chair,table`);
const value = arg.slice(separator + 1);
for (const part of value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean))
flags.importOnly.push(part);
continue;
}
throw new Error(
`Unknown flag: ${arg} (run with --help for the supported flags)`,
);
}
// Applying SQL implies generating it, and organising it implies generating.
if (flags.applySql) flags.generateSql = true;
if (flags.organizeSql) flags.generateSql = true;
// A dry run without an import step has nothing to preview.
if (flags.dryRun && !flags.importNew)
throw new Error("--dry-run only makes sense together with --import-new");
if (flags.importNew && flags.applySql) {
// Both write; that is allowed but it is worth making the operator think.
logNote?.("--import-new and --apply-sql will both write to the catalog");
}
return flags;
}
let logNote: ((line: string) => void) | undefined;
/**
* Pick the furniture to import from a source's furnidata.
*
* Pure so the filtering rules (skip what we already have, `--only` filter,
* `--limit` cap) can be tested without a database.
*/
export function selectImportCandidates(
entries: SourceFurni[],
present: Set<string>,
options: { only?: string[]; limit?: number } = {},
): SourceFurni[] {
const normalize = (value: string) => value.trim().toLowerCase();
const only = options.only?.length
? new Set(options.only.map(normalize))
: null;
const candidates = entries.filter((entry) => {
if (!entry.classname) return false;
if (present.has(normalize(entry.classname))) return false;
if (only && !only.has(normalize(entry.classname))) return false;
return true;
});
const limit = options.limit ?? 0;
return limit > 0 ? candidates.slice(0, limit) : candidates;
}
/** Human-readable list of what the run will do. */
export function describeActions(flags: CatalogSyncFlags): string {
const actions = ["audit"];
if (flags.repairIcons) actions.push("icons");
if (flags.repairNitros) actions.push("nitros");
if (flags.repairFurniData) actions.push("furnidata");
if (flags.repairStructure) actions.push("structure");
if (flags.checkFurniDataIds) actions.push("id-check");
if (flags.importNew) actions.push(flags.dryRun ? "import (dry)" : "import");
if (flags.reconcile) actions.push("reconcile");
if (flags.fixSpriteIds) actions.push("sprite-ids");
if (flags.rebuildOffers) actions.push("offers");
if (flags.alignIds) actions.push(`align-ids${flags.apply ? "=apply" : ""}`);
if (flags.renameBundles)
actions.push(`rename${flags.apply ? "=apply" : "-dry"}-bundles`);
if (flags.applySql) actions.push("apply-sql");
else if (flags.generateSql) actions.push("generate-sql");
if (flags.organizeSql) actions.push("organised");
return actions.join(" + ");
}
/** True when the file is being executed, not imported by a test. */
function isEntryPoint(): boolean {
const entry = process.argv[1];
return (
Boolean(entry) && import.meta.url === pathToFileURL(String(entry)).href
);
}
async function loadSourceCatalogue(): Promise<SourceRef[]> {
const { listSources } = await import("@/lib/services/clone-sources");
const sources = await listSources();
// Full source objects: the import step needs the icon/nitro base URLs too.
return sources.filter((source) => source.furnidataUrl);
}
async function commandListSources(flags: CatalogSyncFlags): Promise<number> {
const sources = await loadSourceCatalogue();
if (flags.tsv) {
for (const source of sources)
console.log([source.id, source.name, source.furnidataUrl].join("\t"));
return 0;
}
console.log(`Configured clone sources (${sources.length}):`);
for (const source of sources)
console.log(` ${source.id}\t${source.name}\t${source.furnidataUrl}`);
console.log(
"\nCompare with: --sources=<id> (comma-separated; display names work too)",
);
return 0;
}
/** Persist the generated SQL so it can be reviewed or replayed by hand. */
export async function writeGeneratedSql(sql: string): Promise<string> {
await mkdir(sqlOutputDir(), { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const file = join(sqlOutputDir(), `catalog-${stamp}.sql`);
await writeFile(file, sql.endsWith("\n") ? sql : `${sql}\n`, "utf8");
return file.replace(`${process.cwd()}/`, "");
}
/**
* Run one of the repo's existing standalone maintenance scripts as a child
* process, with the same Node conditions this CLI needs.
*/
function runChildScript(
script: string,
args: string[],
log: (line: string) => void,
): number {
const nodeArgs = [
"--conditions=react-server",
"--import",
"tsx",
script,
...args,
];
log(`· running scripts/${script} ${args.join(" ")}`.trimEnd());
const result = spawnSync(process.execPath, nodeArgs, {
cwd: process.cwd(),
stdio: "inherit",
env: process.env,
});
if (result.error) {
console.error(`! ${script} failed to start: ${result.error.message}`);
return 1;
}
return result.status ?? 1;
}
/**
* Import furniture that exists at the compared source but not in this hotel.
*
* `cloneSingleFurni` downloads the bundle + icon, stores the bundle as
* `LOCAL_BUNDLE_EXT`, appends the furnidata entry and creates the catalog page
* plus the offer itself — so an import is not usable for furniture you want to
* keep out of the catalog.
*/
async function stepImportNew(
flags: CatalogSyncFlags,
sources: SourceRef[],
log: (line: string) => void,
): Promise<ImportOutcome | undefined> {
const { getClonableClassnames, fetchSourceFurnidata } = await import(
"@/lib/services/clone-import"
);
const { cloneSingleFurni } = await import("@/lib/services/clone-import");
let planned = 0;
let imported = 0;
let skipped = 0;
let failed = 0;
const failures: Array<{ classname: string; error: string }> = [];
const deferredEntries: Array<{
entry: Record<string, unknown>;
itemType: string;
}> = [];
let lastSource = "";
const skippedSources: string[] = [];
for (const source of sources) {
if (!canImportBundles(source)) {
log(
`! ${source.name} serves no .nitro bundles — it can be compared against, but nothing can be imported from it`,
);
skippedSources.push(source.name);
continue;
}
if (flags.importLimit > 0 && planned >= flags.importLimit) break;
const remaining = flags.importLimit
? flags.importLimit - planned
: Number.POSITIVE_INFINITY;
log(`· listing furniture missing at ${source.name}…`);
const clonable = new Set(await getClonableClassnames(source));
const entries = await fetchSourceFurnidata(source.furnidataUrl);
const candidates = selectImportCandidates(entries, clonable, {
only: flags.importOnly,
limit: Number.isFinite(remaining) ? remaining : 0,
});
if (candidates.length === 0) {
log(` nothing new to import from ${source.name}`);
continue;
}
lastSource = source.name;
if (flags.dryRun) {
log(
` ${candidates.length} would be imported from ${source.name}${flags.importOnly.length ? " (filtered by --only)" : ""}`,
);
for (const entry of candidates.slice(0, 20))
log(` ${entry.classname} — ${entry.name}`);
if (candidates.length > 20)
log(` … and ${candidates.length - 20} more`);
planned += candidates.length;
continue;
}
planned += candidates.length;
const started = Date.now();
let done = 0;
let index = 0;
const worker = async () => {
while (index < candidates.length) {
const entry = candidates[index++];
const result = await cloneSingleFurni({
source,
entry,
deferFurniData: true,
});
done += 1;
if (result.ok) imported += 1;
else if (result.skipped) skipped += 1;
else {
failed += 1;
failures.push({
classname: entry.classname,
error: result.error ?? "unknown error",
});
}
if (result.furniDataEntry)
deferredEntries.push(
result.furniDataEntry as {
entry: Record<string, unknown>;
itemType: string;
},
);
if (done % 10 === 0 || done === candidates.length) {
const rate = Math.max(done / ((Date.now() - started) / 1000), 1);
const etaMin = Math.round((candidates.length - done) / rate / 60);
log(
` ${source.name}: ${done}/${candidates.length} ${rate.toFixed(1)}/s, ETA ~${etaMin}m`,
);
}
}
};
const workers = Math.max(
1,
Math.min(flags.importConcurrency, candidates.length),
);
await Promise.all(Array.from({ length: workers }, worker));
}
if (!flags.dryRun && deferredEntries.length > 0) {
const { appendFurniEntriesBulk } = await import(
"@/lib/services/furni-data"
);
await appendFurniEntriesBulk(deferredEntries);
log(`· FurnitureData.json: +${deferredEntries.length} entries (one write)`);
}
return {
source: lastSource,
candidates: planned,
imported,
skipped,
failed,
failures: failures.slice(0, 25),
skippedSources,
};
}
/** Keep FurnitureData.json consistent with items_base after the import. */
async function stepReconcile(log: (line: string) => void): Promise<void> {
const { reconcileFurniDataWithItemsBase } = await import(
"@/lib/services/furni-data"
);
const result = await reconcileFurniDataWithItemsBase();
log(
`· reconcile: ${result.fixedIds} sprite id(s), ${result.fixedOfferIds} offer id(s), ${result.normalizedIds} normalised, ${result.fixedConflicts} conflict(s) resolved, ${result.missing} still missing`,
);
}
/** items_base.sprite_id must equal its id wherever furnidata carries the item. */
async function stepFixSpriteIds(log: (line: string) => void): Promise<void> {
const { verifyAndFixSpriteIds } = await import("@/lib/services/furni-data");
const result = await verifyAndFixSpriteIds();
log(`· sprite ids: ${result.checked} checked, ${result.fixed} corrected`);
}
/** offer_id mirrors the row id, never a literal -1. */
async function stepRebuildOffers(log: (line: string) => void): Promise<void> {
const { rebuildCatalogOfferIds } = await import("@/lib/services/furni-data");
const result = await rebuildCatalogOfferIds();
log(`· offer ids: ${JSON.stringify(result)}`);
}
export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
const catalogue = await loadSourceCatalogue();
// Resolve the selection up-front so a typo fails before anything is written.
const known = new Set<string>();
for (const source of catalogue) {
known.add(source.id);
known.add(source.name);
}
const unknown = flags.sourceIds.filter((id) => !known.has(id));
if (unknown.length > 0) {
console.error(
`Unknown source: ${unknown.join(", ")}\nRun --list-sources to see the configured sites.`,
);
return 1;
}
const selected = flags.sourceIds.length
? catalogue.filter(
(source) =>
flags.sourceIds.includes(source.id) ||
flags.sourceIds.includes(source.name),
)
: catalogue;
const { runCatalogAudit } = await import("@/lib/services/catalog-audit");
const report: AuditReport = {
sources: selected,
issues: [],
missingFromSources: [],
};
const quiet = flags.json;
const log = (line: string) => {
if (!quiet) console.log(line);
};
const started = Date.now();
let pendingSql: string | undefined;
log(
`Catalog sync — ${selected.length} source(s), ${describeActions(flags)} — bundles stored as ${LOCAL_BUNDLE_EXT} (FURNI_BUNDLE_EXT)` +
(flags.applySql ? " [SQL WILL BE APPLIED]" : ""),
);
// 1) Move items_base ids onto their furnidata ids before anything else, so
// every later step sees stable ids. The script only reports without
// `--apply`.
if (flags.alignIds) {
runChildScript(
"scripts/align-db-ids-with-furnidata.ts",
flags.apply ? ["--apply"] : [],
log,
);
}
// 2) Audit + repair of what is already here.
await runCatalogAudit(
(event) => {
switch (event.type) {
case "progress":
log(`· ${event.message ?? ""}`);
break;
case "items_loaded":
log(`· items_base: ${event.total} rows`);
break;
case "catalog_loaded":
log(`· catalog_items: ${event.total} rows`);
break;
case "sql_ready":
log(`· catalog SQL generated: ${event.sqlCount ?? 0} statement(s)`);
pendingSql = event.sql;
break;
case "sql_applied":
log(
`· catalog SQL applied: ${event.applied ?? 0} (failed ${event.sqlFailed ?? 0})`,
);
break;
case "furnidata_repair_complete":
log(
`· FurnitureData.json: +${event.furniData?.added ?? 0} entries, ${event.furniData?.removedDuplicates ?? 0} duplicate(s) removed`,
);
break;
case "structure_repair_complete":
log(
`· structure: ${event.structure?.orphanedRemoved ?? 0} orphaned removed, ${event.structure?.duplicatesMerged ?? 0} duplicate(s) merged, ${event.structure?.remappedReferences ?? 0} reference(s) remapped`,
);
break;
case "repair_complete":
log(
`· asset repair: ${event.repair?.succeeded ?? 0} ok, ${event.repair?.failed ?? 0} failed, ${event.repair?.skipped ?? 0} skipped`,
);
break;
case "batch_complete":
report.issues = event.issues ?? [];
report.missingFromSources = event.missingFromSources ?? [];
report.summary = event.summary;
break;
case "error":
console.error(`! ${event.message ?? "audit error"}`);
break;
default:
break;
}
},
{
sourceIds: flags.sourceIds,
repair: flags.repairIcons,
repairNitros: flags.repairNitros,
sql: flags.generateSql,
applySql: flags.applySql,
organizeSql: flags.organizeSql,
repairFurniData: flags.repairFurniData,
repairStructure: flags.repairStructure,
checkFurniDataIds: flags.checkFurniDataIds,
},
);
if (pendingSql?.trim()) report.sqlFile = await writeGeneratedSql(pendingSql);
// 3) Import what the source has and this hotel does not.
if (flags.importNew) {
if (selected.length === 0) log("· no source selected, nothing to import");
else report.imported = await stepImportNew(flags, selected, log);
}
// 4) Post-import consistency, then the legacy bundle rename.
if (flags.reconcile) await stepReconcile(log);
if (flags.fixSpriteIds) await stepFixSpriteIds(log);
if (flags.rebuildOffers) await stepRebuildOffers(log);
if (flags.renameBundles) {
runChildScript(
"scripts/migrate-nitro-to-hab.ts",
flags.apply ? ["--yes"] : ["--dry-run"],
log,
);
}
if (flags.json) {
console.log(
JSON.stringify(
{ flags, elapsedMs: Date.now() - started, ...report },
null,
2,
),
);
} else {
printSummary(report, started);
if (report.sqlFile) console.log(`Catalog SQL: ${report.sqlFile}`);
}
const errors = report.issues.filter((issue) => issue.severity === "error");
if (flags.failOnErrors && errors.length > 0) {
if (!quiet) console.error(`${errors.length} error(s) remain unresolved.`);
return 3;
}
return 0;
}
function printSummary(report: AuditReport, started: number): void {
const summary = report.summary;
const seconds = ((Date.now() - started) / 1000).toFixed(1);
console.log(`\nDone in ${seconds}s`);
if (!summary) {
console.log("No summary was produced (the audit stopped early).");
return;
}
console.log(` items_base ${summary.totalItemsBase}`);
console.log(` catalog_items ${summary.totalCatalogItems}`);
console.log(` missing catalog entry ${summary.missingCatalog}`);
console.log(` orphaned catalog rows ${summary.orphanedCatalog}`);
console.log(` duplicate classnames ${summary.duplicates}`);
console.log(
` missing icons ${summary.missingIcon} (repaired ${summary.iconsRepaired}, still missing ${summary.iconsStillMissing})`,
);
console.log(
` missing nitro bundles ${summary.missingNitro} (repaired ${summary.nitrosRepaired}, still missing ${summary.nitrosStillMissing})`,
);
console.log(
` catalog SQL ${summary.catalogSqlCount} stmt, applied ${summary.catalogSqlApplied}`,
);
console.log(` furnidata added ${summary.furniDataAdded}`);
console.log(
` not restorable ${summary.unrepairableCount} (no icon/bundle on any compared source)`,
);
if (report.imported) {
const outcome = report.imported;
console.log(
` imported from source ${outcome.imported} new (${outcome.skipped} already present, ${outcome.failed} failed)`,
);
if (outcome.skippedSources.length > 0)
console.log(
` compared only ${outcome.skippedSources.join(", ")} (no .nitro bundles to import from)`,
);
for (const failure of outcome.failures)
console.log(` ${failure.classname}: ${failure.error}`);
}
if (report.missingFromSources.length > 0) {
const bySource = new Map<string, number>();
for (const entry of report.missingFromSources)
bySource.set(entry.sourceName, (bySource.get(entry.sourceName) ?? 0) + 1);
console.log(
`\n on compared sources but not in this hotel: ${report.missingFromSources.length}`,
);
for (const [name, count] of bySource)
console.log(` ${name.padEnd(24)} ${count}`);
}
const errors = report.issues.filter((issue) => issue.severity === "error");
if (errors.length > 0) {
console.log(`\n unresolved errors: ${errors.length}`);
for (const issue of errors.slice(0, 20))
console.log(` ${issue.classname ?? issue.type}: ${issue.message}`);
if (errors.length > 20) console.log(` … and ${errors.length - 20} more`);
}
}
async function main(): Promise<number> {
const flags = parseCatalogSyncArgs(process.argv.slice(2));
try {
if (flags.listSources) return await commandListSources(flags);
return await commandRun(flags);
} catch (error) {
console.error(
`Catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
if (isEntryPoint()) {
main().then(
(code) => {
process.exit(code);
},
(error) => {
console.error(error);
process.exit(1);
},
);
}
+4 -12
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# Create/update the Cloudflare Cache Rule that stores the CMS public API
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
# stuurt).
# 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
@@ -37,18 +35,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
BASE="https://api.cloudflare.com/client/v4"
PHASE="http_request_cache_settings"
DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
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()`.
#
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
# tussen gebruikers.
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
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"
+4 -21
View File
@@ -45,28 +45,11 @@ 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")) {
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`,
assert.equal(
process.versions.node,
pinnedVersion,
"the active Node.js runtime must match .nvmrc",
);
}
-124
View File
@@ -1,124 +0,0 @@
#!/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'
+30 -219
View File
@@ -76,13 +76,6 @@ healthy() {
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.
@@ -92,156 +85,29 @@ detect_blue_green() {
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.
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
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
local live="" port="" result=""
local count=0
for port in "$slot_a_port" "$slot_b_port"; do
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
live="$live $port"
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"
for port in $live; do count=$((count + 1)); result="$port"; done
if [ "$count" -eq 1 ]; then
printf '%s' "$result"
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
;;
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
case "$port" in
"$slot_b_port") printf '%s' "$slot_b_port" ;;
*) printf '%s' "$slot_a_port" ;;
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.
@@ -287,7 +153,6 @@ switch_upstream() {
# gelden.
start_candidate() {
local port="$1" name="$2"
assert_port_free "$port" "$name"
(
set -a
# shellcheck disable=SC1091
@@ -318,7 +183,7 @@ finish() {
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
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
docker logs "$new_container" --tail 50 >&2 || true
docker rm -f "$new_container" || true
fi
@@ -326,9 +191,9 @@ finish() {
# 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 "$new_container" ]; 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 "$new_container" ]; 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"
@@ -395,30 +260,11 @@ if ! grep -qs '^DATABASE_URL=' .env; then
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
# All three engines are needed: the chromium gate below plus the cross-browser
# imaging gate just before cutover. Idempotent — already-installed engines in
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
pnpm exec playwright install chromium firefox webkit
pnpm exec playwright install chromium
echo "Building $image"
# 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 \
DOCKER_BUILDKIT=1 docker build --network=host --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.
@@ -492,28 +338,15 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
exit 1
fi
# 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.
# The avatar/badge disk cache lives on the host bind and is written by uid 33
# inside the container. Root-owned directories make every cache write fail
# silently, which turns each avatar into a fresh live render.
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
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
if [ "$blue_green" -eq 1 ]; then
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
@@ -523,40 +356,23 @@ if [ "$blue_green" -eq 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.
# 2. 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
# 3. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
# cutover de productie breken in plaats van ervoor.
healthy "$new_port"
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
# 4b. Cross-browser imaging gate. The chromium suite above proves the
# candidate serves the right bytes; this proves every bundled engine can
# actually decode the render. The webkit path is the one that historically
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
# a release out in an emergency.
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
else
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
fi
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
# 4. 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
# 5. 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"
@@ -584,11 +400,6 @@ else
healthy 3002
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
else
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
fi
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
fi
docker tag "$verified_image" "epicnext-cms:verified-$sha"
+1 -1
View File
@@ -39,6 +39,6 @@ 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 \
DOCKER_BUILDKIT=1 docker build --network=host --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
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
ENV_FILE="$DIR/.env"
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
PROJECT_NAME="epicnext-crowdsec"
CONTAINER_NAME="epicnext-crowdsec"
DEFAULT_PORT="18080"
mode="${1:-enable}"
case "$mode" in
enable|--enable) ;;
status|--status) ;;
disable|--disable) ;;
blocklists|blocklists-install-cron|blocklists-uninstall-cron) ;;
*) echo "Usage: bash cms security [enable|status|disable|blocklists|blocklists-install-cron|blocklists-uninstall-cron]" >&2; exit 1 ;;
esac
umask 077
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
for command in docker flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
docker info >/dev/null 2>&1 || fail "Docker is not reachable."
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
exec 9>"$DIR/.deploy.lock"
flock -w 30 9 || fail "Another installation or update is running."
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
case "$mode" in
blocklists) exec bash "$DIR/scripts/blocklists-sync.sh" sync "${2:-}" ;;
blocklists-install-cron) exec bash "$DIR/scripts/blocklists-sync.sh" install-cron ;;
blocklists-uninstall-cron) exec bash "$DIR/scripts/blocklists-sync.sh" uninstall-cron ;;
esac
env_get() {
local key="$1" line
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
esac
done < "$ENV_FILE"
return 1
}
env_set() {
local key="$1" value="$2" tmp
tmp="$(mktemp "$DIR/.env.crowdsec.XXXXXX")"
if awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="=";done=0} { if ($1==k) { print k "=" v; done=1 } else print } END { if (!done) print k "=" v }' "$ENV_FILE" > "$tmp"; then
chmod 600 "$tmp"
mv -f -- "$tmp" "$ENV_FILE"
else
rm -f -- "$tmp"
fail "Could not update .env"
fi
}
compose_cmd() {
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
}
health_probe() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
else
compose_cmd exec -T crowdsec wget -q -O - "$url" >/dev/null 2>&1
fi
}
container_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
}
if [[ "$mode" = disable || "$mode" = --disable ]]; then
set +e
compose_cmd stop crowdsec
rc=$?
set -e
[[ $rc -eq 0 ]] || printf 'CrowdSec engine was not running or could not be stopped.\n'
env_set CROWDSEC_LOCAL_ENABLED false
printf 'CrowdSec local stack disabled. The engine container is stopped; volumes and .env key were kept.\n'
exit 0
fi
if [[ "$mode" = status || "$mode" = --status ]]; then
enabled=no
[[ "$(env_get CROWDSEC_LOCAL_ENABLED 2>/dev/null || true)" = true ]] && enabled=yes
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
[[ -z "$port" ]] && port="$DEFAULT_PORT"
printf 'CROWDSEC_LOCAL_ENABLED=%s\n' "$enabled"
if container_running; then
printf 'Engine: running\n'
if health_probe "http://127.0.0.1:$port/health"; then
printf 'LAPI health: OK (127.0.0.1:%s)\n' "$port"
else
printf 'LAPI health: UNREACHABLE (127.0.0.1:%s)\n' "$port"
fi
else
printf 'Engine: not running\n'
printf 'Start with: bash cms security\n'
fi
exit 0
fi
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
[[ -n "$port" ]] || port="$DEFAULT_PORT"
[[ "$port" =~ ^[0-9]{1,5}$ ]] || fail "CROWDSEC_LAPI_PORT must be a port number."
if (( port < 1024 || port > 65535 )); then
fail "CROWDSEC_LAPI_PORT must be within 1024-65535."
fi
key="$(env_get CROWDSEC_LAPI_API_KEY 2>/dev/null || true)"
[[ -n "$key" ]] || key="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
url="$(env_get CROWDSEC_LAPI_URL 2>/dev/null || true)"
[[ -n "$url" ]] || url="http://127.0.0.1:$port"
log_dir="${CROWDSEC_NGINX_LOG_DIR:-$(env_get CROWDSEC_NGINX_LOG_DIR 2>/dev/null || true)}"
[[ -n "$log_dir" ]] || log_dir="/var/log/nginx"
if ! container_running && command -v ss >/dev/null 2>&1; then
if ss -ltn "( sport = :$port )" 2>/dev/null | grep -q LISTEN; then
fail "Port $port is already in use. Set CROWDSEC_LAPI_PORT (and CROWDSEC_LAPI_URL) in .env to a free port and re-run."
fi
fi
if [[ ! -r "$log_dir/access.log" ]]; then
printf 'Warning: %s/access.log is not readable. The engine will run but has no detections until an access log is available.\n' "$log_dir"
fi
env_set CROWDSEC_LOCAL_ENABLED true
env_set CROWDSEC_LAPI_URL "$url"
env_set CROWDSEC_LAPI_PORT "$port"
env_set CROWDSEC_LAPI_API_KEY "$key"
env_set CROWDSEC_NGINX_LOG_DIR "$log_dir"
compose_cmd config --quiet || fail "CrowdSec Compose configuration is invalid; fix CROWDSEC_* settings in .env."
set +e
compose_cmd up -d --wait crowdsec
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
compose_cmd up -d crowdsec
fi
attempt=0
while ! health_probe "http://127.0.0.1:$port/health"; do
attempt=$((attempt + 1))
[[ $attempt -lt 30 ]] || fail "CrowdSec LAPI did not become healthy on port $port."
sleep 2
done
printf 'CrowdSec engine running in LAPI-only mode on 127.0.0.1:%s (container %s).\n' "$port" "$CONTAINER_NAME"
compose_cmd exec -T crowdsec cscli bouncers list >/dev/null 2>&1 \
&& printf 'Bouncer "cms" was registered against the local LAPI.\n' \
|| printf 'Warning: could not list bouncers. Diagnose with: docker compose exec -T %s cscli bouncers list\n' "$CONTAINER_NAME"
printf 'Restart the CMS container (or run your next deployment) so it loads the new bouncer env. For a clone: bash cms update --skip-pull\n'
+2 -94
View File
@@ -3,21 +3,15 @@
#
# Modes:
# (default) — post-deploy cleanup (safe, fast):
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
# least-recently-used entries. This cap is the actual bound.
# - Build cache older than 72h, capped at 4 GB max used space.
# - Unreferenced images older than 7 days (keeps rollback images around).
# - Stopped containers older than 24h.
# - Dangling images, which are always unreferenced.
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
# --force — emergency mode ("never let the disk max out"): drops everything
# with no age windows:
# - ALL unreferenced build cache,
# - ALL unreferenced images (no age grace),
# - ALL stopped containers.
#
# The default mode escalates to --force on its own when / drops below 8 GB free,
# so the bound holds even if this stops running on schedule.
#
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
# Idempotent; exits 0 when Docker is unavailable.
set -Eeuo pipefail
@@ -40,101 +34,15 @@ command -v docker >/dev/null 2>&1 || {
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true
# A hard ceiling on the root filesystem is what actually bounds the growth, so
# the emergency path is reached on disk pressure rather than only on a timer.
# The image/container passes stay age-gated: a rollback image and a stopped
# container are cheap to keep for a week and expensive to lose.
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
# 8 GB free is comfortable for a database plus a release swap.
if (( FREE_KB < 8 * 1024 * 1024 )); then
FORCE=1
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
"$(now)" "$FREE_KB" >>"$LOG_FILE"
fi
if (( FORCE )); then
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
docker image prune -af >>"$LOG_FILE" 2>&1 || true
docker container prune -f >>"$LOG_FILE" 2>&1 || true
else
# --max-used-space and --filter are mutually exclusive in buildx: passing
# both makes the cap a no-op and the cache grows without bound. The cap alone
# is the bound, and it evicts least-recently-used entries to get there.
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
fi
# Dangling images have no tag and no container, so nothing can reference them.
# They are what repeated local builds leave behind.
docker image prune -f >>"$LOG_FILE" 2>&1 || true
# ── Interrupted git gc leftovers ─────────────────────────────────
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
# nothing reclaims it: git only clears those on the next successful gc. One such
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
# than a day are considered, so a gc running right now is never touched.
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
while IFS= read -r -d '' tmp; do
size=$(du -h "$tmp" | cut -f1)
rm -f "$tmp"
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
fi
# ── Orphaned browser profiles ─────────────────────────────────────
# byparr launches a real Firefox per request, and each launch leaves a
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
# days on this host, ~1.7 GB/day.
#
# Deleting a profile out from under a running browser kills that job, so live
# ones are identified the only way that is reliable rather than by age: a
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
# pointing into the directory. Anything not referenced that way, and untouched
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
#
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
# age window that is safe for the leak is far too wide for the disk.
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
for container in ${BYPARR_CONTAINERS:-byparr}; do
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
removed=$(
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
set -u
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
base="${1:-/tmp}"
live_file=$(mktemp)
# Live profiles are the ones a running process still holds open.
for p in $(ps -eo pid= 2>/dev/null); do
ls -l "/proc/$p/fd" 2>/dev/null
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
count=0
for dir in "$base"/playwright_firefoxdev_profile-*; do
[ -d "$dir" ] || continue
# Never touch something a process is still using.
grep -Fxq "$dir" "$live_file" && continue
# A profile a browser is still writing to is not an orphan
# yet, even if the directory itself looks old.
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
continue
fi
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
done
rm -f "$live_file"
printf "%s" "$count"
' sh /tmp 2>/dev/null || printf '0'
)
if [[ "${removed:-0}" -gt 0 ]]; then
printf '[%s] removed %s orphaned browser profiles from %s\n' \
"$(now)" "$removed" "$container" >>"$LOG_FILE"
fi
done
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true
+22 -88
View File
@@ -1,13 +1,7 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
detectMemoryLimitMb,
heapLimitMb,
runtimeNodeOptions,
} from "./docker-start.mjs";
import { it } from "vitest";
it("imports runtime validation without starting the CMS", () => {
const result = spawnSync(
@@ -22,88 +16,28 @@ it("imports runtime validation without starting the CMS", () => {
assert.equal(result.status, 0, result.stderr);
});
describe("heap limit", () => {
it("leaves headroom for the memory V8 does not account for", () => {
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
});
it("clamps to a floor and a ceiling", () => {
// Too small to run a Next.js server at all: floor wins.
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
// A huge or absent limit must not turn into a 100 GB heap.
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
expect(heapLimitMb(Number.NaN)).toBe(8192);
expect(heapLimitMb(0)).toBe(8192);
});
it("rejects the local CrowdSec bouncer without a key", () => {
const result = spawnSync(
process.execPath,
[
"--input-type=module",
"-e",
"import {validateRuntime} from './scripts/docker-start.mjs';try{validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true'});process.exit(1)}catch(error){if(!String(error.message).includes('CROWDSEC_LAPI_API_KEY'))throw error}",
],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr);
});
describe("cgroup detection", () => {
const asReader = (contents) => (path) => {
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
return contents[path];
};
it("reads the cgroup v2 limit", () => {
expect(
detectMemoryLimitMb(
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
),
).toBe(2867);
});
it("falls back to cgroup v1 when v2 is absent", () => {
expect(
detectMemoryLimitMb(
asReader({
"/sys/fs/cgroup/memory.max": "",
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
}),
),
).toBe(4300);
});
it("treats an unlimited cgroup as no limit at all", () => {
// cgroup v1 reports "max"; a bare sentinel means the same thing.
expect(
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
).toBe(8192);
expect(
detectMemoryLimitMb(
asReader({
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
}),
),
).toBe(8192);
});
it("falls back when neither cgroup file is readable", () => {
expect(
detectMemoryLimitMb(() => {
throw new Error("ENOENT");
}),
).toBe(8192);
});
});
describe("NODE_OPTIONS", () => {
it("adds the cap when none is set", () => {
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
expect(runtimeNodeOptions(undefined, 2867)).toBe(
"--max-old-space-size=2867",
it("accepts a complete local CrowdSec configuration", () => {
const result = spawnSync(
process.execPath,
[
"--input-type=module",
"-e",
"import {validateRuntime} from './scripts/docker-start.mjs';validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true',CROWDSEC_LAPI_API_KEY:'fixture-key',CROWDSEC_LAPI_URL:'http://127.0.0.1:18080'})",
],
{ encoding: "utf8" },
);
});
it("keeps unrelated options already present", () => {
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
"--no-warnings --max-old-space-size=2867",
);
});
it("never overrides an explicit operator choice", () => {
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
"--max-old-space-size=8192",
);
});
assert.equal(result.status, 0, result.stderr);
});
+17 -70
View File
@@ -1,70 +1,7 @@
// Fail before listening if an installation has no valid runtime configuration.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
/** Fraction of the container memory limit V8 is allowed to use for its heap.
* The rest has to cover native allocations the JS heap cannot account for:
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
* RSC rendering. */
const HEAP_FRACTION = 0.7;
const MIN_HEAP_MB = 512;
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
* for 4300 MB, and a backstop at or below that would silently turn the fraction
* into a fixed number and make the two limits disagree. This exists purely so a
* nonsensical cgroup reading cannot ask for an unbounded heap. */
const MAX_HEAP_MB = 8192;
export function heapLimitMb(cgroupLimitBytes) {
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
return MAX_HEAP_MB;
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
}
/**
* Read this container's memory ceiling from cgroup v2, falling back to v1.
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
* container on a 24 GB host lets the heap grow past the limit and the kernel
* OOM-kills the process mid-request — which is what produced the
* `next-build (v16)` kills in the host logs. A container that GCs before it
* reaches the ceiling degrades to a slower page instead of a killed process.
*/
export function detectMemoryLimitMb(readFile = readFileSync) {
const candidates = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
for (const path of candidates) {
let raw;
try {
raw = readFile(path, "utf8").trim();
} catch {
continue;
}
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
// sentinel of a very large number on some kernels.
if (raw === "max" || raw === "") continue;
const bytes = Number(raw);
if (!Number.isFinite(bytes) || bytes <= 0) continue;
// A host-sized "limit" means no cgroup ceiling was applied.
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
return heapLimitMb(bytes);
}
return heapLimitMb(Number.NaN);
}
export function runtimeNodeOptions(
existing = "",
heapMb = detectMemoryLimitMb(),
) {
const flag = `--max-old-space-size=${heapMb}`;
if (!existing.trim()) return flag;
// Respect an explicit operator override; only add the cap when absent.
if (existing.includes("--max-old-space-size")) return existing;
return `${existing} ${flag}`;
}
export function validateRuntime(settings) {
const invalid = [];
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
@@ -86,6 +23,22 @@ export function validateRuntime(settings) {
invalid.push(key);
}
}
const localEnabled = ["true", "1"].includes(
String(settings.CROWDSEC_LOCAL_ENABLED ?? "")
.trim()
.toLowerCase(),
);
if (localEnabled) {
if (!settings.CROWDSEC_LAPI_API_KEY?.trim())
invalid.push("CROWDSEC_LAPI_API_KEY");
if (settings.CROWDSEC_LAPI_URL) {
try {
new URL(settings.CROWDSEC_LAPI_URL);
} catch {
invalid.push("CROWDSEC_LAPI_URL");
}
}
}
if (invalid.length)
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
}
@@ -96,13 +49,7 @@ if (
) {
try {
validateRuntime(process.env);
const heapMb = detectMemoryLimitMb();
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
const child = spawn(process.execPath, ["server.js"], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nodeOptions },
});
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => child.kill(signal));
child.on("error", () => {
Loaded 100 of 528 files, more files were not shown because too many files have changed in this diff. Show more