Compare commits
90
Commits
ac23d7d1d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
836defd0c1 | ||
|
|
9560ddc212 | ||
|
|
c0a5079ede | ||
|
|
a8172ce88e | ||
|
|
9416317c5b | ||
|
|
e17576856f | ||
|
|
201296e837 | ||
|
|
2ae8127476 | ||
|
|
95bc7da83e | ||
|
|
9fd675a6a0 | ||
|
|
5080ca586d | ||
|
|
76493a9df8 | ||
|
|
9cff32a04b | ||
|
|
79b0b8894d | ||
|
|
6b9ed8d15e | ||
|
|
6c89a7ed46 | ||
|
|
fbf98cd2b5 | ||
|
|
cf448d6ce9 | ||
|
|
4768b2b406 | ||
|
|
af67a28639 | ||
|
|
a4bc25b6d6 | ||
|
|
fb724a35cc | ||
|
|
a878d831aa | ||
|
|
1ad4f9ea28 | ||
|
|
4f6b352691 | ||
|
|
42fa988d9c | ||
|
|
be4dc08c31 | ||
|
|
6ef92f57fc | ||
|
|
a16e9da018 | ||
|
|
3cb5baabfb | ||
|
|
e96c408ac1 | ||
|
|
e4564abc5e | ||
|
|
30583b8e2e | ||
|
|
97454f361b | ||
|
|
ecd8e5852c | ||
|
|
8ca538f99f | ||
|
|
1e81cac504 | ||
|
|
3bc218ba16 | ||
|
|
61f4db2a00 | ||
|
|
597287fce5 | ||
|
|
5c428c4526 | ||
|
|
e7a6b2b602 | ||
|
|
4b25292ecc | ||
|
|
b40293bd06 | ||
|
|
9cf1eb615c | ||
|
|
aef0939ee9 | ||
|
|
c6e5e0b1a9 | ||
|
|
2249303dbd | ||
|
|
07c5904879 | ||
|
|
4776a41c01 | ||
|
|
a32cf6119b | ||
|
|
bb3799f705 | ||
|
|
d28dde459a | ||
|
|
095353012a | ||
|
|
ef0c425598 | ||
|
|
8d3263a8fe | ||
|
|
050fcdf25b | ||
|
|
a40669b130 | ||
|
|
c15177a52e | ||
|
|
fb470123c3 | ||
|
|
1dd584cd82 | ||
|
|
c9b363d60d | ||
|
|
e870beb71c | ||
|
|
728999b2de | ||
|
|
8d8dab5817 | ||
|
|
59a45d2165 | ||
|
|
6895eaab49 | ||
|
|
8557370d91 | ||
|
|
16a902bf14 | ||
|
|
dc4cca653b | ||
|
|
9894240f06 | ||
|
|
43590f1509 | ||
|
|
b58f3215f7 | ||
|
|
3a296551f6 | ||
|
|
e70b96e3a7 | ||
|
|
480343d5a5 | ||
|
|
77a0d1010c | ||
|
|
f56b345598 | ||
|
|
a4bcd40e7b | ||
|
|
4b575d3a1d | ||
|
|
61107848f8 | ||
|
|
1e3406a7ae | ||
|
|
ef6c671903 | ||
|
|
ef2f8c30f0 | ||
|
|
73005a3c05 | ||
|
|
918134df69 | ||
|
|
d64d864175 | ||
|
|
62de3e3a8b | ||
|
|
9f024dc6ac | ||
|
|
05c2706eaf |
No files matched your search
@@ -26,6 +26,25 @@ bun.lockb
|
||||
public/nitro-assets
|
||||
public/swf
|
||||
|
||||
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
|
||||
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
|
||||
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
|
||||
# testcontainers en drizzle-kit afhankelijk maken.
|
||||
e2e/
|
||||
integration/
|
||||
|
||||
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
||||
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
||||
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
||||
backups/
|
||||
|
||||
# Wordt tijdens de build opnieuw gegenereerd door `pnpm assets:editor`
|
||||
# (bron: node_modules/tinymce) en staat niet in git.
|
||||
public/vendor/
|
||||
|
||||
# Lokale buildcache voor `docker compose build` (cache_from/cache_to).
|
||||
.docker-cache/
|
||||
|
||||
.deploy.lock
|
||||
logs
|
||||
|
||||
|
||||
+39
-2
@@ -3,8 +3,14 @@
|
||||
# ==============================================================================
|
||||
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
|
||||
# `connection_limit` in the URL is only honoured by tools that read the URL
|
||||
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||
# DATABASE_URL supports both MySQL/MariaDB ("mysql://...") and PostgreSQL ("postgres://...").
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=25
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
@@ -18,6 +24,21 @@ 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
|
||||
@@ -44,6 +65,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
# Bcrypt cost factor for new password hashes.
|
||||
BCRYPT_COST=12
|
||||
# Password-length policy for every new password (register, password reset and
|
||||
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
|
||||
PASSWORD_MIN_LENGTH=12
|
||||
PASSWORD_MAX_LENGTH=128
|
||||
|
||||
# --- ANTI-DDOS (app-layer gate, production only) ---
|
||||
# On by default in production. Set to "false" to disable (not recommended).
|
||||
@@ -78,6 +103,18 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
||||
# Override for tests/staging (production uses the public endpoint by default).
|
||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||
|
||||
# --- CAPTCHA (env fallback; admin settings win when set) ---
|
||||
# Enable a captcha on register/login/forgot/resend without touching the DB.
|
||||
# "none" (default), "turnstile", "recaptcha" or "hcaptcha". Example:
|
||||
# CAPTCHA_PROVIDER=turnstile
|
||||
CAPTCHA_PROVIDER=
|
||||
# Provider keys. The SITE key is public (it ships to every browser), the
|
||||
# SECRET is server-only and must never be committed — set it on the host or as
|
||||
# a masked CI/CD variable. Cloudflare Turnstile keys:
|
||||
# https://dash.cloudflare.com/turnstile
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
|
||||
+105
-4
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
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}"
|
||||
@@ -57,6 +57,9 @@ blob-report/
|
||||
build-reports/
|
||||
!/docs/performance-budgets.md
|
||||
|
||||
# Local buildcache (docker compose cache_from/cache_to)
|
||||
.docker-cache/
|
||||
|
||||
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
||||
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
||||
backups/
|
||||
+9
-9
@@ -26,15 +26,15 @@ This source change alone does not configure or deploy the production service.
|
||||
|
||||
## Exported content
|
||||
|
||||
| Source | Repository destination |
|
||||
| --- | --- |
|
||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||
| Configured furni icons | `Gamedata/icons` |
|
||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
||||
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
||||
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
||||
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
||||
| Source | Repository destination |
|
||||
| ------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||
| Configured furni icons | `Gamedata/icons` |
|
||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
||||
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
||||
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
||||
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
||||
|
||||
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
|
||||
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
|
||||
|
||||
+29
-7
@@ -1,23 +1,45 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
FROM node:26.12.1-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||
# Installeer git, bash en corepack. bash is nodig voor
|
||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||
# Alpine levert geen bash mee.
|
||||
#
|
||||
# pnpm komt niet meer uit een losgepind npm-pakket, maar uit corepack: die
|
||||
# leest de versie én de sha512-hash uit de `packageManager`-pin van
|
||||
# package.json. Daarmee kan de image nooit uit sync raken met het lockfile
|
||||
# (vroeger stond hier [email protected] terwijl package.json 12.11.2 voorschreef)
|
||||
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g [email protected]
|
||||
--mount=type=cache,target=/root/.npm \
|
||||
apk add --no-cache git bash util-linux \
|
||||
&& npm install -g [email protected] \
|
||||
&& corepack enable pnpm
|
||||
|
||||
# corepack bewaart de gedownloade pnpm buiten de cache-mount, zodat de runtime
|
||||
# (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm ook
|
||||
# zonder netwerk kan gebruiken.
|
||||
ENV COREPACK_HOME="/usr/local/share/corepack"
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# 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"
|
||||
|
||||
# Alleen package.json: de `packageManager`-pin bepaalt welke pnpm corepack
|
||||
# installeert. Deze laag blijft gecachet wanneer alleen de lockfile verandert.
|
||||
COPY package.json ./
|
||||
RUN corepack install
|
||||
|
||||
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 \
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount. De mount moet op
|
||||
# de store staan die pnpm ook echt gebruikt ($PNPM_HOME/store/v11); met het
|
||||
# oude pad (/root/.local/share/pnpm/store) cache'te de mount niets en
|
||||
# downloadde elke build alle packages opnieuw.
|
||||
RUN --mount=type=cache,target=/usr/local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
@@ -52,7 +74,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
||||
pnpm run build \
|
||||
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||
|
||||
FROM node:26.10.0-alpine AS runner
|
||||
FROM node:26.12.1-alpine AS runner
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
WORKDIR /app
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noComponentHookFactories": "off",
|
||||
"usePlaywrightValidDescribeCallback": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,4 +6,10 @@ services:
|
||||
HOSTNAME: 127.0.0.1
|
||||
PORT: "3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
@@ -253,6 +253,36 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Oude, nog gecachte Nitro-configs vroegen SoundData/layout/sounds op paden
|
||||
# die niet bestaan. Serveer de bronbestanden daar ook, zodat een verouderde
|
||||
# browsercache geen 404's meer veroorzaakt.
|
||||
location = /nitro-client/nitro-assets/gamedata/SoundData.json {
|
||||
alias /var/www/Gamedata/config/SoundData.json;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
access_log off;
|
||||
}
|
||||
location = /nitro-client/configuration/soundboard-layout.jsonc {
|
||||
alias /var/www/Gamedata/config/soundboard-layout.jsonc;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
access_log off;
|
||||
}
|
||||
location = /nitro-client/configuration/soundboard-sounds.jsonc {
|
||||
alias /var/www/Gamedata/config/soundboard-sounds.jsonc;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Clientconfiguratie (renderer-config, ui-config, SoundData-URL's) moet niet
|
||||
# 30 dagen in de browser blijven hangen: een gewijzigde gamedata-URL zou
|
||||
# anders pas na cache-expiry effect hebben.
|
||||
location ^~ /nitro-client/configuration/ {
|
||||
alias /var/www/Octane/dist/configuration/;
|
||||
try_files $uri =404;
|
||||
limit_req zone=cms_static_per_ip burst=1000 nodelay;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header Cache-Tag "cms-client";
|
||||
}
|
||||
|
||||
location ^~ /nitro-client/ {
|
||||
alias /var/www/Octane/dist/;
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
@@ -39,6 +39,13 @@ http {
|
||||
server_tokens off;
|
||||
keepalive_timeout 30s;
|
||||
|
||||
# Gamedata/icons are served from disk by alias locations. Caching the
|
||||
# stat/open results avoids a syscall storm on every room load.
|
||||
open_file_cache max=10000 inactive=30s;
|
||||
open_file_cache_valid 60s;
|
||||
open_file_cache_min_uses 2;
|
||||
open_file_cache_errors off;
|
||||
|
||||
client_max_body_size 64m;
|
||||
client_body_buffer_size 16k;
|
||||
client_header_buffer_size 1k;
|
||||
|
||||
+47
-18
@@ -1,5 +1,20 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
# Next.js CMS — single slot on port 3002
|
||||
#
|
||||
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
|
||||
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
|
||||
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
|
||||
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
|
||||
# migrations en zet nginx pas om na health + e2e + imaging.
|
||||
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
|
||||
#
|
||||
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
|
||||
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
|
||||
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
|
||||
# van de site drukken. De service start alleen als je hem expliciet noemt
|
||||
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
|
||||
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
|
||||
# je dat dus juist NOOIT doen.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -14,8 +29,20 @@ x-cms: &cms
|
||||
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.
|
||||
# Lokale buildcache op schijf. `scripts/docker-prune.sh` brengt de
|
||||
# daemon-cache na elke deploy terug tot 4GB, waardoor de lagen van de
|
||||
# vorige release verdwijnen. Deze export overleeft die prune: een rebuild
|
||||
# na een lockfile-wijziging hergebruikt dan de al gedownloade packages
|
||||
# (pnpm store) in plaats van ze opnieuw te downloaden.
|
||||
# De map ligt binnen de buildcontext, zodat buildx een ontbrekende
|
||||
# cachemap stilzwijgend negeert i.p.v. de build te laten falen; zowel
|
||||
# .dockerignore als .gitignore sluiten de map uit.
|
||||
cache_from:
|
||||
- type=local,src=.docker-cache/cms
|
||||
cache_to:
|
||||
- type=local,dest=.docker-cache/cms,mode=max
|
||||
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
|
||||
# on the single port that is actually served (3002).
|
||||
network_mode: host
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
@@ -28,13 +55,17 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
|
||||
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))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -43,21 +74,19 @@ x-cms: &cms
|
||||
services:
|
||||
cms:
|
||||
<<: *cms
|
||||
# Alleen starten als je deze host bewust compose-only beheert.
|
||||
profiles: ["cms"]
|
||||
container_name: epicnext-cms
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
profiles: ["green"]
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
image: ghcr.io/thephaseless/byparr:v3.0.4
|
||||
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
|
||||
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
|
||||
# versie die ooit werd binnengehaald.
|
||||
pull_policy: always
|
||||
container_name: byparr
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
@@ -69,4 +98,4 @@ services:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
start_period: 30s
|
||||
+14
-15
@@ -2,20 +2,20 @@
|
||||
|
||||
## Operator changes
|
||||
|
||||
| Area | Behavior and location |
|
||||
| --- | --- |
|
||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||
| Area | Behavior and location |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||
|
||||
## Deployment requirements
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
## Verification
|
||||
|
||||
Local verification on 2026-09-09:
|
||||
|
||||
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||
|
||||
@@ -56,9 +56,18 @@ 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 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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
|
||||
|
||||
### Local verification and deployment boundary
|
||||
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
|
||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||
|
||||
|
||||
+15
-15
@@ -1,4 +1,4 @@
|
||||
# Informational route JavaScript budgets
|
||||
# Route JavaScript budgets (enforced)
|
||||
|
||||
Run after the existing production build; no second build or server is needed:
|
||||
|
||||
@@ -14,12 +14,11 @@ For each configured App Router route, resolve its exact app path using `app-path
|
||||
|
||||
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 manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||
|
||||
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||
|
||||
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||
|
||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||
@@ -28,20 +27,21 @@ The definition follows the data exposed by the installed Next 16.3.8 build and t
|
||||
|
||||
## Initial limits
|
||||
|
||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
|
||||
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.
|
||||
|
||||
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
|
||||
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.
|
||||
|
||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `/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 |
|
||||
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).
|
||||
|
||||
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.
|
||||
| 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 |
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
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`.
|
||||
+6
-2
@@ -6,12 +6,16 @@ import { defineConfig } from "drizzle-kit";
|
||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
||||
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
||||
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
||||
const dbUrl = process.env.DATABASE_URL ?? "";
|
||||
const isPostgres =
|
||||
dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://");
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
dialect: isPostgres ? "postgresql" : "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/drafts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
url: dbUrl,
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
|
||||
@@ -1 +1 @@
|
||||
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
|
||||
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectNoApplicationError } from "./helpers";
|
||||
|
||||
// Unauthenticated requests to private routes must bounce to the login form —
|
||||
// never render private content and never 500. Two guard paths exist: the edge
|
||||
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
|
||||
// member pages and the mod console redirect from their layouts with a bare
|
||||
// `/login`.
|
||||
|
||||
// Routes where the proxy preserves the requested destination.
|
||||
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
|
||||
// Routes guarded by a page/layout `redirect("/login")` with no destination.
|
||||
const LAYOUT_ROUTES = [
|
||||
"/settings",
|
||||
"/me",
|
||||
"/messages",
|
||||
"/notifications",
|
||||
"/friends",
|
||||
"/redeem",
|
||||
"/mod",
|
||||
"/client",
|
||||
] as const;
|
||||
|
||||
for (const route of PROXY_ROUTES) {
|
||||
test(`${route} redirects to login and keeps the return path`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto(route);
|
||||
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||
await expectNoApplicationError(page);
|
||||
|
||||
const from = new URL(page.url()).searchParams.get("from");
|
||||
expect(from, "login lost the return destination").toBeTruthy();
|
||||
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of LAYOUT_ROUTES) {
|
||||
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
|
||||
const response = await page.goto(route);
|
||||
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||
await expectNoApplicationError(page);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Shared assertions for the read-only public e2e suite. These specs run against
|
||||
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
|
||||
* guard the "everything loads, nothing is broken" contract that no unit test
|
||||
* can see.
|
||||
*/
|
||||
|
||||
/** Fails if Next rendered a runtime/server error boundary into the page. */
|
||||
export async function expectNoApplicationError(page: Page) {
|
||||
await expect(page.locator("body")).not.toContainText("Application error");
|
||||
await expect(page.locator("body")).not.toContainText("Internal Server Error");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every <img> that finished loading must have decoded pixels. A failed request
|
||||
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
|
||||
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
|
||||
* that are still pending (`complete === false`) are ignored.
|
||||
*/
|
||||
export async function expectNoBrokenImages(page: Page) {
|
||||
const broken = await page.evaluate(() => {
|
||||
return Array.from(document.images)
|
||||
.filter((img) => {
|
||||
const src = img.currentSrc || img.src;
|
||||
if (!src || src.startsWith("data:")) return false;
|
||||
return img.complete && img.naturalWidth === 0;
|
||||
})
|
||||
.map((img) => img.currentSrc || img.src);
|
||||
});
|
||||
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
|
||||
}
|
||||
|
||||
/** No horizontal scrollbar — the classic mobile layout regression. */
|
||||
export async function expectNoHorizontalOverflow(page: Page) {
|
||||
const overflowing = await page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
return doc.scrollWidth > window.innerWidth + 1;
|
||||
});
|
||||
expect(overflowing, "page scrolls horizontally").toBe(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
|
||||
* chance to start before the broken-image check runs.
|
||||
*/
|
||||
export async function scrollThroughPage(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const step = window.innerHeight;
|
||||
for (let y = 0; y < document.body.scrollHeight; y += step) {
|
||||
window.scrollTo(0, y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Skips a spec when the CMS answered with a maintenance redirect. */
|
||||
export function isMaintenanceRedirect(url: string) {
|
||||
return /\/maintenance(?:$|[/?])/.test(url);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,5 @@
|
||||
import type { APIRequestContext, APIResponse } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Where the imaging specs are pointed.
|
||||
*
|
||||
@@ -12,6 +14,56 @@
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
avatarPath,
|
||||
getThroughEdge,
|
||||
IMAGER_PATH,
|
||||
IS_EDGE,
|
||||
looksLikeImage,
|
||||
@@ -17,7 +18,10 @@ test.skip(
|
||||
);
|
||||
|
||||
test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
|
||||
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);
|
||||
@@ -26,14 +30,17 @@ test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||
test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
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 request.get("/api/imaging/badge?code=ADM");
|
||||
const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Caching contract for the anonymous pages. This is deterministic and stays in
|
||||
// the blocking deploy gate: the perf work relies on the edge/shared cache
|
||||
// (`src/proxy.ts`) — public content pages advertise a short `s-maxage`, while
|
||||
// session-dependent pages (login, register, settings) must stay
|
||||
// `private, no-store`. Timing budgets are advisory and live in
|
||||
// `e2e/quality/performance.spec.ts`.
|
||||
test("anonymous content pages advertise a shared cache", async ({
|
||||
request,
|
||||
}) => {
|
||||
for (const route of ["/news", "/staff", "/shop"]) {
|
||||
const response = await request.get(route);
|
||||
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
|
||||
}
|
||||
});
|
||||
|
||||
test("session-dependent pages stay private and uncached", async ({
|
||||
request,
|
||||
}) => {
|
||||
for (const route of ["/login", "/register"]) {
|
||||
const response = await request.get(route);
|
||||
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
|
||||
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
|
||||
"s-maxage",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expectNoApplicationError,
|
||||
expectNoBrokenImages,
|
||||
expectNoHorizontalOverflow,
|
||||
isMaintenanceRedirect,
|
||||
scrollThroughPage,
|
||||
} from "../helpers";
|
||||
|
||||
// ADVISORY suite — run with `pnpm test:e2e:quality`, not part of the deploy
|
||||
// gate. These checks depend on live content/upload state and user data (a
|
||||
// missing upload, a legacy malformed avatar figure, a bad theme asset), so
|
||||
// they are informational: they tell you the site has a real content problem,
|
||||
// but they must never block a code deploy. Every assertion the CMS can
|
||||
// guarantee on its own lives in the blocking specs instead.
|
||||
const PUBLIC_ROUTES = [
|
||||
"/",
|
||||
"/news",
|
||||
"/staff",
|
||||
"/shop",
|
||||
"/search",
|
||||
"/leaderboard",
|
||||
"/help",
|
||||
"/radio",
|
||||
] as const;
|
||||
|
||||
for (const route of PUBLIC_ROUTES) {
|
||||
test(`${route} loads every image and does not overflow`, async ({ page }) => {
|
||||
const response = await page.goto(route);
|
||||
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expectNoApplicationError(page);
|
||||
|
||||
await scrollThroughPage(page);
|
||||
await expectNoBrokenImages(page);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("the register page loads its theme/avatar images", async ({ page }) => {
|
||||
const response = await page.goto("/register");
|
||||
test.skip(
|
||||
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||
"register is not reachable on this deploy",
|
||||
);
|
||||
await scrollThroughPage(page);
|
||||
await expectNoBrokenImages(page);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const response = await page.goto("/");
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expectNoApplicationError(page);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ADVISORY suite — run with `pnpm test:e2e:quality`. Warm-page latency depends
|
||||
// on the host, the edge and the shared data caches, so a slow CI hop can trip
|
||||
// these budgets without anything being wrong. The cache-control *contract*
|
||||
// (deterministic) lives in the blocking `e2e/performance.spec.ts`; only the
|
||||
// timing budgets are advisory.
|
||||
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
|
||||
|
||||
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
|
||||
|
||||
for (const route of WARM_ROUTES) {
|
||||
test(`${route} responds within the warm budget`, async ({ request }) => {
|
||||
await request.get(route); // warm the render + data caches
|
||||
const started = Date.now();
|
||||
const response = await request.get(route);
|
||||
const elapsed = Date.now() - started;
|
||||
expect([200, 304]).toContain(response.status());
|
||||
expect(
|
||||
elapsed,
|
||||
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
|
||||
).toBeLessThan(BUDGET_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectNoApplicationError, isMaintenanceRedirect } from "./helpers";
|
||||
|
||||
// The register form is the gate for every new player, so it gets a deeper
|
||||
// read-only pass than the generic public-page spec. It also pins the captcha
|
||||
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
|
||||
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
|
||||
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
|
||||
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
|
||||
|
||||
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
|
||||
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
|
||||
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
|
||||
|
||||
test.describe("register", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const response = await page.goto("/register");
|
||||
test.skip(
|
||||
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||
"register is not reachable on this deploy",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders every field a new player has to fill in", async ({ page }) => {
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="mail"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"]').first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator('input[name="password_confirmation"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="terms"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||
await expectNoApplicationError(page);
|
||||
});
|
||||
|
||||
test("exposes exactly one captcha widget with a site key", async ({
|
||||
page,
|
||||
}) => {
|
||||
const widgets = page.locator(CAPTCHA_SELECTOR);
|
||||
|
||||
if (EXPECT === "none") {
|
||||
await expect(widgets).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (EXPECT === "turnstile") {
|
||||
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
|
||||
}
|
||||
|
||||
const count = await widgets.count();
|
||||
expect(
|
||||
count,
|
||||
`expected at most one captcha provider, found ${count}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
|
||||
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
|
||||
expect(siteKey?.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
|
||||
const script = page.locator(
|
||||
'script[src*="challenges.cloudflare.com/turnstile"]',
|
||||
);
|
||||
await expect(script).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -116,9 +116,14 @@ 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.install();
|
||||
await page.clock.pauseAt(new Date());
|
||||
await page.clock.pauseAt(new Date("2030-01-01T12:01:00Z"));
|
||||
await page.locator('input[name="title"]').fill("Immediate final edit");
|
||||
await page
|
||||
.getByRole("button", { name: "Close editor fixture", exact: true })
|
||||
|
||||
+148
-50
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
// 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;
|
||||
+78
-30
File diff suppressed because it is too large.
Load diff
+24
-1
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
||||
// Barrel-heavy packages: without this every import pulls the whole
|
||||
// module graph into the chunk that first touches it.
|
||||
optimizePackageImports: [
|
||||
"lucide-react",
|
||||
"motion",
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
"@tanstack/react-virtual",
|
||||
],
|
||||
useTypeScriptCli: true,
|
||||
hideLogsAfterAbort: true,
|
||||
},
|
||||
@@ -167,6 +175,21 @@ 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 [
|
||||
{
|
||||
|
||||
+33
-26
File diff suppressed because it is too large.
Load diff
+26
-9
@@ -1,22 +1,39 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Deploy gate: keep this suite deterministic and light. One worker, one browser
|
||||
// engine, no live dev server and no parallelism — that is what keeps the host
|
||||
// from pinning at 100% CPU while the specs run.
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: ["**/ui/**", "**/news-real/**"],
|
||||
testIgnore: ["**/ui/**", "**/imaging/**", "**/news-real/**", "**/quality/**"],
|
||||
forbidOnly: !!process.env.CI,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 30000,
|
||||
expect: { timeout: 10000 },
|
||||
globalTimeout: 10 * 60 * 1000,
|
||||
maxFailures: process.env.CI ? 10 : undefined,
|
||||
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",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--disable-default-apps",
|
||||
"--disable-extensions",
|
||||
"--disable-sync",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
],
|
||||
},
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Advisory quality suite. Unlike the main e2e config (the blocking deploy
|
||||
// gate), this checks things that depend on live content and data — broken
|
||||
// uploads, legacy malformed avatar figures, warm-page latency. Those are real
|
||||
// signals but they are not something a code change can guarantee, so they must
|
||||
// never block a deploy. Point it at a running CMS with PLAYWRIGHT_BASE_URL
|
||||
// (defaults to the local host port).
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/quality",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
timeout: 60000,
|
||||
expect: { timeout: 15000 },
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: "playwright-report/quality", open: "never" }],
|
||||
],
|
||||
outputDir: "test-results/quality",
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
+31
-7
@@ -1,8 +1,24 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw Error("Invalid UI_TEST_PORT");
|
||||
// The fixture server binds a loopback port. The CI runner packs several jobs
|
||||
// onto the shared host network (capacity > 1), so a fixed port makes concurrent
|
||||
// runs fight over the bind: one wins, the other's browser then gets
|
||||
// "connection refused". Pick one random high port for the whole run and pass it
|
||||
// to every worker through the environment, unless a port is pinned explicitly
|
||||
// (e.g. a developer reusing a manually started fixture server).
|
||||
function fixturePort() {
|
||||
const requested = Number(process.env.UI_TEST_PORT);
|
||||
if (Number.isInteger(requested) && requested >= 1024 && requested <= 65535)
|
||||
return requested;
|
||||
const shared = Number(process.env.PW_UI_TEST_PORT);
|
||||
if (Number.isInteger(shared) && shared >= 1024 && shared <= 65535)
|
||||
return shared;
|
||||
const port = randomInt(30000, 50000);
|
||||
process.env.PW_UI_TEST_PORT = String(port);
|
||||
return port;
|
||||
}
|
||||
const port = fixturePort();
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/ui",
|
||||
@@ -39,11 +55,11 @@ export default defineConfig({
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "desktop",
|
||||
name: "desktop-chromium",
|
||||
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: "mobile",
|
||||
name: "mobile-chromium",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
viewport: { width: 390, height: 844 },
|
||||
@@ -52,12 +68,20 @@ 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: 60000,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { UI_TEST_PORT: String(port) },
|
||||
},
|
||||
});
|
||||
Generated
+4954
-2736
File diff suppressed because it is too large.
Load diff
+8
-8
@@ -1,9 +1,9 @@
|
||||
packages:
|
||||
- '.'
|
||||
- "."
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@swc/core': true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
msw: true
|
||||
@@ -13,12 +13,12 @@ allowBuilds:
|
||||
minimumReleaseAge: 60
|
||||
|
||||
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'
|
||||
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'
|
||||
postcss-selector-parser: "^7.1.6"
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$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 blijft handmatig: Docker (corepack) en de runner volgen de pin automatisch, maar een bump raakt ook de lockfile en de README-versietabel (scripts/sync-readme-versions.mjs)",
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+102
-20
File diff suppressed because it is too large.
Load diff
Executable
+210
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+19
-6
@@ -42,13 +42,10 @@ old_container=""
|
||||
new_container=""
|
||||
blue_green=0
|
||||
|
||||
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
|
||||
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
|
||||
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
|
||||
# CPU en pids zijn bewust verwijderd; memory blijft gelimiteerd op 4g/5g swap
|
||||
# om een geheugenlek niet de host te laten vullen (MariaDB, nginx, Traefik).
|
||||
mem_limit="4g"
|
||||
mem_swap_limit="5g"
|
||||
cpu_limit="2"
|
||||
pids_limit="512"
|
||||
|
||||
is_current() {
|
||||
local head
|
||||
@@ -300,7 +297,6 @@ start_candidate() {
|
||||
done < "$deploy_dir/.env"
|
||||
docker run -d --name "$name" --restart always --net=host \
|
||||
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
||||
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
|
||||
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||
@@ -416,9 +412,26 @@ pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install chromium firefox webkit
|
||||
|
||||
echo "Building $image"
|
||||
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
|
||||
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
|
||||
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
|
||||
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
|
||||
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
|
||||
build_cache_flags=()
|
||||
if [ -n "$build_cache_dir" ]; then
|
||||
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||
build_cache_flags=(
|
||||
--cache-from "type=local,src=$build_cache_dir"
|
||||
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||
)
|
||||
else
|
||||
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||
fi
|
||||
fi
|
||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
check_current
|
||||
# Read reports from the already-built image; do not start an extra application.
|
||||
|
||||
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
|
||||
export NEWS_E2E_IMAGE="$image"
|
||||
export NEWS_E2E_RELEASE="$sha"
|
||||
build_attempted=1
|
||||
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
|
||||
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
|
||||
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
|
||||
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
|
||||
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
|
||||
build_cache_flags=()
|
||||
if [ -n "$build_cache_dir" ]; then
|
||||
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||
build_cache_flags=(
|
||||
--cache-from "type=local,src=$build_cache_dir"
|
||||
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||
)
|
||||
else
|
||||
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||
fi
|
||||
fi
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||
@@ -126,7 +126,9 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
||||
docker tag "$remote_migration_image" "$migration_image"
|
||||
else
|
||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" \
|
||||
--cache-from type=local,src=.docker-cache/cms \
|
||||
-t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||
fi
|
||||
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||
|
||||
+15
-4
@@ -78,13 +78,24 @@ else
|
||||
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
||||
# sites-enabled/cms.conf must be a symlink, not a copy. A regular file here
|
||||
# silently shadows sites-available/cms.conf, so nginx keeps serving stale
|
||||
# config even though the sync updated sites-available.
|
||||
enabled="$NGINX_DIR/sites-enabled/cms.conf"
|
||||
if [[ -L "$enabled" && "$(readlink "$enabled")" == "../sites-available/cms.conf" ]]; then
|
||||
echo "= $enabled symlink up to date"
|
||||
else
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
echo "- sites-enabled/cms.conf missing"
|
||||
echo "- $enabled is not the expected symlink"
|
||||
changed=1
|
||||
else
|
||||
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
||||
echo "+ linked sites-enabled/cms.conf"
|
||||
mkdir -p "$NGINX_DIR/sites-enabled"
|
||||
if [[ -e "$enabled" || -L "$enabled" ]]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -a "$enabled" "$BACKUP_DIR/cms-enabled.conf"
|
||||
fi
|
||||
ln -sfn ../sites-available/cms.conf "$enabled"
|
||||
echo "+ linked $enabled"
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mode": "informational",
|
||||
"mode": "enforce",
|
||||
"routes": [
|
||||
{
|
||||
"route": "/",
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/me",
|
||||
"maxInitialRawBytes": 890880,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/news",
|
||||
"maxInitialRawBytes": 880640,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/events",
|
||||
"maxInitialRawBytes": 890880,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/search",
|
||||
"maxInitialRawBytes": 890880,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/admin/catalog",
|
||||
"maxInitialRawBytes": 1904640,
|
||||
"maxInitialGzipBytes": 573440
|
||||
"maxInitialRawBytes": 1208320,
|
||||
"maxInitialGzipBytes": 389120
|
||||
},
|
||||
{
|
||||
"route": "/admin/studio/furni",
|
||||
"maxInitialRawBytes": 1433600,
|
||||
"maxInitialGzipBytes": 450560
|
||||
"maxInitialRawBytes": 1607680,
|
||||
"maxInitialGzipBytes": 522240
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,12 +7,12 @@ const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"];
|
||||
export function validateBudgets(config) {
|
||||
if (
|
||||
config?.schemaVersion !== 1 ||
|
||||
config.mode !== "informational" ||
|
||||
(config.mode !== "informational" && config.mode !== "enforce") ||
|
||||
!Array.isArray(config.routes) ||
|
||||
config.routes.length === 0
|
||||
)
|
||||
throw new Error(
|
||||
"Budget config requires schemaVersion 1, mode informational and nonempty routes.",
|
||||
"Budget config requires schemaVersion 1, mode informational or enforce, and nonempty routes.",
|
||||
);
|
||||
if (
|
||||
Object.keys(config).some(
|
||||
@@ -288,7 +288,7 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
mode: "informational",
|
||||
mode: config.mode,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildId,
|
||||
commitSha: metadata.commitSha ?? null,
|
||||
@@ -308,6 +308,10 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
}
|
||||
|
||||
export function markdownReport(report) {
|
||||
const modeLine =
|
||||
report.mode === "enforce"
|
||||
? `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: enforce; an over-budget or unavailable route fails the command and the image build.`
|
||||
: `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`;
|
||||
const lines = [
|
||||
"# Route JavaScript size report",
|
||||
"",
|
||||
@@ -315,7 +319,7 @@ export function markdownReport(report) {
|
||||
"",
|
||||
report.gzip,
|
||||
"",
|
||||
`Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`,
|
||||
modeLine,
|
||||
"",
|
||||
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
||||
@@ -370,6 +374,17 @@ export function main(argv = process.argv.slice(2)) {
|
||||
console.warn(
|
||||
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
|
||||
);
|
||||
if (report.mode === "enforce") {
|
||||
const failed = report.routes.filter((row) => row.status !== "measured");
|
||||
if (failed.length > 0) {
|
||||
console.error(
|
||||
`Performance budgets enforced: ${failed
|
||||
.map((row) => `${row.route} (${row.status})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -373,7 +373,10 @@ describe("route JS measurement", () => {
|
||||
expect(() =>
|
||||
validateBudgets({ ...config, routes: [budget, budget] }),
|
||||
).toThrow();
|
||||
expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow();
|
||||
expect(validateBudgets({ ...config, mode: "enforce" }).mode).toBe(
|
||||
"enforce",
|
||||
);
|
||||
expect(() => validateBudgets({ ...config, mode: "strict" })).toThrow();
|
||||
expect(() => validateBudgets({ ...config, typo: true })).toThrow();
|
||||
});
|
||||
it("emits warnings and artifacts with exit zero when a build is unavailable", () => {
|
||||
@@ -423,4 +426,51 @@ describe("route JS measurement", () => {
|
||||
expect(markdown).toContain("not measured browser traffic");
|
||||
expect(markdown).toContain("Raw bytes");
|
||||
});
|
||||
describe("enforce mode", () => {
|
||||
const run = (dir, budgets) => {
|
||||
const write = (relative, value) => {
|
||||
const file = path.join(dir, relative);
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(value));
|
||||
};
|
||||
write("budgets.json", budgets);
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/performance-report.mjs",
|
||||
"--next-dir",
|
||||
dir,
|
||||
"--config",
|
||||
path.join(dir, "budgets.json"),
|
||||
"--output-dir",
|
||||
path.join(dir, "reports"),
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
};
|
||||
it("passes when every route is measured within budget", () => {
|
||||
const enforce = { ...config, mode: "enforce" };
|
||||
const runResult = run(fixture().dir, enforce);
|
||||
expect(runResult.status).toBe(0);
|
||||
expect(runResult.stdout).toContain("Mode: enforce");
|
||||
expect(runResult.stderr).not.toContain("Performance budgets enforced");
|
||||
});
|
||||
it("fails when a route is over budget", () => {
|
||||
const enforce = {
|
||||
schemaVersion: 1,
|
||||
mode: "enforce",
|
||||
routes: [{ ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 }],
|
||||
};
|
||||
const runResult = run(fixture().dir, enforce);
|
||||
expect(runResult.status).toBe(1);
|
||||
expect(runResult.stderr).toContain("Performance budgets enforced");
|
||||
expect(runResult.stderr).toContain("/news (over-budget)");
|
||||
});
|
||||
it("fails when a route cannot be measured, so broken manifests do not pass quietly", () => {
|
||||
const enforce = { ...config, mode: "enforce" };
|
||||
const runResult = run(path.join(fixture().dir, "missing"), enforce);
|
||||
expect(runResult.status).toBe(1);
|
||||
expect(runResult.stderr).toContain("/news (unavailable)");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ const hasCompose =
|
||||
}).status === 0;
|
||||
|
||||
it.skipIf(!hasCompose)(
|
||||
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
|
||||
"loads the loopback override from .env for the cms service without changing mounts or host networking",
|
||||
() => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
||||
try {
|
||||
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
|
||||
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
||||
)
|
||||
delete environment[key];
|
||||
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
|
||||
// can never squat port 3002 on a CI-managed host. Selecting the
|
||||
// profile here keeps the loopback/port-override contract under test.
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
["compose", "config", "--format", "json"],
|
||||
["compose", "--profile", "cms", "config", "--format", "json"],
|
||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
||||
@@ -84,6 +84,22 @@ function applyOverrides(data) {
|
||||
data["soundboard.url"] =
|
||||
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
|
||||
}
|
||||
if (gamedata) {
|
||||
const soundboardDefaults = {
|
||||
"soundboard.manifest.url": `${gamedata}/config/SoundData.json?t=%timestamp%`,
|
||||
"soundboard.sounds.url": `${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`,
|
||||
"soundboard.layout.url": `${gamedata}/config/soundboard-layout.jsonc?t=%timestamp%`,
|
||||
};
|
||||
for (const [key, value] of Object.entries(soundboardDefaults)) {
|
||||
if (
|
||||
!(key in data) ||
|
||||
isPlaceholder(data[key]) ||
|
||||
data[key] === "configuration/soundboard-layout.jsonc"
|
||||
) {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never force ads on/off over a hotel's own choice; only default to "off"
|
||||
// when the key does not exist yet (prevents external ad scripts from 404ing).
|
||||
if (!("show.google.ads" in data)) data["show.google.ads"] = false;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -118,6 +118,13 @@ systemd_cmd=()
|
||||
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
||||
export CMS_MEMORY_CAPPED=1
|
||||
|
||||
# Gebruik hoogstens de helft van de cores, zodat de machine niet op 100% CPU
|
||||
# piekt, maar behoud voldoende parallelle kracht op (Ryzen) hosts.
|
||||
cpu_count="$(nproc 2>/dev/null || echo 1)"
|
||||
worker_cores=$((cpu_count / 2))
|
||||
[ "$worker_cores" -lt 1 ] && worker_cores=1
|
||||
cpu_affinity="0-$((worker_cores - 1))"
|
||||
|
||||
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
||||
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||
@@ -174,7 +181,7 @@ run_systemd() {
|
||||
# expliciet: deze host heeft geen swap.
|
||||
local high=$((effective * 95 / 100))
|
||||
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
|
||||
exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@"
|
||||
exec "${systemd_cmd[@]}" -E "PATH=$PATH" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" -p "AllowedCPUs=$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
run_ulimit() {
|
||||
@@ -186,7 +193,11 @@ run_ulimit() {
|
||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||
return 1
|
||||
}
|
||||
exec "$@"
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_refuse() {
|
||||
@@ -197,7 +208,11 @@ run_refuse() {
|
||||
|
||||
run_opted_out() {
|
||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||
exec "$@"
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$backend" in
|
||||
|
||||
@@ -60,7 +60,6 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const gif = await toBadgeGif(buffer);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, gif);
|
||||
} catch {
|
||||
back("error", "Could not process or write the badge file");
|
||||
|
||||
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
// key is one of 3 known const values
|
||||
comment: COMMENTS[key] ?? null,
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
|
||||
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
POINTS_KEYS.map((key) =>
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
// key from POINTS_KEYS const
|
||||
.values({ key, value: values[key], comment: "Radio points" })
|
||||
.onDuplicateKeyUpdate({
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
// key from POINTS_KEYS const
|
||||
set: { value: values[key] },
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
// guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
|
||||
|
||||
+291
-100
File diff suppressed because it is too large.
Load diff
+15
-47
@@ -2,7 +2,10 @@
|
||||
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { insertCatalogItemRow } from "@/actions/catalog-items";
|
||||
import {
|
||||
bulkInsertCatalogItems,
|
||||
insertCatalogItemRow,
|
||||
} from "@/actions/catalog-items";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import {
|
||||
createPageCommand,
|
||||
@@ -279,52 +282,17 @@ export async function createAutoCategory(input: {
|
||||
}
|
||||
|
||||
const rows = input.offerRows ?? [];
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
|
||||
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
||||
`);
|
||||
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
|
||||
|
||||
let created = 0;
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) continue;
|
||||
try {
|
||||
await insertCatalogItemRow({
|
||||
pageId,
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: row.amount ?? 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: nextOrder++,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
});
|
||||
created++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[createAutoCategory] failed to insert item #${row.baseId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
const { created } = await bulkInsertCatalogItems({
|
||||
pageId,
|
||||
staffId: staff.id,
|
||||
rows: rows.map((r) => ({
|
||||
baseId: r.baseId,
|
||||
credits: r.credits,
|
||||
points: r.points,
|
||||
pointsType: r.pointsType,
|
||||
amount: r.amount,
|
||||
})),
|
||||
});
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import { invalidateLoginCache } from "@/lib/auth/login-core";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import { db, PasswordReset, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
let error: "password" | "invalid" | "failed" | null = null;
|
||||
if (password.length < 12) error = "password";
|
||||
if (
|
||||
password.length < passwordPolicy.minLength ||
|
||||
password.length > passwordPolicy.maxLength
|
||||
)
|
||||
error = "password";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("register", () => {
|
||||
accountCreated: expect.any(Number),
|
||||
ipRegister: "203.0.113.9",
|
||||
ipCurrent: "203.0.113.9",
|
||||
look: "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
|
||||
look: "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
|
||||
termsAccepted: true,
|
||||
});
|
||||
expect(state.invalidateKey).toHaveBeenCalledWith("login:user:Alice_123");
|
||||
|
||||
+15
-5
@@ -5,6 +5,8 @@ import { after } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { sendVerification } from "@/lib/auth/email-verification";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
|
||||
import { invalidateKey } from "@/lib/cached-db";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -82,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean {
|
||||
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
||||
}
|
||||
|
||||
// Rendered into the schema below and mapped back to a locale-independent code
|
||||
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
|
||||
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
|
||||
const PASSWORD_MAX_MESSAGE = "Password is too long";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
@@ -104,8 +111,8 @@ const registerSchema = z
|
||||
),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
||||
.max(128, "Password is too long") // Added max length
|
||||
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
|
||||
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||
@@ -159,8 +166,8 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"This username is reserved": "usernameReserved",
|
||||
"Enter a valid email address": "emailValid",
|
||||
"Temporary email domains are not allowed": "emailDisposable",
|
||||
"Password must be at least 12 characters": "passwordMinLength",
|
||||
"Password is too long": "passwordMaxLength",
|
||||
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
|
||||
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength",
|
||||
"Password must contain at least one uppercase letter": "passwordUpper",
|
||||
"Password must contain at least one lowercase letter": "passwordLower",
|
||||
"Password must contain at least one digit": "passwordDigit",
|
||||
@@ -169,7 +176,10 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
};
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
// Shared with the imager URL builder, which normalises malformed stored looks
|
||||
// to the same figure (the hair part needs a colour slot: `hr-100-1`, not
|
||||
// `hr-100-`).
|
||||
const DEFAULT_LOOK = DEFAULT_AVATAR_FIGURE;
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function saveFavicon(
|
||||
}
|
||||
|
||||
const buffer = upload.bytes;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
@@ -46,7 +44,6 @@ export async function saveFavicon(
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore if file doesn't exist */
|
||||
@@ -85,7 +82,6 @@ export async function deleteFavicon(): Promise<{
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -29,9 +29,7 @@ export async function saveLogo(
|
||||
}
|
||||
|
||||
const buffer = upload.bytes;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
@@ -278,14 +278,12 @@ export async function uploadBackground(formData: FormData): Promise<void> {
|
||||
|
||||
let uploaded = false;
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
await writeSetting("background_image", `/api/media/${name}`);
|
||||
await writeSetting(
|
||||
@@ -319,7 +317,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
|
||||
const name = String(formData.get("preset") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
// guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect(MANAGER_PATH);
|
||||
|
||||
|
||||
@@ -142,7 +142,6 @@ export default async function ApplyStaffPage({
|
||||
<article key={String(position.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={rankName}
|
||||
|
||||
@@ -120,7 +120,6 @@ export default async function ApplyTeamPage({
|
||||
<article key={String(team.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={team.rankName}
|
||||
|
||||
@@ -48,7 +48,6 @@ export default async function BadgesPage() {
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
|
||||
alt={badge.badgeName}
|
||||
|
||||
@@ -190,7 +190,6 @@ export default async function DrawBadgePage({
|
||||
{b.badgeUrl ? (
|
||||
// Badge images are arbitrary external URLs stored by the
|
||||
// draw-badge tool, so a plain <img> is correct here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={b.badgeUrl}
|
||||
alt=""
|
||||
|
||||
@@ -149,7 +149,6 @@ export default async function GuildPage({
|
||||
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{guild.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||
alt={`${guild.name} badge`}
|
||||
|
||||
@@ -74,7 +74,6 @@ export default async function GuildsPage() {
|
||||
}}
|
||||
>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt={`${g.name} badge`}
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user