1 Commits
Author SHA1 Message Date
remco 3b320b08c2 Add renovate.json
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (pull_request) Successful in 34s
CI / tests-unit (pull_request) Successful in 1m33s
CI / tests-integration (pull_request) Failing after 1m38s
CI / tests-ui (pull_request) Successful in 2m21s
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-02 21:00:15 +00:00
326 changed files with 3716 additions and 12191 deletions

No files matched your search

+6 -12
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -33,12 +33,6 @@ jobs:
- name: Toolchain check
run: node scripts/check-node-toolchain.mjs
# Port selection decides which blue/green slot stays live. Getting it
# wrong starts the candidate on an occupied port, so the regression that
# caused a failed deploy is covered here, before any image is built.
- name: Deploy port-selection tests
run: bash scripts/ci-deploy-ports.test.sh
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -64,7 +58,7 @@ jobs:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -92,7 +86,7 @@ jobs:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -108,7 +102,7 @@ jobs:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -141,7 +135,7 @@ jobs:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
@@ -175,7 +169,7 @@ jobs:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
-9
View File
@@ -1,9 +0,0 @@
name: Gitea Runner Clean Test
on: [push]
jobs:
test-job:
runs-on: ubuntu-latest # Gitea zoekt hier zelf de v5-runner bij die dit label heeft!
steps:
- name: Hallo wereld
run: echo "De v5 Gitea Runner werkt perfect en volledig anoniem!"
-7
View File
@@ -1,12 +1,5 @@
image: node:26
# Runner containers have no systemd, so scripts/with-memory-cap.sh cannot
# enforce an RSS cap there and correctly refuses to run unbounded. These
# builds are already isolated inside their own runner container (not the
# host) and use the webpack builder; opt out explicitly on purpose.
variables:
CMS_MEMORY_CAP_BACKEND: "none"
stages:
- test
- build
-14
View File
@@ -1,14 +0,0 @@
{
"WARNING": "This file is automatically generated by Gitea Runner. Do not edit it manually unless you know what you are doing. Removing this file will cause Gitea Runner to re-register as a new runner.",
"id": 22,
"uuid": "ffb52201-e18e-4a0f-96e9-cf8a0b849365",
"name": "v5-runner",
"token": "0484b5a82d3bda9a62972a6d2646ca7cb90bc4e2",
"address": "https://gitlab.epicnabbo.nl/",
"labels": [
"self-hosted:host",
"ubuntu-latest:docker://node:18-bullseye",
"debian-latest:docker://debian:bullseye-slim"
],
"ephemeral": false
}
View File
Whitespace-only changes.
+4 -19
View File
@@ -3,12 +3,10 @@ FROM node:26.10.0-alpine AS migrations
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Installeer git, bash en pnpm v12. bash is nodig voor
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
# Alpine levert geen bash mee.
# Installeer git en pnpm v12
RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache git bash \
&& npm install -g pnpm@12.10.1
apk add --no-cache git \
&& npm install -g pnpm@12.8.1
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
ENV PNPM_HOME="/usr/local/share/pnpm"
@@ -28,21 +26,8 @@ FROM migrations AS builder
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# The build runs the webpack builder (see the `build` script in package.json).
# Turbopack's compiler is a single native process that grows past 12GB RSS on
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
# --max-old-space-size cap does NOT help, because that memory is native
# Turbopack memory rather than the V8 heap.
#
# `pnpm run build` goes through scripts/with-memory-cap.sh. BuildKit's build
# container has /sys/fs/cgroup mounted read-only (no cgroup MemoryMax) and
# `ulimit -v` breaks V8-based builds (see the script header), so this stage
# explicitly opts out of the cap. The real bound here is the webpack builder
# + the V8 heap cap above, and the build runs isolated in its own container,
# not on the host; the host itself is protected by the same wrapper through
# systemd.
# Fix voor OOM Killer: dwing de Node-compiler om agressief op te ruimen bij 4GB RAM
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV CMS_MEMORY_CAP_BACKEND=none
# Bouw de Next.js applicatie met caching
RUN --mount=type=cache,target=/app/.next/cache \
+7 -125
View File
@@ -10,21 +10,14 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- |
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
| pnpm | 12.10.1 | Pinned via `packageManager` |
| Node.js | 26.9.0 | Current release pinned in `.nvmrc` |
| pnpm | >= 11.25.0 | Recommended package manager |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
Core stack: **Next.js 16.4.0** (App Router) · **React 19.3.0** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.3** · **Zod 4.6.5** · **Vitest 5.0.3** · **Biome 2.5.15** · **Playwright 1.63.0**.
> Builds must run through the `pnpm` scripts. This host has no swap and
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
> kernel and can take the database and the live release down with it. See
> [Memory-capped commands](#memory-capped-commands).
---
## Quick Start
@@ -109,10 +102,6 @@ pnpm build && pnpm start # or: npm run build && npm start
Open `http://localhost:3002` in your browser.
> Always go through these scripts. `next build` is refused outright when it is
> not running under the memory cap — see
> [Memory-capped commands](#memory-capped-commands).
### 6. First Login
1. Register an account at `/register`, or log in with an existing emulator account.
@@ -594,7 +583,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
### Requirements (host)
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab`, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (`.nitro` assets) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
### Configuration — `/docker/Polaris-imager/.env`
@@ -886,24 +875,6 @@ that the CI deploy path deliberately uses `docker run` rather than compose, so
the resource limits are declared in **both** places — limits that only existed
in compose would never apply to a real release.
### Compose on a CI host
Compose and CI both want port 3002, so only one of them can own a host. A stray
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
green slot, and every later release stopped on "Port 3002 is already in use" —
after the build, the migrations and the browser gate. Two guards now prevent
that:
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream.
- `ci-deploy.sh` retires a compose replica of *this* checkout
(`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx
currently serves. Anything else still fails loudly in `assert_port_free`.
### The nginx upstream is the switch
nginx does not know about container names; it reads a plain list of backends from
@@ -962,99 +933,15 @@ branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
---
## Memory-capped commands
This host runs with `vm.overcommit_memory=0` **and no swap**. When a process
asks for more memory than is free, the kernel does not wait — it calls the
OOM-killer immediately, and it picks its victim across the **whole machine**,
not just the offending process. An uncapped build does not merely fail: it can
take the MariaDB process, nginx and the live release down with it.
Every heavy command therefore runs inside its own cgroup with a hard
`MemoryMax`, via `scripts/with-memory-cap.sh`. If the build outgrows its
ceiling, only that cgroup is killed — the build fails, the site keeps serving.
| Script | Ceiling | Covers |
| --------------------- | ------- | --------------------------------------- |
| `pnpm dev` | 8 GB | Dev server |
| `pnpm build` | 10 GB | Production build |
| `pnpm analyze` | 10 GB | Build + bundle-size report |
| `pnpm test` | 8 GB | Vitest |
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
| `pnpm test:ui` | 8 GB | Playwright |
| `pnpm test:e2e` | 8 GB | Playwright |
| `pnpm test:integration` | 8 GB | Vitest integration config |
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
### Running the builder by hand
```bash
npx next build # ✗ refused before it allocates anything
```
`next build` loads `next.config.ts`, which **refuses any production build that
is not running under the memory cap**. This closes the one hole the `pnpm`
scripts leave open: invoking the builder directly — from a terminal, an IDE, or
an automated agent — would otherwise bypass the cgroup entirely and go
unbounded.
The refusal looks like this:
```
Error: Refusing to run an uncapped production build.
On this host an unbounded `next build` gets OOM-killed by the kernel,
and the killer may take the database, nginx or the live release with it.
Use the capped build instead:
pnpm build
```
Fix: use `pnpm build`. If you are genuinely inside an isolated environment
where the container *is* the boundary (the Docker build, a CI runner), set
`CMS_MEMORY_CAPPED=1` to opt out deliberately.
### Backends
`scripts/with-memory-cap.sh` picks its mechanism automatically:
| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes |
| ------------------------ | ------------------------------------------- | ------------------------------------------------------- |
| `auto` *(default)* | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. |
| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. |
| `none` | None — warning only | Docker build and GitLab runner, each already isolated. |
On a host **without** systemd and without `CMS_MEMORY_CAP_BACKEND=none`, the
script refuses to run rather than proceeding unbounded.
> Do not "fix" a cap failure by lowering `--max-old-space-size` or by raising
> `CMS_MEMORY_CAP_VIRTUAL` (the default is `40g` on purpose). A `ulimit -v` of
> 10g makes V8 clamp its own heap to ~2.25 GB and webpack dies with
> `std::bad_alloc`. Measure first; raise the ceiling deliberately.
---
## Scripts
| Command | Description |
| ------------------------- | -------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build (memory-capped) |
| `pnpm build` | Production build |
| `pnpm start` | Start production server |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
| `pnpm test:e2e` | Playwright end-to-end tests |
| `pnpm test:integration` | Integration tests (own Vitest config) |
| `pnpm test:housekeeping` | Housekeeping feature tests |
| `pnpm lint` | Lint and format check (Biome) |
| `pnpm biome:lint` | Alias of `pnpm lint` |
| `pnpm format` | Format files in place (Biome) |
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
| `pnpm i18n:check` | Audit CMS translation coverage |
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
@@ -1064,16 +951,11 @@ script refuses to run rather than proceeding unbounded.
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + report per-route bundle sizes |
| `pnpm performance:report` | Re-render the performance report from a build |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm jobs:worker` | Start background task worker |
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
| `pnpm biome:check` | Lint and format code |
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
>
> The heavy ones run memory-capped — see
> [Memory-capped commands](#memory-capped-commands). A production `next build`
> run outside the wrapper is refused rather than executed unbounded.
---
@@ -1198,7 +1080,7 @@ The CMS automatically translates furniture names and descriptions to **13 langua
pnpm dev # Start with hot reload
pnpm typecheck # Type check all files
pnpm test # Run test suite
pnpm analyze # Build and report per-route bundle sizes
pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format
```
-24
View File
@@ -38,30 +38,6 @@
}
}
}
},
{
"includes": [
"src/components/admin/catalog-manager/sortable-tree.tsx",
"src/components/admin/studio/organize-imports-dialog/mall-helpers.tsx",
"src/app/admin/import/furni/nitro-editor-dialog.tsx"
],
"linter": {
"rules": {
"suspicious": {
"noArrayIndexKey": "off"
}
}
}
},
{
"includes": ["src/components/admin/catalog-manager/sortable-tree.tsx"],
"linter": {
"rules": {
"correctness": {
"useExhaustiveDependencies": "off"
}
}
}
}
],
"css": {
+1 -133
View File
@@ -78,23 +78,6 @@ map "$cms_cc_class|$cms_skip_cache" $cms_public_cc {
"~^3\|0" "public, max-age=300, s-maxage=300, stale-while-revalidate=600";
}
# Bestandsnaam van een furni-icon, opgehaald uit de URL. De locatie voor
# /swf/dcr/hof_furni/icons/ heeft die nodig om hetzelfde bestand bij de
# gamedata-boom op te kunnen vragen. Leeg laten voor elke andere URL, zodat
# niets anders deze waarde per ongeluk gebruikt.
map $uri $furni_icon_file {
~^/swf/dcr/hof_furni/icons/(?<icon_file>.+)$ $icon_file;
default "";
}
# Cache-Control per status voor het CMS-valrimpeltje. Een 404 mag nooit
# gecacht worden (zie @gamedata_missing hieronder), dus die krijgt `no-store`
# in plaats van de icon-TTL.
map $upstream_status $furni_icon_cc {
200 "public, max-age=604800, stale-while-revalidate=2592000";
default "no-store";
}
# ─── Mime fix ───
types {
application/json jsonc;
@@ -179,22 +162,6 @@ server {
ssl_early_data on;
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
# Resuming a session skips the full handshake, which is most of the cost of
# a TLS connection. Without this nginx performs no session resumption at all:
# every visitor paid a full handshake on every request. 50M shared sessions
# is roughly 1GB at the default 20-byte key id plus overhead.
ssl_session_cache shared:CMS_TLS:50m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# `index index.html` without a `root` left nginx resolving every
# try_files/$uri against the compiled-in default /etc/nginx/html. The
# /robots.txt and /favicon.ico probes then stat() a path the worker cannot
# traverse, and because a failed stat is logged at crit the error log filled
# with 149 crit lines per scan. Pointing root at the CMS document root makes
# the same probe a plain 404, which log_not_found already suppresses.
root /var/www/html;
index index.html;
# ─── Security Headers ───
@@ -393,113 +360,14 @@ server {
return 404;
}
# ─── Furni-icons: gamedata-boom eerst, CMS-boom als valrimpeltje ───
#
# De CMS bouwde zijn icon-URL's altijd vanaf /swf/dcr/hof_furni/icons/,
# maar de catalogus komt uit /var/www/Gamedata/icons:
# - van de 16.263 classnames in items_base staat er 15.338 hier onder de
# "veilige" naam (`highscore_perteam_1_icon.png`), tegen 11.267 in
# public/swf/dcr/hof_furni/icons;
# - de swf-boom houdt kleurvarianten bovendien vast met een letterlijke
# `*` in de bestandsnaam (`highscore_perteam*1_icon.png`), dus elke
# aanvraag met de veilige naam mist;
# - en `hof.furni.url` wijst de Nitro-client al naar deze boom.
# Resultaat was een 404 voor `highscore_perteam_1_icon.png` en duizenden
# andere, terwijl `/gamedata/icons/` ze wél heeft.
#
# Deze locatie leest de gamedata-boom rechtstreeks van schijf — nginx heeft
# er leesrechten op en het is de boom die de client ook gebruikt. Wat daar
# niet staat wordt doorgestuurd naar de CMS, die uit public/ serveert,
# zodat niets wat nu al laadde stopt met laden.
#
# Een echte miss is een no-store 404, nooit een gecachte: `add_header`
# zonder `always` zegt niets over een 404, en zonder de expliciete handler
# hieronder neemt Cloudflare de zone-TTL (1 jaar) over. Zelfde les als bij
# @gamedata_missing hierboven.
location ^~ /swf/dcr/hof_furni/icons/ {
alias /var/www/Gamedata/icons/;
error_page 404 = @furni_icon_from_cms;
# 403 = het pad valt op een map, dus `alias` eindigt op een directory.
# Dat is geen icon, dus dezelfde route als een miss.
error_page 403 = @furni_icon_missing;
# Geen limit_req: zelfde reden als /gamedata/icons/. Een kamerladen
# vuurt honderden icons in één burst af.
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=2592000";
add_header Cache-Tag "cms-furni-icons";
access_log off;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
if ($cors_headers) {
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
}
# Niet in de gamedata-boom: val terug op public/swf/dcr/hof_furni/icons/,
# waar de CMS naartoe importeert. Het pad wordt hier opnieuw opgebouwd
# omdat een named location geen prefix van `$uri` afstropt.
#
# De Cache-Control komt uit `$furni_icon_cc` (status-afhankelijk) in plaats
# van uit een tweede `error_page`: nginx negeert `error_page` die binnen
# een named location staat die zelf via `error_page` bereikt is, dus een
# geketende 404-handler bestaat hier niet.
location @furni_icon_from_cms {
internal;
proxy_pass http://cms_app/swf/dcr/hof_furni/icons/$furni_icon_file;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_intercept_errors on;
# `=404` (geen URI) vervangt de body door nginx' eigen foutpagina. Next
# stuurt voor een miss een volledige HTML-pagina van ~300 KB mee, en een
# gebroken icon hoeft geen 300 KB aan markup op te halen. Dit is geen
# interne redirect, dus de `add_header` hieronder blijven gelden.
error_page 404 =404;
# `proxy_hide_header` haalt de Cache-Control van de CMS weg, anders
# staan er twee in één antwoord (Next stuurt voor /swf/** een
# `public, max-age=604800`, `location /` elders nog een
# `private, no-cache`). Eén header per antwoord.
proxy_hide_header Cache-Control;
add_header Cache-Control $furni_icon_cc always;
add_header Cache-Tag "cms-furni-icons";
access_log off;
}
location @furni_icon_missing {
add_header Cache-Control "no-store" always;
add_header Cache-Tag "cms-furni-icons" always;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
access_log off;
return 404;
}
location /camera/ {
alias /var/www/Camera/;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Tag "cms-camera";
}
# robots.txt is generated by the CMS (src/app/robots.ts, force-dynamic
# because it needs APP_URL) and sitemap.xml points crawlers at it. This
# location used to answer from disk with try_files, which made it a
# guaranteed 404: the file does not exist in public/, so crawlers were told
# to obey a robots.txt they could never read. Proxy it like the route it
# actually is. favicon.ico below stays on disk — log_not_found already
# keeps its miss quiet.
location = /robots.txt {
access_log off;
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ─── Static Next.js Assets ───
location /_next/static/ {
@@ -1,39 +0,0 @@
[Unit]
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
# and health probes). This is NOT optional: a web process alone does not
# establish that scheduled work runs. The CMS reports it as a failed
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
# missing, which is exactly what happened while nothing supervised this.
#
# It runs on the host rather than in a container on purpose: the schedule
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
# and it must survive CMS deploys (a container is replaced on every release).
Description=AtomNext CMS scheduled-job worker
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
After=network-online.target docker.service mariadb.service
Wants=network-online.target
# Start ordering only; the worker tolerates the database being briefly absent
# and retries, so do not make it hard-fail when mariadb is slow to boot.
Wants=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/var/www/atom-nexst
Environment=NODE_ENV=production
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
# bad DATABASE_URL) from spinning.
Restart=always
RestartSec=10
# Give a crashed job time to finish its DB transaction before the next start,
# otherwise a mid-transaction kill can loop on the same failure.
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cms-jobs-worker
[Install]
WantedBy=multi-user.target
-19
View File
@@ -1,19 +0,0 @@
[Service]
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
# inherits that soft limit, so worker_connections 2048 could not actually be
# reached and every start logged:
# "2048 worker_connections exceed open file resource limit: 1024"
# Raise both soft and hard to 65536 so the master's rlimit covers
# worker_connections before nginx is even started.
LimitNOFILE=65536
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
# down until someone noticed. nginx is the only thing serving the site, so it
# must come back on its own. `on-failure` restarts only abnormal exits, which
# keeps an operator-initiated `systemctl stop` from being undone.
Restart=on-failure
RestartSec=2
# Give in-flight requests time to drain on stop/reload instead of severing
# keepalive connections and long-polling SSE streams mid-response.
TimeoutStopSec=30
+1 -8
View File
@@ -3,19 +3,12 @@
# ─────────────────────────────────────────────────────────────────────────────
x-cms: &cms
image: epicnext-cms:${CMS_RELEASE:-local}
# No `network: host` on the build. BuildKit (v0.26, Docker 29) refuses to grant
# host networking unless every caller passes --allow=network.host, and
# `docker compose build` has no such flag — so asking for it here turned every
# rebuild into an immediate "additional privileges requested" failure, which
# left the previous release serving traffic. The build only needs outbound
# internet (apk, pnpm, next/font/google), which the default bridge provides.
build:
context: .
dockerfile: Dockerfile
args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# Runtime host networking IS required: blue/green needs per-release host ports
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
network: host
network_mode: host
stop_grace_period: 15s
restart: unless-stopped
+3 -11
View File
@@ -12,25 +12,17 @@ The command writes `report.json` and `report.md` and prints the Markdown report.
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from the manifest's chunk lists are excluded.
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
## Initial limits
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
| --- | ---: | ---: | ---: | ---: |
@@ -1,31 +0,0 @@
-- Repair the escalation introduced by 0018's rule 1 ("has admin.dashboard gets
-- ALL admin.*"). Migrating 0011 grants admin.dashboard to every rank >= 6 so
-- that the sidebar opens, which meant rank 6 silently acquired
-- admin.permissions.manage, admin.rcon.execute, admin.settings.edit,
-- admin.users.edit, admin.users.reset_password, admin.room.delete, ...
--
-- Rule 1 is narrowed to `admin.%.view` (read-only, all the sidebar needs) in
-- both the migration set and the runtime repair action. This migration undoes
-- the over-grant on databases that already ran 0018: every role below the top
-- rank keeps dashboard + *.view and loses every other admin.* grant. Ranks
-- that legitimately hold tools keep them, because rule 3 only targets
-- rank >= 7 and those roles are not touched here.
--
-- Note on the rank extraction: `acl_roles.slug` looks like `rank_7`, and
-- MySQL's SUBSTRING is 1-based, so the digits start at position 6 — right
-- after the 5-character `rank_`. Reading from position 7 truncates the first
-- digit, which turns rank_10 into 0 and rank_7 into an empty string, i.e. both
-- would compare as < 7 and lose grants this migration is supposed to preserve.
-- The REGEXP guard below guarantees the remainder really is all digits.
DELETE `amp`
FROM `acl_model_permissions` `amp`
JOIN `acl_roles` `ar`
ON `ar`.`id` = `amp`.`model_id`
AND `amp`.`model_type` = 'Role'
JOIN `acl_permissions` `ap`
ON `ap`.`id` = `amp`.`permission_id`
WHERE `ap`.`slug` LIKE 'admin.%'
AND `ap`.`slug` NOT LIKE '%.view'
AND `ar`.`slug` REGEXP '^rank_[0-9]+$'
AND CAST(SUBSTRING(`ar`.`slug`, 6) AS UNSIGNED) < 7;
@@ -1,19 +0,0 @@
-- 0035_users_mail_index.sql
-- Index on users.mail.
--
-- The authentication paths all look an account up by mail: password reset,
-- e-mail verification, duplicate-address detection and the verify/resend
-- cooldown all resolve a single user from a submitted address. Without an index
-- each of those is a full table scan of `users`, which grows with every
-- registration.
--
-- Deliberately NOT unique. Legacy rows predate the duplicate-address handling
-- and can legitimately contain the same address more than once, so a unique
-- index would fail to apply on an existing database. The lookup is made
-- deterministic by ordering on `id` (see requestReset / the verify page), which
-- is stable without the index and correct with it.
--
-- The column is VARCHAR(500), which exceeds the 767-byte InnoDB prefix limit on
-- older row formats, hence an explicit 191-character prefix: enough to make the
-- lookup selective and still indexable everywhere.
CREATE INDEX IF NOT EXISTS `users_mail_index` ON `users` (`mail`(191));
+1 -4
View File
@@ -88,10 +88,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
await page
.locator('input[autocomplete="current-password"]')
.press("Enter");
// The login page honours `?from=`, so an admin bounced off /admin lands
// back where they were heading instead of on /me. The step below
// navigates there explicitly anyway; this asserts the redirect target.
await expect(page).toHaveURL(/\/admin\/articles\/new(?:\?|$)/);
await expect(page).toHaveURL(/\/me(?:\?|$)/);
const session = await context.request
.get("/api/auth/session")
.then((response) => response.json());
+6 -4
View File
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = {
name: "fixture_chair.hab",
name: "fixture_chair.nitro",
mimeType: "application/octet-stream",
buffer: Buffer.from("isolated upload fixture"),
};
@@ -23,7 +23,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
expect(route.request().postData()).toContain('name="classname"');
expect(route.request().postData()).toContain("fixture_chair");
expect(route.request().postData()).toContain(
'filename="fixture_chair.hab"',
'filename="fixture_chair.nitro"',
);
await route.fulfill({ json: { ok: true, attachmentId } });
});
@@ -38,7 +38,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ json: { ok: true } });
});
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
await expect(
page.getByText("Matching original file attached", { exact: true }),
).toBeVisible();
@@ -94,7 +94,9 @@ for (const scenario of [
})
: route.abort("failed"),
);
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await page
.getByLabel("Choose the original .nitro file")
.setInputFiles(file);
await expect(page.getByRole("alert")).toContainText(scenario.message);
await expect(
page.getByRole("button", {
-136
View File
@@ -1,136 +0,0 @@
import { expect, test } from "@playwright/test";
/**
* The furniture pane once declared `initial={{ opacity: 0 }}` / `animate={{
* opacity: 1 }}` through motion's minimal `motion/react-m` entry. That entry
* renders the element but never runs the animation, so the inline style stayed
* at opacity: 0: every row was in the DOM, measurable, and its image loaded,
* yet the whole list was invisible.
*
* studio.spec.ts could not catch it because playwright.ui.config.ts sets
* `reducedMotion: "reduce"`, and under reduced motion the animation is skipped
* and the element lands straight on its final value. This file opts out of that
* so a future animation swap cannot quietly hide the list again.
*/
const items = [
{
id: 1,
classname: "fixture_chair",
name: "Fixture chair",
description: "Synthetic chair",
type: "flooritem",
revision: 1,
category: "other",
alreadyImported: true,
nitroExists: true,
iconUrl: "/fixture/cover.svg",
},
{
id: 2,
classname: "fixture_table",
name: "Fixture table",
description: "Synthetic table",
type: "flooritem",
revision: 1,
category: "other",
alreadyImported: false,
nitroExists: false,
iconUrl: "/fixture/cover.svg",
},
];
test("the furniture pane is painted, not merely present in the DOM", async ({
browser,
}) => {
const context = await browser.newContext({
reducedMotion: "no-preference",
viewport: { width: 1440, height: 900 },
});
const page = await context.newPage();
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.pathname === "/api/admin/import/furni") {
return route.fulfill({
json:
url.searchParams.get("action") === "stats"
? { totalInDb: 2, inCatalog: 1, notInCatalog: 1, missingNitro: 0 }
: {
items,
meta: { currentPage: 1, lastPage: 1, total: 2, perPage: 20 },
},
});
}
if (url.pathname === "/api/admin/import/clone")
return route.fulfill({ json: { sources: [] } });
if (url.pathname === "/api/admin/studio/import-jobs")
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
if (url.pathname === "/api/admin/studio/nitro-quality")
return route.fulfill({
json: {
report: {
scales: [32, 64].map((size) => ({
size,
state: "missing",
assets: [],
animations: [],
directions: [],
issues: [],
})),
},
},
});
return route.fulfill({
status: 404,
json: { error: "Unknown fixture endpoint" },
});
});
await page.goto("/admin/studio-harness", { waitUntil: "domcontentloaded" });
const list = page.locator('[data-testid="studio-furniture-list"]');
await expect(list).toBeVisible();
await expect
.poll(async () => list.locator("[data-index]").count(), { timeout: 20000 })
.toBeGreaterThan(0);
// Let any entrance animation run before sampling computed styles.
await page.waitForTimeout(1000);
// The rows exist. Now prove they are actually painted: no ancestor may be
// left faded out, which is precisely the failure this guards against.
const samples = await list.locator("[data-index]").evaluateAll((els) =>
els.slice(0, 5).map((el) => {
const opacities: number[] = [];
let node: Element | null = el;
while (node && opacities.length < 12) {
opacities.push(Number(getComputedStyle(node).opacity));
node = node.parentElement;
}
const rect = el.getBoundingClientRect();
return {
minOpacity: Math.min(...opacities),
width: rect.width,
height: rect.height,
};
}),
);
expect(samples.length).toBeGreaterThan(0);
for (const s of samples) {
expect(s.minOpacity, "an ancestor is faded out").toBeGreaterThan(0.9);
expect(s.width).toBeGreaterThan(0);
expect(s.height).toBeGreaterThan(0);
}
// Playwright treats opacity 0 as not visible, so this is the end-to-end form.
await expect(list.locator("[data-index]").first()).toBeVisible();
await expect(
page.getByRole("button", { name: "View Fixture chair", exact: true }),
).toBeVisible();
await context.close();
});
+6 -19
View File
@@ -156,14 +156,7 @@ beforeAll(async () => {
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
delete process.env.SKIP_ENV_VALIDATION;
delete process.env.OPENAI_API_KEY;
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
// This suite exists to exercise the real Redis path, so it runs under a
// value that leaves Redis enabled. "development" is used because it is the
// only non-production value src/env.ts accepts. Vitest's own environment is
// still configured via vitest.integration.config.ts. Object.assign is used
// because process.env.NODE_ENV is typed read-only.
Object.assign(process.env, { NODE_ENV: "development" });
Object.assign(process.env, { NODE_ENV: "test" });
process.env.HOTEL_NAME = "Integration";
await connection.query(
@@ -387,8 +380,9 @@ describe("Redis application cache", () => {
let fetches = 0;
const fetch = async () => ({ revision: ++fetches });
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
// Second read is served from cache, so the origin is not consulted again.
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
expect(
await cache.cached(key, 60000, async () => ({ revision: 1 })),
).resolves.toMatchObject({ revision: 1 });
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
cache.invalidateMemory(key);
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
@@ -409,9 +403,6 @@ describe("Redis application cache", () => {
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
"updated",
);
// `second`'s memory copy was dropped too, but its Redis entry survives, so
// the read is served from the shared cache and never recomputes. This is
// what makes the two entries independent.
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
"second",
);
@@ -629,12 +620,9 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(existing.status).toBe("draft");
expect(existing.publishedAt).toBeNull();
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
// A draft has no public article, so this read is a negative result that
// gets cached. Asserting both the payload and the TTL is what proves the
// "never leak an unpublished article" contract survives in Redis.
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
expect(await appRedis?.get(negativeKey)).toBe("null");
expect(await appRedis?.get(negativeKey)).toBeNull();
expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0);
const body = `<p>${"Contenuto completo è 📰 ".repeat(4000)}</p>`;
@@ -851,8 +839,7 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
// Cached negative results are stored as the JSON encoding of null.
expect(await redis.get(negativeKey)).toBe("null");
expect(await redis.get(negativeKey)).toBeNull();
const publish = articleForm({
id: String(existing.id),
baseToken: articleEditToken(existing),
+1 -46
View File
@@ -1,47 +1,7 @@
import { execSync } from "node:child_process";
import type { NextConfig } from "next";
import { PHASE_PRODUCTION_BUILD } from "next/constants";
import createNextIntlPlugin from "next-intl/plugin";
/**
* This host runs with `vm.overcommit_memory=0` and no swap, so a process that
* asks for more memory than is free gets OOM-killed by the kernel immediately.
* The killer picks its victim across the WHOLE machine — an unbounded build can
* take down the database, nginx and the live release with it.
*
* `scripts/with-memory-cap.sh` runs a heavy command in its own cgroup with a
* hard `MemoryMax`, so only that build dies and the site keeps serving. Every
* script in package.json goes through it.
*
* The one hole that leaves is running the builder by hand: `npx next build`,
* `pnpm exec next build`, or an IDE/agent task invoking it directly skips the
* wrapper entirely and is unbounded. This guard closes that. `next build`
* loads the config, so refusing here stops the build before it allocates
* anything. See the header of scripts/with-memory-cap.sh.
*/
function assertMemoryCapped(phase: string): void {
if (phase !== PHASE_PRODUCTION_BUILD) return;
if (process.env.CMS_MEMORY_CAPPED === "1") return;
throw new Error(
[
"Refusing to run an uncapped production build.",
"",
"On this host an unbounded `next build` gets OOM-killed by the kernel,",
"and the killer may take the database, nginx or the live release with it.",
"",
"Use the capped build instead:",
" pnpm build",
"",
"It runs the builder through scripts/with-memory-cap.sh, which puts it in",
"its own cgroup with a MemoryMax, so a runaway build fails alone.",
"",
"Already inside an isolated environment (Docker, a CI runner) where the",
"container itself is the boundary? Set CMS_MEMORY_CAPPED=1 explicitly.",
].join("\n"),
);
}
const getGitCommit = () => {
try {
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
@@ -214,9 +174,4 @@ const nextConfig: NextConfig = {
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
// Exported as a function so the build phase is known before the config is
// used. next-intl only accepts a plain object, so it is applied here.
export default function config(phase: string) {
assertMemoryCapped(phase);
return withNextIntl(nextConfig);
}
export default withNextIntl(nextConfig);
+28 -28
View File
@@ -5,20 +5,20 @@
"engines": {
"node": ">=26.10.0 <27"
},
"packageManager": "pnpm@12.10.1",
"packageManager": "pnpm@12.8.1",
"scripts": {
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev",
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
"dev": "pnpm assets:editor && next dev",
"build": "pnpm assets:editor && NODE_OPTIONS='--max-old-space-size=4096' next build",
"start": "next start",
"toolchain:check": "node scripts/check-node-toolchain.mjs",
"lint": "biome check .",
"biome:lint": "biome check .",
"lint": "biome check . || true",
"biome:lint": "biome check . || true",
"format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
"test": "vitest run --coverage.enabled=false",
"test:coverage": "vitest run",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
"db:bulk": "tsx scripts/bulk-import-json.ts",
@@ -30,25 +30,25 @@
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
"analyze": "next experimental-analyze",
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs",
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test",
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
"test:e2e": "playwright test",
"test:news:real": "node --import tsx e2e/news-real/run.ts",
"test:ui": "playwright test --config playwright.ui.config.ts",
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"dependencies": {
"@base-ui/react": "1.8.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.21",
"@formatjs/icu-messageformat-parser": "3.5.20",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.1",
"@tanstack/react-virtual": "3.14.13",
@@ -59,16 +59,16 @@
"drizzle-orm": "0.45.3",
"hash-wasm": "4.12.0",
"ioredis": "6.0.0",
"isomorphic-dompurify": "^4.5.0",
"isomorphic-dompurify": "^4.4.0",
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.52.0",
"lucide-react": "1.50.0",
"lzma-wasm": "1.0.7",
"motion": "14.0.0",
"music-metadata": "12.0.0",
"music-metadata": "11.16.1",
"mysql2": "3.24.5",
"next": "16.4.0",
"next": "16.3.8",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"otplib": "13.5.0",
@@ -76,17 +76,17 @@
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.89.0",
"resend": "6.32.1",
"resend": "6.32.0",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
"tailwind-merge": "3.7.0",
"tinymce": "8.9.3",
"tinymce": "8.9.2",
"zod": "4.6.5"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/parser": "8.0.7",
"@babel/parser": "7.29.9",
"@biomejs/biome": "2.5.15",
"@playwright/test": "1.63.0",
"@tailwindcss/forms": "0.5.11",
@@ -98,14 +98,14 @@
"@vitest/coverage-v8": "5.0.3",
"drizzle-kit": "0.31.11",
"esbuild": "0.28.2",
"msw": "^2.15.0",
"pino-pretty": "13.2.0",
"postcss": "8.5.29",
"msw": "3.0.1",
"pino-pretty": "13.1.3",
"postcss": "8.5.28",
"tailwindcss": "4.3.3",
"testcontainers": "12.2.0",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.3",
"vite": "8.3.2",
"vitest": "5.0.3"
}
}
}
+371 -360
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -16,4 +16,3 @@ overrides:
glob: '^11.0.0'
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2'
+57 -30
View File
@@ -1,26 +1,5 @@
// Retired service worker — this file no longer serves anything.
//
// The app used to register a worker that cached /_next/static/ and /assets/
// cache-first under a cache name with no build id. A release could not
// invalidate it, so browsers replayed the previous release's chunks against
// the new HTML and React never hydrated, which is what left the Catalog
// Studio on a blank white page. It also bought nothing: nginx already sends
// those two prefixes as `max-age=31536000, immutable` and their filenames are
// content-hashed, so the HTTP cache already handles them correctly.
//
// This file must stay at /sw.js and keep its install/activate handlers.
// Simply deleting it would leave every existing registration alive and in
// control of the page, with the old caching still running. Instead this
// worker does the opposite of what it used to: it deletes every cache and
// unregisters itself, then reloads open clients so they stop being
// controlled by it.
//
// Nothing registers it any more (see src/app/layout.tsx), so this only ever
// runs for browsers that already have a worker installed. Once a visitor
// loads the site again it uninstalls itself and never comes back.
//
// The manifest is unrelated and untouched — it is generated by
// src/app/manifest.ts and keeps the site installable without a worker.
const CACHE = "atom-v3";
const API_CACHE = "atom-api-v3";
self.addEventListener("install", () => self.skipWaiting());
@@ -28,13 +7,61 @@ self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
.then(() => self.registration.unregister())
.then(() =>
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== CACHE && k !== API_CACHE)
.map((k) => caches.delete(k)),
),
)
.then((clients) => {
for (const client of clients) client.navigate(client.url);
}),
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (
url.pathname.startsWith("/assets/") ||
url.pathname.startsWith("/_next/static/")
) {
event.respondWith(
caches.open(CACHE).then((cache) =>
cache.match(req).then(
(hit) =>
hit ||
fetch(req).then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
}),
),
),
);
return;
}
if (
url.pathname.startsWith("/api/home") ||
url.pathname.startsWith("/api/online") ||
url.pathname.startsWith("/api/radio/config")
) {
event.respondWith(
caches.open(API_CACHE).then((cache) =>
fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
})
.catch(() => cache.match(req)),
),
);
return;
}
if (req.mode === "navigate") {
event.respondWith(fetch(req));
}
});
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+4 -21
View File
@@ -45,28 +45,11 @@ for (const image of nodeImages) {
);
}
const cmpVersion = (a, b) => {
const pa = a.split(".").map((part) => Number.parseInt(part, 10));
const pb = b.split(".").map((part) => Number.parseInt(part, 10));
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
};
// `.nvmrc` is the recommended version for reproducible local development and
// the exact Docker base image (both asserted above), but the *runtime* we run
// on may be any version the package.json engines range accepts. Requiring an
// exact patch match here would fail on every Node.js patch release, even when
// the version is explicitly supported.
if (!process.argv.includes("--static")) {
const active = process.versions.node;
const upperBound = `${major + 1}.0.0`;
assert.ok(
cmpVersion(active, pinnedVersion) >= 0 &&
cmpVersion(active, upperBound) < 0,
`the active Node.js runtime (${active}) must satisfy package.json engines.node (${packageJson.engines.node}); .nvmrc pins ${pinnedVersion} as the recommended version`,
assert.equal(
process.versions.node,
pinnedVersion,
"the active Node.js runtime must match .nvmrc",
);
}
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
#
# Background: on a host where both blue/green slots answer /api/health, the
# original read_active_port() counted healthy slots and only consulted the nginx
# upstream when the count was not exactly 1. With two healthy slots it fell back
# to the upstream file, but an operator `docker compose up` can leave an extra
# replica behind, after which the fallback picked slot A regardless of which slot
# was really live. The candidate then tried to start on an occupied port, and the
# health probe answered from the pre-existing container on that port instead of
# the candidate — producing 30 failed "expected release never became healthy"
# attempts against a release that was never serving.
#
# The functions are extracted from ci-deploy.sh rather than copied so this test
# cannot drift from the script it protects.
set -Eeuo pipefail
deploy_script="$(dirname "$0")/ci-deploy.sh"
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
# Pull the two functions out of the real script.
extract() {
sed -n "/^$1() {/,/^}/p" "$deploy_script"
}
read_active_port_fn="$(extract read_active_port)"
assert_port_free_fn="$(extract assert_port_free)"
answers_health_fn="$(extract answers_health)"
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
echo "could not extract functions from $deploy_script" >&2
exit 1
fi
slot_a_port=3002
slot_b_port=3003
fail() { echo "FAIL: $*" >&2; exit 1; }
# ── read_active_port ──────────────────────────────────────────────────────────
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
run_read_active_port() {
local body="$1" a="$2" b="$3" tmp
tmp="$(mktemp)"
if [ "$body" = "none" ]; then
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
rm -f "$tmp"
else
printf '%s\n' "$body" >"$tmp"
fi
CMS_UPSTREAM_FILE="$tmp" \
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
bash -c "
slot_a_port=$slot_a_port
slot_b_port=$slot_b_port
upstream_file=\"\$CMS_UPSTREAM_FILE\"
$read_active_port_fn
# Defined after the extracted function on purpose: answers_health is a
# collaborator here, and the test substitutes a deterministic stub for it.
answers_health() {
local p=\$1 want
case \$p in
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
*) want='' ;;
esac
[ \"\$want\" = yes ]
}
read_active_port
echo
" 2>/dev/null
rm -f "$tmp"
}
# nginx points at slot B and both answer -> trust the upstream file.
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
# The regression: both healthy, nginx points at B, but slot A is an unrelated
# leftover replica. The upstream file is the only thing that knows which slot is
# live, so it must win.
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
# Upstream names a dead slot: fall back to a slot that actually answers, never to
# the dead port itself.
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
# rollback path has no target.
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
# No upstream file at all: pick a slot that answers.
got="$(run_read_active_port none no yes)"
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
got="$(run_read_active_port none yes no)"
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
# ── assert_port_free ─────────────────────────────────────────────────────────
# Runs against real loopback ports: 3999 is intentionally unused, so the check
# must report it free.
bash -c "
$assert_port_free_fn
assert_port_free 3999 candidate >/dev/null 2>&1
" || fail "a port with no listener must be reported as free"
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
# it genuinely is free, otherwise the assertion would be meaningless.
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
if bash -c "
$assert_port_free_fn
assert_port_free 3002 candidate >/dev/null 2>&1
"; then
fail "an occupied port must be rejected, but assert_port_free returned success"
fi
fi
echo 'Deploy port-selection tests passed'
+29 -198
View File
@@ -76,13 +76,6 @@ healthy() {
return 1
}
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
# een al draaiend proces dat nu of nooit antwoordt.
answers_health() {
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
}
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
# niet stilvalt op een upgrade.
@@ -92,156 +85,29 @@ detect_blue_green() {
return 0
}
# Welke poort is op dit moment ÉCHT live?
#
# Volgorde van vertrouwen:
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
#
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
# container die toevallig een andere release draaide.
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
read_active_port() {
local port="" pointed=""
if [ -r "$upstream_file" ]; then
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
fi
if [ -n "$port" ] && answers_health "$port"; then
printf '%s' "$port"
return 0
fi
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
# enige andere gezonde slot, anders is er niets om op te bouwen.
for candidate in "$slot_a_port" "$slot_b_port"; do
[ "$candidate" = "$port" ] && continue
if answers_health "$candidate"; then
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
printf '%s' "$candidate"
return 0
local live="" port="" result=""
local count=0
for port in "$slot_a_port" "$slot_b_port"; do
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
live="$live $port"
fi
done
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
# rollback-poging toch het vorige slot kan starten.
if [ -n "$port" ]; then
printf '%s' "$port"
for port in $live; do count=$((count + 1)); result="$port"; done
if [ "$count" -eq 1 ]; then
printf '%s' "$result"
return 0
fi
printf '%s' "$slot_a_port"
}
# Poort-bezetting controleren vóór het starten van de kandidaat.
#
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
# container op diezelfde poort beantwoordt. Dat levert een misleidende
# "expected release never became healthy" op in plaats van de echte oorzaak.
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
# hem vasthoudt.
assert_port_free() {
local port="$1" name="$2"
local holders=""
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
# hoeft te zien.
if type ss >/dev/null 2>&1; then
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
fi
[ -z "$holders" ] && return 0
echo "Port $port is already in use, cannot start candidate $name" >&2
printf '%s\n' "$holders" >&2
# Noem exact het container dat de poort vasthoudt.
#
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
# onschuldige containers begraven.
local squatter="squatter_pids"
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
if [ -n "$squatter_pids" ]; then
local pid cid owner=""
for pid in $squatter_pids; do
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
[ -n "$cid" ] || continue
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
done
fi
echo "" >&2
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
# naam die toevallig op een van deze poorten draait — meestal een
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
# dus niet geraakt.
case "$owner" in
*"/$name"*|*"/$slot_b_container"*)
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
*)
cat >&2 <<EOF
This port is held by a container that is not a blue/green slot, so the deploy
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
the other slot until cutover.
Remove the stray container and re-run the deploy:
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
If it comes back after a reboot, it is started by docker-compose.yml rather
than by this script; delete or disable that service.
EOF
;;
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
case "$port" in
"$slot_b_port") printf '%s' "$slot_b_port" ;;
*) printf '%s' "$slot_a_port" ;;
esac
return 1
}
# Ruim een compose-replica op die een blauwe/groene slot bezet.
#
# Een `docker compose up` — of de dagelijkse `scripts/docker-update.sh`, waarvan
# de CI-eigendomscontrole per slot wankelde — laat een replica met container_name
# `epicnext-cms` achter op poort 3002. Die draait nooit live: nginx wijst naar de
# poort van een slot-container die dit script zelf heeft gestart, en die staat per
# definitie aan de andere kant dan de kandidaat. Zonder deze opruimstap loopt elke
# release vast op een bezette poort totdat iemand de container met de hand
# verwijdert.
#
# Bewust smal, want een container van een ander deployment is niet van ons:
# - alleen een replica die uit precies deze checkout komt
# (com.docker.compose.project.config_files), niet een losse compose-project;
# - nooit een slot-container, want die beheert dit script zelf;
# - nooit de poort waar nginx naar wijst.
# Wat daarnaast nog op de doel-poort zit, laat assert_port_free() met zijn eigen
# foutmelding staan in plaats van stilzwijgend verdwijnen.
retire_compose_replicas() {
local live_port="$1" cid name="" config_files="" port=""
while read -r cid; do
[ -n "$cid" ] || continue
name="$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null | sed -n 's#^/##p' || true)"
case "$name" in ''|"$slot_a_container"|"$slot_b_container") continue ;; esac
config_files="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$cid" 2>/dev/null || true)"
[ "$config_files" = "$deploy_dir/docker-compose.yml" ] || continue
port="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$cid" 2>/dev/null | sed -n 's#^PORT=##p' | head -1 || true)"
[ -n "$port" ] && [ "$port" != "$live_port" ] || continue
echo "Removing compose replica $name on port $port: it squats a blue/green slot and is not the live release (nginx serves $live_port)"
docker rm -f "$name" || return 1
done < <(docker ps --filter "label=com.docker.compose.project.config_files=$deploy_dir/docker-compose.yml" --format '{{.ID}}')
return 0
}
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
@@ -287,7 +153,6 @@ switch_upstream() {
# gelden.
start_candidate() {
local port="$1" name="$2"
assert_port_free "$port" "$name"
(
set -a
# shellcheck disable=SC1091
@@ -318,7 +183,7 @@ finish() {
if [ "$cutover_started" -eq 0 ]; then
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
echo "Deployment failed before cutover; the live release was never stopped" >&2
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
docker logs "$new_container" --tail 50 >&2 || true
docker rm -f "$new_container" || true
fi
@@ -326,9 +191,9 @@ finish() {
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
if [ -n "$new_container" ]; then docker logs "$new_container" --tail 50 >&2 || true; fi
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
if [ -n "$new_container" ]; then docker rm -f "$new_container" || true; fi
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
if healthy "$old_port"; then
echo "Rollback verified on port $old_port"
@@ -395,27 +260,11 @@ if ! grep -qs '^DATABASE_URL=' .env; then
exit 1
fi
# De image krijgt het label van $sha, en `verify-deployed-release.mjs` controleert
# later alleen díe label. Zonder deze check bouwt een vuile werkboom dus een image
# die zegt release $sha te zijn terwijl er ongecommitte code in zit — precies het
# scenario "rebuilden levert geen nieuwe code". `docker-update.sh` deed dit al.
# `--untracked-files=normal` laat gitignored artefacten (.next, coverage,
# build-reports) buiten beschouwing; die worden toch niet meegebouwd.
if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
echo "Error: de werkboom is niet schoon, dus de image zou een verkeerd release-label krijgen." >&2
echo " Commit of stash de wijzigingen en draai opnieuw." >&2
echo " De live release is niet aangeraakt." >&2
git status --short >&2
exit 1
fi
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
echo "Building $image"
# No --network=host: BuildKit only grants it via --allow=network.host, and the
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current
# Read reports from the already-built image; do not start an extra application.
@@ -489,28 +338,15 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
exit 1
fi
# The application writes everything under storage/ as uid 33, but storage is a
# host bind so the image's own ownership is irrelevant. Any path that is not
# uid 33 makes the write fail with EACCES, and because most of these writes are
# inside a try/catch the failure is silent: the avatar cache just never fills
# (each avatar becomes a fresh live render) and the catalog export reports
# "delivery failed" while the emulator never receives the update. The old code
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
nitro-cleanup config-backups nitro-scale32-backups; do
target="$deploy_dir/storage/$owned_dir"
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
[ -d "$target" ] || continue
chown -R 33:33 "$target" 2>/dev/null || true
done
# The avatar/badge cache needs its leaf directories to exist before first use;
# the cache misses (and re-renders live) rather than erroring when they do not.
# The avatar/badge disk cache lives on the host bind and is written by uid 33
# inside the container. Root-owned directories make every cache write fail
# silently, which turns each avatar into a fresh live render.
for cache_dir in avatars badges; do
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
fi
done
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
if [ "$blue_green" -eq 1 ]; then
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
@@ -520,28 +356,23 @@ if [ "$blue_green" -eq 1 ]; then
docker rm -f "$new_container"
fi
# 2. Een compose-replica die ooit is achtergebleven zit hier nog op de
# doel-poort. Hij draait niet live en wordt dus opgeruimd, zodat de release
# niet op een bezette poort stukloopt.
retire_compose_replicas "$old_port"
# 3. Start de kandidaat ernaast. De live release draait ononderbroken door.
# 2. Start de kandidaat ernaast. De live release draait ononderbroken door.
candidate_attempted=1
start_candidate "$new_port" "$new_container"
# 4. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
# 3. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
# cutover de productie breken in plaats van ervoor.
healthy "$new_port"
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
# 4. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
cutover_started=1
switch_upstream "$new_port"
echo "Cut over to port $new_port; retiring port $old_port"
# 6. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
# 5. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
# waarop er geen enkele container draait.
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
docker stop "$old_container"
+1 -1
View File
@@ -39,6 +39,6 @@ pnpm exec playwright install chromium
export NEWS_E2E_IMAGE="$image"
export NEWS_E2E_RELEASE="$sha"
build_attempted=1
DOCKER_BUILDKIT=1 docker build --progress=plain \
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
+2 -94
View File
@@ -3,21 +3,15 @@
#
# Modes:
# (default) — post-deploy cleanup (safe, fast):
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
# least-recently-used entries. This cap is the actual bound.
# - Build cache older than 72h, capped at 4 GB max used space.
# - Unreferenced images older than 7 days (keeps rollback images around).
# - Stopped containers older than 24h.
# - Dangling images, which are always unreferenced.
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
# --force — emergency mode ("never let the disk max out"): drops everything
# with no age windows:
# - ALL unreferenced build cache,
# - ALL unreferenced images (no age grace),
# - ALL stopped containers.
#
# The default mode escalates to --force on its own when / drops below 8 GB free,
# so the bound holds even if this stops running on schedule.
#
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
# Idempotent; exits 0 when Docker is unavailable.
set -Eeuo pipefail
@@ -40,101 +34,15 @@ command -v docker >/dev/null 2>&1 || {
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true
# A hard ceiling on the root filesystem is what actually bounds the growth, so
# the emergency path is reached on disk pressure rather than only on a timer.
# The image/container passes stay age-gated: a rollback image and a stopped
# container are cheap to keep for a week and expensive to lose.
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
# 8 GB free is comfortable for a database plus a release swap.
if (( FREE_KB < 8 * 1024 * 1024 )); then
FORCE=1
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
"$(now)" "$FREE_KB" >>"$LOG_FILE"
fi
if (( FORCE )); then
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
docker image prune -af >>"$LOG_FILE" 2>&1 || true
docker container prune -f >>"$LOG_FILE" 2>&1 || true
else
# --max-used-space and --filter are mutually exclusive in buildx: passing
# both makes the cap a no-op and the cache grows without bound. The cap alone
# is the bound, and it evicts least-recently-used entries to get there.
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
fi
# Dangling images have no tag and no container, so nothing can reference them.
# They are what repeated local builds leave behind.
docker image prune -f >>"$LOG_FILE" 2>&1 || true
# ── Interrupted git gc leftovers ─────────────────────────────────
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
# nothing reclaims it: git only clears those on the next successful gc. One such
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
# than a day are considered, so a gc running right now is never touched.
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
while IFS= read -r -d '' tmp; do
size=$(du -h "$tmp" | cut -f1)
rm -f "$tmp"
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
fi
# ── Orphaned browser profiles ─────────────────────────────────────
# byparr launches a real Firefox per request, and each launch leaves a
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
# days on this host, ~1.7 GB/day.
#
# Deleting a profile out from under a running browser kills that job, so live
# ones are identified the only way that is reliable rather than by age: a
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
# pointing into the directory. Anything not referenced that way, and untouched
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
#
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
# age window that is safe for the leak is far too wide for the disk.
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
for container in ${BYPARR_CONTAINERS:-byparr}; do
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
removed=$(
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
set -u
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
base="${1:-/tmp}"
live_file=$(mktemp)
# Live profiles are the ones a running process still holds open.
for p in $(ps -eo pid= 2>/dev/null); do
ls -l "/proc/$p/fd" 2>/dev/null
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
count=0
for dir in "$base"/playwright_firefoxdev_profile-*; do
[ -d "$dir" ] || continue
# Never touch something a process is still using.
grep -Fxq "$dir" "$live_file" && continue
# A profile a browser is still writing to is not an orphan
# yet, even if the directory itself looks old.
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
continue
fi
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
done
rm -f "$live_file"
printf "%s" "$count"
' sh /tmp 2>/dev/null || printf '0'
)
if [[ "${removed:-0}" -gt 0 ]]; then
printf '[%s] removed %s orphaned browser profiles from %s\n' \
"$(now)" "$removed" "$container" >>"$LOG_FILE"
fi
done
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true
+1 -93
View File
@@ -1,13 +1,7 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
detectMemoryLimitMb,
heapLimitMb,
runtimeNodeOptions,
} from "./docker-start.mjs";
import { it } from "vitest";
it("imports runtime validation without starting the CMS", () => {
const result = spawnSync(
@@ -21,89 +15,3 @@ it("imports runtime validation without starting the CMS", () => {
);
assert.equal(result.status, 0, result.stderr);
});
describe("heap limit", () => {
it("leaves headroom for the memory V8 does not account for", () => {
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
});
it("clamps to a floor and a ceiling", () => {
// Too small to run a Next.js server at all: floor wins.
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
// A huge or absent limit must not turn into a 100 GB heap.
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
expect(heapLimitMb(Number.NaN)).toBe(8192);
expect(heapLimitMb(0)).toBe(8192);
});
});
describe("cgroup detection", () => {
const asReader = (contents) => (path) => {
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
return contents[path];
};
it("reads the cgroup v2 limit", () => {
expect(
detectMemoryLimitMb(
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
),
).toBe(2867);
});
it("falls back to cgroup v1 when v2 is absent", () => {
expect(
detectMemoryLimitMb(
asReader({
"/sys/fs/cgroup/memory.max": "",
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
}),
),
).toBe(4300);
});
it("treats an unlimited cgroup as no limit at all", () => {
// cgroup v1 reports "max"; a bare sentinel means the same thing.
expect(
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
).toBe(8192);
expect(
detectMemoryLimitMb(
asReader({
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
}),
),
).toBe(8192);
});
it("falls back when neither cgroup file is readable", () => {
expect(
detectMemoryLimitMb(() => {
throw new Error("ENOENT");
}),
).toBe(8192);
});
});
describe("NODE_OPTIONS", () => {
it("adds the cap when none is set", () => {
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
expect(runtimeNodeOptions(undefined, 2867)).toBe(
"--max-old-space-size=2867",
);
});
it("keeps unrelated options already present", () => {
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
"--no-warnings --max-old-space-size=2867",
);
});
it("never overrides an explicit operator choice", () => {
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
"--max-old-space-size=8192",
);
});
});
+1 -70
View File
@@ -1,70 +1,7 @@
// Fail before listening if an installation has no valid runtime configuration.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
/** Fraction of the container memory limit V8 is allowed to use for its heap.
* The rest has to cover native allocations the JS heap cannot account for:
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
* RSC rendering. */
const HEAP_FRACTION = 0.7;
const MIN_HEAP_MB = 512;
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
* for 4300 MB, and a backstop at or below that would silently turn the fraction
* into a fixed number and make the two limits disagree. This exists purely so a
* nonsensical cgroup reading cannot ask for an unbounded heap. */
const MAX_HEAP_MB = 8192;
export function heapLimitMb(cgroupLimitBytes) {
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
return MAX_HEAP_MB;
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
}
/**
* Read this container's memory ceiling from cgroup v2, falling back to v1.
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
* container on a 24 GB host lets the heap grow past the limit and the kernel
* OOM-kills the process mid-request — which is what produced the
* `next-build (v16)` kills in the host logs. A container that GCs before it
* reaches the ceiling degrades to a slower page instead of a killed process.
*/
export function detectMemoryLimitMb(readFile = readFileSync) {
const candidates = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
for (const path of candidates) {
let raw;
try {
raw = readFile(path, "utf8").trim();
} catch {
continue;
}
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
// sentinel of a very large number on some kernels.
if (raw === "max" || raw === "") continue;
const bytes = Number(raw);
if (!Number.isFinite(bytes) || bytes <= 0) continue;
// A host-sized "limit" means no cgroup ceiling was applied.
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
return heapLimitMb(bytes);
}
return heapLimitMb(Number.NaN);
}
export function runtimeNodeOptions(
existing = "",
heapMb = detectMemoryLimitMb(),
) {
const flag = `--max-old-space-size=${heapMb}`;
if (!existing.trim()) return flag;
// Respect an explicit operator override; only add the cap when absent.
if (existing.includes("--max-old-space-size")) return existing;
return `${existing} ${flag}`;
}
export function validateRuntime(settings) {
const invalid = [];
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
@@ -96,13 +33,7 @@ if (
) {
try {
validateRuntime(process.env);
const heapMb = detectMemoryLimitMb();
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
const child = spawn(process.execPath, ["server.js"], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nodeOptions },
});
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => child.kill(signal));
child.on("error", () => {
+4 -21
View File
@@ -58,27 +58,10 @@ trap 'exit 130' INT
trap 'exit 143' TERM
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
# This host belongs to CI: the blue/green deploy owns both host ports (3002 and
# 3003) and one of the two slot containers is always the live release. Compose
# may only run where CI does not.
#
# Checking epicnext-cms-app alone was not enough. After a cutover to the green
# slot the blue container is stopped, renamed and deleted, so the guard stopped
# firing while the host stayed CI-managed. `docker compose up` then recreated a
# replica named epicnext-cms on port 3002 — the blue slot, exactly where the next
# candidate has to start — and every later release failed on a busy port until
# someone removed that container by hand (see logs/docker-update.cron.log).
# Therefore: both slot containers count, and so does the nginx upstream, which is
# the only thing that still marks the host as blue/green when a slot is idle.
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
# An existing CI deployment is a different owner of the same host port.
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
fi
for slot_container in epicnext-cms-app epicnext-cms-green; do
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
fi
done
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
@@ -123,7 +106,7 @@ 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 --network=host --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
fi
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
+4 -4
View File
@@ -1,10 +1,9 @@
import "./load-env";
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
import { readFurniData } from "@/lib/services/furni-data";
const ICON_DIR = path.join(
@@ -192,10 +191,11 @@ async function main(): Promise<void> {
);
let nitroMissing = 0;
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
for (const cls of catalogClasses) {
if (!bundleExistsInDir(NITRO_DIR, cls)) nitroMissing++;
if (!nitroSet.has(`${cls}.nitro`)) nitroMissing++;
}
console.log(`[5] catalog items without a bundle on disk: ${nitroMissing}`);
console.log(`[5] catalog items without .nitro bundle: ${nitroMissing}`);
await db.$client.end();
process.exit(0);
-386
View File
@@ -1,386 +0,0 @@
import "./load-env";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
autoDetectInteraction,
nitroAnimationStatesCount,
} from "@/lib/furni/auto-interaction";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
/**
* Generate a plain SQL file that repairs every furniture row in `items_base`
* from the visual logic inside each `.hab` / `.nitro` bundle:
*
* width / length / stack_height ← logic.model.dimensions x/y/z
* allow_stack ← z > 0
* allow_sit / allow_lay / allow_walk ← furnidata flags (cansiton/canlayon/canstandon)
* interaction_modes_count ← real nitro animation states / mechanic
* interaction_type ← logicType + classname mechanic (only fills 'default')
*
* The dimensions are NOT in furnidata — they live in the bundle JSON at
* `logic.model.dimensions` (or top-level `dimensions` for official bundles).
* The interaction columns mirror `verifyAndFixInteractionModesCount`
* (src/lib/services/furni-import.ts:1341): real furnidata flags + .nitro states,
* and `interaction_type` is only touched when it is still `default`.
*
* Usage:
* pnpm exec tsx scripts/generate-stack-height-sql.ts [options]
*
* Options:
* --dir=<path> bundle directory (default: /var/www/Gamedata/bundled/furniture)
* --furnidata=<path> local FurnitureData.json (default: /var/www/Gamedata/config/FurnitureData.json)
* --out=<path> SQL output file (default: stack-heights.sql)
* --types=<list> items_base types (default: s,i → floor + wall furniture)
* --all emit every matched row instead of only changed rows
* --no-interaction dimensions/allow_stack only, skip interaction columns
*
* Nothing in the database is modified here; you run the generated file yourself.
*/
const args = new Map<string, string | true>();
for (const raw of process.argv.slice(2)) {
const eq = raw.indexOf("=");
if (eq === -1) args.set(raw.replace(/^--/, ""), true);
else args.set(raw.slice(2, eq), raw.slice(eq + 1));
}
const DIR = String(args.get("dir") ?? "/var/www/Gamedata/bundled/furniture");
const FURNIDATA = String(
args.get("furnidata") ?? "/var/www/Gamedata/config/FurnitureData.json",
);
const OUT = String(args.get("out") ?? "stack-heights.sql");
const TYPES = String(args.get("types") ?? "s,i")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const EMIT_ALL = args.has("all");
const WITH_INTERACTION = !args.has("no-interaction");
const EXTENSIONS = [".hab", ".nitro"] as const;
interface Dims {
x: number;
y: number;
z: number;
}
interface Flags {
cansiton: boolean;
canlayon: boolean;
canstandon: boolean;
}
interface BundleInfo {
dims: Dims | null;
animationStates: number | undefined;
logicType: string | undefined;
}
interface Row {
item_name: string;
public_name: string;
width: number;
length: number;
stack_height: number;
allow_stack: number | string;
allow_sit: number | string;
allow_lay: number | string;
allow_walk: number | string;
interaction_type: string;
interaction_modes_count: number;
}
/** Escape a value for a single-quoted MySQL string literal. */
function q(value: string): string {
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
}
/** `enum('0','1')` values must be quoted — an unquoted 0 is read as index 0 (''). */
function qbit(value: boolean): string {
return value ? "'1'" : "'0'";
}
function isTruthyBit(value: number | string): boolean {
return Number(value) === 1;
}
/** Pull dimensions out of a parsed bundle JSON, tolerating both layouts. */
function extractDims(json: unknown): Dims | null {
const root = json as Record<string, unknown> | null;
if (!root) return null;
const logic = root.logic as Record<string, unknown> | undefined;
const model = logic?.model as Record<string, unknown> | undefined;
const candidate =
(model?.dimensions as Dims | undefined) ??
(logic?.dimensions as Dims | undefined) ??
(root.dimensions as Dims | undefined);
if (!candidate) return null;
const x = Number(candidate.x);
const y = Number(candidate.y);
const z = Number(candidate.z);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
return { x, y, z };
}
/** Parse a bundle once and cache everything we need from it. */
function readBundleInfo(path: string): BundleInfo {
try {
const json = parseNitroBundle(readFileSync(path)).json as Record<
string,
unknown
>;
return {
dims: extractDims(json),
animationStates: nitroAnimationStatesCount(json),
logicType:
typeof json.logicType === "string" ? json.logicType : undefined,
};
} catch {
return { dims: null, animationStates: undefined, logicType: undefined };
}
}
/**
* Read the local FurnitureData.json into a classname → flags map. Duplicate
* classnames are merged so a true flag in any entry wins (same rule as the
* app's `lookupRealFurniFlags`).
*/
function loadFurniFlags(path: string): Map<string, Flags> {
const map = new Map<string, Flags>();
if (!existsSync(path)) return map;
try {
const json = JSON.parse(readFileSync(path, "utf-8")) as Record<
string,
{ furnitype?: Array<Record<string, unknown>> }
>;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const item of json[section]?.furnitype ?? []) {
const cn = item.classname;
if (typeof cn !== "string" || !cn) continue;
const candidate: Flags = {
cansiton: !!item.cansiton,
canlayon: !!item.canlayon,
canstandon: !!item.canstandon,
};
const existing = map.get(cn);
if (!existing) {
map.set(cn, candidate);
continue;
}
existing.cansiton = existing.cansiton || candidate.cansiton;
existing.canlayon = existing.canlayon || candidate.canlayon;
existing.canstandon = existing.canstandon || candidate.canstandon;
}
}
} catch {
// unreadable furnidata → interaction columns are skipped
}
return map;
}
async function main(): Promise<void> {
const t0 = Date.now();
const dir = resolve(DIR);
const entries = new Set(readdirSync(dir));
const infoCache = new Map<string, BundleInfo>();
const flagsMap = WITH_INTERACTION
? loadFurniFlags(resolve(FURNIDATA))
: new Map<string, Flags>();
const [rows] = (await db.execute(
sql`SELECT item_name, public_name, width, length, stack_height, allow_stack,
allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count
FROM items_base
WHERE type IN (${sql.join(
TYPES.map((t) => sql`${t}`),
sql`, `,
)})`,
)) as unknown as [Row[], unknown];
console.log(
`[stack-sql] ${rows.length} furniture rows | ${entries.size} bundles | ${flagsMap.size} furnidata flags`,
);
const updates: string[] = [];
let matched = 0;
let changed = 0;
let resolvedBase = 0;
const counts = {
dims: 0,
allowFlags: 0,
modes: 0,
interactionType: 0,
};
const missing: string[] = [];
const unparsed: string[] = [];
for (const row of rows) {
const name = row.item_name;
const base = name.includes("*") ? name.slice(0, name.indexOf("*")) : name;
const variants = [name, name.trim(), base, base.trim()];
let chosen: string | null = null;
for (const variant of variants) {
for (const ext of EXTENSIONS) {
const candidate = `${variant}${ext}`;
if (entries.has(candidate)) {
chosen = candidate;
break;
}
}
if (chosen) break;
}
if (!chosen) {
missing.push(name);
continue;
}
if (!chosen.startsWith(`${name}.`)) resolvedBase++;
let info = infoCache.get(chosen);
if (!info) {
info = readBundleInfo(resolve(dir, chosen));
infoCache.set(chosen, info);
}
if (!info.dims) {
unparsed.push(`${name} (${chosen})`);
continue;
}
matched++;
const width = Math.trunc(info.dims.x);
const length = Math.trunc(info.dims.y);
const stackHeight = Number(info.dims.z.toFixed(2));
const allowStack = info.dims.z > 0;
const setParts: string[] = [];
const dimsChanged =
row.width !== width ||
row.length !== length ||
Number(row.stack_height) !== stackHeight ||
isTruthyBit(row.allow_stack) !== allowStack;
if (dimsChanged) counts.dims++;
setParts.push(
`width=${width}`,
`length=${length}`,
`stack_height=${stackHeight}`,
`allow_stack=${qbit(allowStack)}`,
);
// Interaction columns — only when the classname exists in furnidata
// (mirrors the app sweep: authoritative flags, never keyword guesses).
const flags =
flagsMap.get(name) ??
flagsMap.get(base) ??
flagsMap.get(name.trim()) ??
flagsMap.get(base.trim());
if (WITH_INTERACTION && flags) {
const auto = autoDetectInteraction(name, row.public_name || undefined, {
cansiton: flags.cansiton,
canlayon: flags.canlayon,
canstandon: flags.canstandon,
hasActionData: true,
animationStates: info.animationStates,
logicType: info.logicType,
});
if (
Number(row.allow_sit) !== 2 &&
isTruthyBit(row.allow_sit) !== auto.canSit
) {
setParts.push(`allow_sit=${qbit(auto.canSit)}`);
counts.allowFlags++;
}
if (
Number(row.allow_lay) !== 2 &&
isTruthyBit(row.allow_lay) !== auto.canLay
) {
setParts.push(`allow_lay=${qbit(auto.canLay)}`);
counts.allowFlags++;
}
if (
Number(row.allow_walk) !== 2 &&
isTruthyBit(row.allow_walk) !== auto.canStand
) {
setParts.push(`allow_walk=${qbit(auto.canStand)}`);
counts.allowFlags++;
}
if (
Number(row.interaction_modes_count ?? 0) !== auto.interactionModesCount
) {
setParts.push(`interaction_modes_count=${auto.interactionModesCount}`);
counts.modes++;
}
if (
(row.interaction_type === "default" || !row.interaction_type) &&
auto.interactionType !== "default"
) {
setParts.push(`interaction_type=${q(auto.interactionType)}`);
counts.interactionType++;
}
}
const isChanged =
dimsChanged ||
setParts.some(
(p) =>
!p.startsWith("width=") &&
!p.startsWith("length=") &&
!p.startsWith("stack_height=") &&
!p.startsWith("allow_stack="),
);
if (isChanged) changed++;
if (!EMIT_ALL && !isChanged) continue;
updates.push(
`UPDATE items_base SET ${setParts.join(", ")} WHERE item_name=${q(name)} AND type IN (${TYPES.map(q).join(", ")});`,
);
}
const header = [
"-- Auto-generated by scripts/generate-stack-height-sql.ts",
`-- Source bundles: ${dir}`,
`-- Furnidata: ${WITH_INTERACTION ? resolve(FURNIDATA) : "(disabled)"}`,
`-- Generated: ${new Date().toISOString()}`,
`-- Furniture rows: ${rows.length} | matched: ${matched} | changed: ${changed} | missing bundle: ${missing.length} | unparsable: ${unparsed.length}`,
`-- Changes: dimensions ${counts.dims} | allow_sit/lay/walk ${counts.allowFlags} | modes_count ${counts.modes} | interaction_type ${counts.interactionType}`,
`-- Mode: ${EMIT_ALL ? "all matched rows" : "changed rows only"}`,
"",
"START TRANSACTION;",
"",
].join("\n");
const footer = "\n\nCOMMIT;\n";
writeFileSync(
resolve(OUT),
`${header}${updates.join("\n")}${footer}`,
"utf-8",
);
console.log(`[stack-sql] matched ${matched}, changed ${changed}`);
console.log(
`[stack-sql] changes -> dims ${counts.dims}, allow flags ${counts.allowFlags}, modes ${counts.modes}, interaction_type ${counts.interactionType}`,
);
if (resolvedBase > 0)
console.log(`[stack-sql] resolved via base classname: ${resolvedBase}`);
console.log(`[stack-sql] updates written: ${updates.length}`);
if (missing.length) {
console.log(
`[stack-sql] no bundle (${missing.length}): ${missing.slice(0, 20).join(", ")}${missing.length > 20 ? ", …" : ""}`,
);
}
if (unparsed.length) {
console.log(
`[stack-sql] unparsable (${unparsed.length}): ${unparsed.slice(0, 20).join(", ")}${unparsed.length > 20 ? ", …" : ""}`,
);
}
console.log(`[stack-sql] wrote ${resolve(OUT)} in ${Date.now() - t0}ms`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+5 -71
View File
@@ -1,17 +1,9 @@
// Must stay the first import. ESM evaluates a module's imports in source
// order, and `../src/features/operations/worker` reaches `@/env`, which parses
// process.env at import time. With this import further down the tree, load-env
// ran *after* the schema validation had already thrown on a missing
// DATABASE_URL, so the worker could only ever start from an environment that
// already exported the config — which is why `pnpm jobs:worker` died
// immediately and nothing supervised it.
import { drainOperationEffects } from "../src/features/operations/worker";
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
import "./load-env";
import * as nodeFs from "node:fs";
import * as nodePath from "node:path";
import { Cron } from "croner";
import { lt, sql } from "drizzle-orm";
import { env } from "../src/env";
import { drainOperationEffects } from "../src/features/operations/worker";
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis";
@@ -26,7 +18,6 @@ import {
diskLevel,
parseDfOutput,
} from "../src/lib/services/disk-usage";
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
import { publishDueArticles } from "../src/lib/services/news-scheduler";
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
import { rcon } from "../src/lib/services/rcon";
@@ -170,69 +161,13 @@ async function checkDiskUsage(): Promise<void> {
}
}
/**
* Resolve the JAR to back up. `EMULATOR_JAR_PATH` may point at the file itself
* or at a directory of release JARs, because the emulator's own unit file
* launches `ls -t Polaris-*-jar-with-dependencies.jar` — a path pinned to one
* release filename goes stale on the next emulator upgrade, and a stale path
* fails as a bare ENOENT from copyFile that gives no hint what is wrong. A
* directory (or a path with a `*`) resolves to the most recently modified JAR,
* matching how the emulator actually picks its build.
*/
export function resolveEmulatorJar(
configuredPath: string,
fs: typeof import("node:fs") = nodeFs,
{ resolve }: typeof import("node:path") = nodePath,
): string | null {
const { existsSync, readdirSync, statSync } = fs;
if (configuredPath.includes("*")) {
const dir = configuredPath.slice(0, configuredPath.lastIndexOf("/") + 1);
const pattern = configuredPath.slice(dir.length);
if (!existsSync(dir)) return null;
return (
readdirSync(dir)
.filter((name: string) => name.startsWith(pattern.split("*")[0] ?? ""))
.map((name: string) => resolve(dir, name))
.filter((path: string) => existsSync(path))
.sort(
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
)[0] ?? null
);
}
if (existsSync(configuredPath) && statSync(configuredPath).isFile())
return configuredPath;
// A directory: take the newest JAR in it.
if (existsSync(configuredPath) && statSync(configuredPath).isDirectory()) {
return (
readdirSync(configuredPath)
.filter((name: string) => name.endsWith(".jar"))
.map((name: string) => resolve(configuredPath, name))
.sort(
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
)[0] ?? null
);
}
return null;
}
async function backupEmulatorJar(): Promise<void> {
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
const fs = await import("node:fs");
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs;
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
await import("node:fs");
const { resolve } = await import("node:path");
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
if (!jarPath) {
// Configured but unusable: say so once, loudly, instead of every night
// logging an opaque copyFile ENOENT that reads like a permissions bug.
logger.error(
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
);
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const backupFile = resolve(
env.EMULATOR_BACKUP_DIR,
@@ -244,11 +179,10 @@ async function backupEmulatorJar(): Promise<void> {
}
try {
copyFileSync(jarPath, backupFile);
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
logger.info("Backed up emulator JAR", {
module: "jobs",
backupFile,
source: jarPath,
});
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
-303
View File
@@ -1,303 +0,0 @@
#!/usr/bin/env tsx
/**
* Rename on-disk furniture bundles from `.nitro` to `.hab`.
*
* Imports have written `<classname>.hab` since the bundle-extension switch, but
* everything already on disk kept its old name. That matters at runtime: the
* client asks for `.hab`, so a catalogue whose assets are still `.nitro` shows
* furniture that renders as nothing. This script closes that gap.
*
* It is deliberately conservative:
* - refuses to run without `--dry-run` or `--yes`;
* - never overwrites an existing `.hab` — a conflict is reported, not resolved;
* - never deletes anything, so a half-finished run is recoverable by hand;
* - idempotent: a second run over migrated dirs is a no-op.
*
* Run it in a maintenance window. The rename is per-file, so a client request
* for a given `.nitro` 404s from the moment that file is renamed until the
* client asks for `.hab`.
*
* Usage:
* pnpm tsx scripts/migrate-nitro-to-hab.ts --dry-run
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --dir /var/www/extra/bundles
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --skip-generic
*/
import "./load-env";
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { db } from "@/lib/db";
import {
getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs";
import { getPublicAssetRoot } from "@/lib/services/public-asset-root";
import { siteSettings } from "@/lib/services/site-settings";
import { getRuntimePath } from "@/lib/utils/runtime-path";
const LEGACY_EXT = ".nitro";
const TARGET_EXT = ".hab";
interface Args {
dryRun: boolean;
yes: boolean;
skipGeneric: boolean;
dirs: string[];
}
function parseArgs(argv: string[]): Args {
const dirs: string[] = [];
let dryRun = false;
let yes = false;
let skipGeneric = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--dry-run") dryRun = true;
else if (arg === "--yes" || arg === "-y") yes = true;
else if (arg === "--skip-generic") skipGeneric = true;
else if (arg === "--dir") {
const value = argv[++i];
if (!value) throw Error("--dir needs a path");
dirs.push(path.resolve(value));
} else throw Error(`Unknown argument: ${arg}`);
}
return { dryRun, yes, skipGeneric, dirs };
}
/**
* Where the app keeps bundles. Mirrors the resolution in figure-import.ts /
* effect-import.ts / pet-import.ts so the script follows the same site settings
* the running instance uses. Settings are best-effort: a database that is down
* must not stop an operator from migrating files, so failures fall back to the
* on-disk defaults rather than aborting.
*/
async function resolveDirs(skipGeneric: boolean): Promise<string[]> {
const found: string[] = [];
const push = (dir: string | null | undefined) => {
if (dir?.trim()) found.push(path.resolve(dir.trim()));
};
// Furniture: primary + every configured mirror (gamedata, nitro-files).
try {
const targets = await getFurniAssetWriteTargets();
push(targets.nitroDir);
for (const mirror of targets.mirrorDirs) push(mirror.nitroDir);
} catch (error) {
console.warn(
` ! could not read furniture asset settings (${(error as Error).message}); using defaults`,
);
push(
getRuntimePath(process.cwd(), "public/nitro-assets/bundled/furniture"),
);
push("/var/www/Gamedata/bundled/furniture");
}
let gamedataRoot = "";
try {
gamedataRoot = await getGamedataRoot();
} catch {
/* defaults below cover it */
}
for (const type of ["figure", "effect"]) {
let configured = "";
try {
configured = (
(await siteSettings.get(`${type}_nitro_dir`, "")) ?? ""
).trim();
} catch {
/* fall through to defaults */
}
if (configured) push(configured);
else if (gamedataRoot)
push(getRuntimePath(gamedataRoot, `bundled/${type}`));
else
push(
getRuntimePath(
getPublicAssetRoot(),
`public/nitro-assets/bundled/${type}`,
),
);
}
// Pets: the CMS dir is `pet`, this deployment's gamedata dir is `pets`.
// Both spellings are listed so neither is silently skipped.
push(getRuntimePath(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"));
if (gamedataRoot) {
push(getRuntimePath(gamedataRoot, "bundled/pet"));
push(getRuntimePath(gamedataRoot, "bundled/pets"));
}
// `generic` holds the stock client UI bundles (selection_arrow, room,
// tile_cursor, place_holder…) that this CMS never imported. They are included
// by default because the renderer's `generic.asset.url` template resolves to
// `.hab` too — leaving them behind breaks the room view, not just furniture.
if (!skipGeneric && gamedataRoot) {
push(getRuntimePath(gamedataRoot, "bundled/generic"));
}
return [...new Set(found)];
}
interface DirResult {
dir: string;
renamed: number;
alreadyHab: number;
conflicts: Array<{ from: string; to: string }>;
errors: Array<{ file: string; message: string }>;
}
async function migrateDir(dir: string, dryRun: boolean): Promise<DirResult> {
const result: DirResult = {
dir,
renamed: 0,
alreadyHab: 0,
conflicts: [],
errors: [],
};
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch (error) {
result.errors.push({ file: dir, message: (error as Error).message });
return result;
}
for (const entry of entries) {
if (!entry.toLowerCase().endsWith(LEGACY_EXT)) continue;
const from = path.join(dir, entry);
const to = path.join(
dir,
`${entry.slice(0, -LEGACY_EXT.length)}${TARGET_EXT}`,
);
// Never clobber. A pre-existing `.hab` is a live bundle the client is
// already serving; leaving the `.nitro` alone is the only safe answer.
if (existsSync(to)) {
result.conflicts.push({
from: path.basename(from),
to: path.basename(to),
});
continue;
}
if (dryRun) {
result.renamed++;
continue;
}
try {
await fs.rename(from, to);
result.renamed++;
} catch (error) {
result.errors.push({ file: entry, message: (error as Error).message });
}
}
// Report the target state too, so a run confirms the end condition rather
// than just the work it did.
for (const entry of await fs.readdir(dir)) {
if (entry.toLowerCase().endsWith(TARGET_EXT)) result.alreadyHab++;
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.dryRun && !args.yes) {
console.error(
"Nothing to do: pass --dry-run to preview, or --yes to rename for real.",
);
process.exitCode = 2;
}
const dirs = [
...new Set([...args.dirs, ...(await resolveDirs(args.skipGeneric))]),
];
const existing = dirs.filter((dir) => existsSync(dir));
console.log(
args.dryRun
? "DRY RUN — no files will be touched."
: "Renaming .nitro bundles to .hab.",
);
if (!args.dryRun) {
console.log(
"Run this in a maintenance window: the client 404s on each file between its rename and its switch to .hab.",
);
}
console.log(`\nDirectories (${existing.length} of ${dirs.length} exist):`);
for (const dir of existing) console.log(` ${dir}`);
const missing = dirs.filter((dir) => !existsSync(dir));
if (missing.length) {
console.log(`\nNot present, skipped:`);
for (const dir of missing) console.log(` ${dir}`);
}
if (!existing.length) {
console.log("\nNo bundle directories found — nothing to migrate.");
return;
}
console.log("");
const results: DirResult[] = [];
for (const dir of existing) {
results.push(await migrateDir(dir, args.dryRun));
}
let totalRenamed = 0;
let totalHab = 0;
let totalConflicts = 0;
let totalErrors = 0;
for (const result of results) {
totalRenamed += result.renamed;
totalHab += result.alreadyHab;
totalConflicts += result.conflicts.length;
totalErrors += result.errors.length;
console.log(
`${result.dir}\n` +
` ${args.dryRun ? "would rename" : "renamed"}: ${result.renamed}\n` +
` .hab present: ${result.alreadyHab}`,
);
for (const conflict of result.conflicts) {
console.log(
` CONFLICT: ${conflict.from} — ${conflict.to} already exists`,
);
}
for (const error of result.errors) {
console.log(` ERROR: ${error.file} — ${error.message}`);
}
}
console.log(
`\n${args.dryRun ? "Would rename" : "Renamed"} ${totalRenamed} file(s). ` +
`${totalHab} .hab bundle(s) present afterwards.`,
);
if (totalConflicts) {
console.log(
`\n${totalConflicts} conflict(s): a .hab with that name already exists. ` +
"The .nitro was left in place. Resolve these by hand — the client can only load one of the two.",
);
}
if (totalErrors)
console.log(`\n${totalErrors} error(s); re-run once they are fixed.`);
// Non-zero on a partial migration so a wrapper cannot report success.
if (totalConflicts || totalErrors) process.exitCode = 1;
}
// The settings lookups open a mysql2 pool, which keeps the event loop alive —
// the script prints its whole report and then sits there burning a timeout
// instead of exiting. Closing the pool is not enough on its own here, so the
// exit is explicit, matching scripts/furni-diagnose-now.ts.
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.then(async () => {
await db.$client.end().catch(() => {});
process.exit(process.exitCode ?? 0);
});
-9
View File
@@ -101,15 +101,6 @@ fi
if [[ ! -d /var/log/nginx ]]; then
install -d -o root -g adm -m 750 /var/log/nginx
fi
# nginx-cms.conf sets `root /var/www/html` so that disk-backed locations
# (favicon.ico) resolve somewhere the www-data worker can actually traverse.
# The previous implicit root was /etc/nginx/html, which sits behind /etc/nginx
# (0750 root:root): the worker got EACCES on every stat, and nginx logs a
# failed stat at crit, so each crawler probe wrote a crit line.
if [[ ! -d /var/www/html ]]; then
install -d -o root -g root -m 755 /var/www/html
echo "+ created /var/www/html (document root)"
fi
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
[[ -f "$f" ]] || touch "$f"
done
+13 -62
View File
@@ -79,27 +79,6 @@ function filesFrom(values) {
return values.map(normalizeAsset);
}
/**
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
* a real file has to be separated from its id. An id is rejected by
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
* suffix), which makes it a usable filter — but only for ids. A malformed
* *path* must still fail loudly rather than be silently dropped, or a broken
* manifest would quietly under-report a route's real weight.
*/
function assetFilesFromChunkList(values) {
if (!Array.isArray(values))
throw new Error("Unsupported JavaScript chunk list.");
const files = [];
for (const value of values) {
if (typeof value !== "string")
throw new Error("Non-string JavaScript asset.");
if (/^\d+$/.test(value)) continue;
files.push(normalizeAsset(value));
}
return files;
}
export function measureRoute({
budget,
appPath,
@@ -107,44 +86,18 @@ export function measureRoute({
clientManifest,
readAsset,
}) {
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
// not — it only records chunks per client module — so after the build moved
// to webpack (3d828a61) every route reported "unavailable" and the report
// silently stopped measuring anything. Fall back to the same source Next's
// own `static-routes-info` uses for webpack builds.
const entries = clientManifest?.entryJSFiles;
const webpackModules = clientManifest?.clientModules;
let filesBySource;
let webpackLayout = false;
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
filesBySource = Object.entries(entries);
} else if (webpackModules && typeof webpackModules === "object") {
webpackLayout = true;
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
filesBySource = [];
for (const node of Object.values(webpackModules)) {
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
// One shared origin label instead of the module path: the per-chunk
// `sources` list is written into report.json, and webpack records
// absolute node_modules paths for every client module on the route.
filesBySource.push(["client-module", node.chunks]);
}
if (filesBySource.length === 0)
throw new Error(
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
);
} else {
if (!entries || typeof entries !== "object" || Array.isArray(entries))
throw new Error(
"entryJSFiles is unavailable; this manifest layout is not supported.",
);
}
const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
const bootstrap = filesFrom(
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
);
@@ -157,9 +110,8 @@ export function measureRoute({
origins.set(file, sources);
};
for (const file of bootstrap) add(file, "bootstrap");
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom;
for (const [entry, values] of filesBySource)
for (const file of readChunkList(values)) add(file, entry);
for (const [entry, values] of Object.entries(entries))
for (const file of filesFrom(values)) add(file, entry);
const size = (file, sources) => {
const bytes = readAsset(file);
return {
@@ -296,13 +248,12 @@ export function collectReport(nextDir, config, metadata = {}) {
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
nodeVersion: process.version,
zlibVersion: process.versions.zlib,
manifestFormat:
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
manifestFormat: "Next App Router client-reference entryJSFiles",
definition:
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus every client chunk this route's client-reference manifest lists, including boundary/loading entries. Turbopack exposes these as entryJSFiles; webpack exposes them only through clientModules[*].chunks, so the same envelope is derived from whichever the build emitted. This is emitted file size, not measured browser traffic or a load-time benchmark.",
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
excluded:
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from the manifest's chunk lists; legacy nomodule polyfills are reported separately.",
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
routes,
};
}
-129
View File
@@ -216,135 +216,6 @@ describe("route JS measurement", () => {
);
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
});
// The regression: after the build moved to webpack (3d828a61) the manifest
// has no entryJSFiles, only clientModules[*].chunks. Every route then
// reported "unavailable" and the report measured nothing at all while still
// exiting zero, so the budgets silently stopped being enforced.
describe("webpack manifests without entryJSFiles", () => {
const webpackManifest = {
clientModules: {
"[project]/src/components/header.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"6726",
"static/chunks/header-entry.js?dpl=abc",
],
},
"[project]/src/app/(site)/news/page.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"7777",
"/_next/static/chunks/page.js?dpl=abc",
],
},
// Async-only modules are recorded with an empty chunk list.
"[project]/src/components/lazy.tsx": { chunks: [] },
},
};
const webpackFiles = {
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
// bootstrap assets must exist or the read fails.
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
"static/chunks/header-entry.js": Buffer.from("header"),
"static/chunks/page.js": Buffer.from("page"),
"static/chunks/polyfill.js": Buffer.from("legacy"),
};
const measure = () =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: webpackManifest,
readAsset: (file) => webpackFiles[file],
});
it("derives the envelope from clientModules and ignores chunk ids", () => {
const row = measure();
expect(row.status).toBe("measured");
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
// are not assets and must not throw or be counted.
expect(row.initial.chunkCount).toBe(5);
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
"static/chunks/4269-shared.js",
"static/chunks/header-entry.js",
"static/chunks/page.js",
"static/chunks/runtime.js",
"static/chunks/shared.js",
]);
// Same bytes as the Turbopack fixture would produce for these files.
expect(row.initial.rawBytes).toBe(
Object.values(webpackFiles)
.filter((b) => !b.includes("legacy"))
.reduce((n, b) => n + b.length, 0),
);
});
it("counts a chunk reached by several client modules only once", () => {
const shared = measure().initial.chunks.find(
(f) => f.path === "static/chunks/4269-shared.js",
);
expect(shared).toBeDefined();
expect(measure().initial.chunkCount).toBe(5);
});
it("does not leak absolute module paths into the report", () => {
const sources = new Set(
measure().initial.chunks.flatMap((chunk) => chunk.sources),
);
// Only the two known origin labels; no node_modules path may appear.
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
});
it("still fails rather than under-reporting a malformed chunk path", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
clientModules: {
x: { chunks: ["static/chunks/../../etc/passwd"] },
},
},
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Unsupported JavaScript asset");
});
it("reports unavailable when webpack recorded no chunks at all", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: { clientModules: { x: { chunks: [] } } },
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Neither entryJSFiles nor clientModules");
});
it("prefers entryJSFiles when a manifest carries both", () => {
// A future Next version could emit both; the explicit list wins
// because it is per-segment and therefore the tighter envelope.
const row = measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
...webpackManifest,
entryJSFiles: {
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
},
},
readAsset: (file) => webpackFiles[file],
});
expect(row.initial.chunkCount).toBe(3);
});
});
it("does not deduplicate shared files across independent cold route totals", () => {
const row = measureRoute({
budget,
+3 -28
View File
@@ -1,35 +1,10 @@
#!/usr/bin/env bash
# Setup cron jobs for maintenance
#
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
# script that pipes one job at a time silently drops every other scheduled job.
# Entries are matched by their command, so re-running this is idempotent.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Adds one schedule line to the crontab unless its command is already present.
add_job() {
local schedule="$1" command
command="${schedule##* }"
local current
current="$(crontab -l 2>/dev/null || true)"
if grep -Fq "$command" <<<"$current"; then
echo "Already scheduled: $command"
return
fi
if [[ -z "$current" ]]; then
printf '%s\n' "$schedule" | crontab -
else
printf '%s\n%s\n' "$current" "$schedule" | crontab -
fi
echo "Scheduled: $schedule"
}
# Run backup every day at 03:00
add_job "0 3 * * * $SCRIPT_DIR/backup.sh"
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly
# run would let ~12 GB accumulate before anything reclaimed it.
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
# Run prune every Sunday at 04:00
echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
echo "Cron jobs configured."
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env bash
# Voer een zwaar commando uit onder een harde geheugenplafond.
#
# Waarom dit bestaat:
# De host draait met `vm.overcommit_memory=0` en ZONDER swap. Vraagt een
# proces meer geheugen dan er vrij is, dan geeft de kernel niets weg en
# roept hij meteen de OOM-killer aan. Die kiest zijn slachtoffer over de
# héle machine, niet alleen in het schuldige proces — dus een build kan de
# database, nginx of de live release meenemen.
#
# `next build` op Turbopack groeit op dit 329-route app voorbij 12GB RSS
# en is ook met 4GB swap nog steeds dood. Zie commit 3d828a61.
#
# Wat dit doet:
# Het commando komt in zijn eigen cgroup met `MemoryMax`. Als het door die
# grens heen groeit krijgt alleen die cgroup een OOM-signaal: het commando
# zelf sterft, de rest van de machine leeft. Dat is precies het gedrag dat
# je wilt — de build faalt, de site blijft staan.
#
# Met `--max-old-space-size` lukt dat niet. Die limiet zit op de V8-heap en
# Turbopack-geheugen is native Rust-geheugen; met een 2GB cap piekte de RSS
# alsnog op 8GB. Zie het commentaar in de Dockerfile.
#
# Backends:
#
# systemd (cgroup MemoryMax)
# Meet RSS over de héle procesboom. Dit is de echte garantie en wordt
# overal gebruikt waar systemd beschikbaar is (de host waar deze
# CMS draait). De RSS-plafonds in package.json zijn hierop gekozen:
# `next build` piekte op 6,5GB, dus 10GB laat ruimte over terwijl er
# 6GB basislast naast blijft passen binnen de 23,5GB van deze machine.
#
# ulimit -v (per proces, virtuele adresruimte)
# Alleen als expliciet gevraagd. Meet virtuele adresruimte, NIET RSS, en
# kan de boom helemaal niet begrenzen: elke worker krijgt z'n eigen
# limiet. Op moderne V8 is het bovendien een vergiftigde gift: `-v 10g`
# laat V8 de heaplimiet terugbrengen naar 2,25GB (webpack sterft met
# std::bad_alloc), en `-v 20g` laat de v8-wasm-memory-toewijzing falen
# tijdens `next build`. Zet CMS_MEMORY_CAP_VIRTUAL ruim boven de fysieke
# RAM als je het echt wilt gebruiken.
#
# Geen van beide -> weigeren. Stil onbegrensd doorlopen zou precies de
# valse geruststelling zijn waar 3d828a61 voor waarschuwt. Omgevingen
# zonder systemd (de Docker-build, de GitLab-runner) kiezen daarom
# expliciet voor CMS_MEMORY_CAP_BACKEND=none — met een waarschuwing,
# en met als rechtvaardiging dat die builds al begrensd zijn door
# `next build --webpack` + `--max-old-space-size` en in hun eigen
# geïsoleerde container draaien, niet op de host.
#
# Markering:
# Elke backend zet `CMS_MEMORY_CAPPED=1` voordat het commando start.
# `next.config.ts` weigert een productie-build zonder die markering, zodat
# een handmatig `npx next build` (of een IDE/agent die de build zelf
# start) niet meer onbeperkt geheugen kan vragen en de hele host mee
# neemt. `CMS_MEMORY_CAP_BACKEND=none` telt mee: die omgevingen draaien
# al in een eigen, geïsoleerde container.
#
# Gebruik: bash scripts/with-memory-cap.sh 10g <command...>
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
set -Eeuo pipefail
usage() {
echo "gebruik: $0 <plafond, bv. 10g> <command...>" >&2
exit 64
}
[ "$#" -ge 2 ] || usage
cap="$1"
shift
# Zet 10g / 512m / 2G / 1234567 om in bytes. Alleen bytes gaan naar
# systemd: MemoryMax accepteert `10G` maar weigert `10g`, en die
# hoofdletterval is te makkelijk om per ongeluk te treffen.
to_bytes() {
local value="$1" number suffix
if [[ "$value" =~ ^([0-9]+)([kKmMgGtT]?)$ ]]; then
number="${BASH_REMATCH[1]}"
suffix="${BASH_REMATCH[2]}"
else
echo "onbekend plafond-formaat: $value" >&2
return 1
fi
case "$suffix" in
k | K) echo $((number * 1024)) ;;
m | M) echo $((number * 1024 * 1024)) ;;
g | G) echo $((number * 1024 * 1024 * 1024)) ;;
t | T) echo $((number * 1024 * 1024 * 1024 * 1024)) ;;
*) echo "$number" ;;
esac
}
bytes="$(to_bytes "$cap")" || exit 64
kilobytes=$((bytes / 1024))
# De virtuele waarde voor ulimit -v. Bewust los van `cap`: zie de toelichting
# hierboven, 10g -v breekt de webpack-build.
virtual_bytes="$(to_bytes "${CMS_MEMORY_CAP_VIRTUAL:-40g}")" || exit 64
virtual_kb=$((virtual_bytes / 1024))
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
systemd_cmd=()
# Vanaf hier is dit script de enige plek waar een zwaar commando nog mag
# starten. De markering maakt dat afdwingbaar in next.config.ts.
export CMS_MEMORY_CAPPED=1
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
# zonder rechten faalt met "Access denied", en dat moet dan een nette
# terugval naar ulimit worden in plaats van een kapotte build.
probe_systemd() {
command -v systemd-run >/dev/null 2>&1 || return 1
[ -d /run/systemd/system ] || return 1
if systemd-run --scope --quiet true 2>/dev/null; then
systemd_cmd=(systemd-run --scope --quiet)
return 0
fi
if systemd-run --user --scope --quiet true 2>/dev/null; then
systemd_cmd=(systemd-run --user --scope --quiet)
return 0
fi
return 1
}
run_systemd() {
echo "[mem-cap] systemd cgroup MemoryMax=$((bytes / 1024 / 1024 / 1024))GB: $*" >&2
exec "${systemd_cmd[@]}" -p "MemoryMax=$bytes" "$@"
}
run_ulimit() {
echo "[mem-cap] ulimit -v ${virtual_kb}KB per proces (geen systemd; RSS-plafond ${cap} niet meetbaar zonder cgroup): $*" >&2
if [ "$virtual_bytes" -lt $((16 * 1024 * 1024 * 1024)) ]; then
echo "[mem-cap] let op: -v onder 16g verlaagt V8's heaplimiet en breekt de build; verhoog CMS_MEMORY_CAP_VIRTUAL" >&2
fi
ulimit -v "$virtual_kb" || {
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
return 1
}
exec "$@"
}
run_refuse() {
echo "[mem-cap] geen systemd hier; weiger onbegrensd te draaien." >&2
echo "[mem-cap] zet CMS_MEMORY_CAP_BACKEND=none om dit bewust te accepteren, of =ulimit voor een per-proces vangnet." >&2
return 1
}
run_opted_out() {
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
exec "$@"
}
case "$backend" in
systemd)
probe_systemd || {
echo "[mem-cap] CMS_MEMORY_CAP_BACKEND=systemd maar systemd-run reageert niet" >&2
exit 70
}
run_systemd "$@"
;;
ulimit)
run_ulimit "$@"
;;
none | off)
run_opted_out "$@"
;;
auto)
if probe_systemd; then
run_systemd "$@"
else
run_refuse "$@"
fi
;;
*)
echo "[mem-cap] onbekende backend: $backend" >&2
exit 64
;;
esac
+29 -68
View File
@@ -10,77 +10,38 @@ import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
await requirePermission(PERMS.CATALOG_EDIT);
// Support comma-separated userIds and/or codes for bulk operations
const userIdInput = String(formData.get("userId") ?? "").trim();
const codeInput = String(formData.get("code") ?? "").trim();
const userIds = userIdInput
? userIdInput
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
const codes = codeInput
? codeInput
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim()
.slice(0, 32);
if (!(userId > 0) || code.length === 0) return;
if (userIds.length === 0 || codes.length === 0) {
return;
}
// Fire the emulator command so the badge appears live for online users.
await rcon.giveBadge(userId, code);
// Process each user/code combination
let granted = false;
for (const userId of userIds) {
for (const code of codes) {
if (!(Number(userId) > 0) || code.length === 0) continue;
// Truncate badge code to 32 characters.
const truncatedCode = code.slice(0, 32);
// Fire the emulator command so the badge appears live for online users.
try {
await rcon.giveBadge(Number(userId), truncatedCode);
} catch {
// ignore rcon errors per pair
}
// Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, Number(userId)),
eq(UsersBadges.badgeCode, truncatedCode),
),
)
.limit(1);
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, Number(userId)));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({
userId: Number(userId),
slotId,
badgeCode: truncatedCode,
});
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
granted = true;
// Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
if (granted) {
revalidatePath("/admin/badges");
}
revalidatePath("/admin/badges");
}
+34 -30
View File
@@ -4,32 +4,11 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
/**
* Store an uploaded media file under MEDIA_ROOT.
*
* The extension always comes from the *detected* format (magic bytes + a full
* sharp decode), never from `file.name` or the browser-supplied MIME type:
* trusting either lets arbitrary bytes land on disk with an attacker-chosen name
* that the media route would then serve.
*/
async function storeUploadedMedia(
file: File,
): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
const validated = await validateSiteImageUpload(file);
if (!validated.success) return { ok: false, error: validated.error };
const baseDir = MEDIA_ROOT;
await mkdir(baseDir, { recursive: true });
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${validated.extension}`;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep))
return { ok: false, error: "Invalid path" };
await writeFile(filePath, validated.bytes);
return { ok: true, name };
}
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
export async function uploadMedia(
formData: FormData,
@@ -37,9 +16,25 @@ export async function uploadMedia(
await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
if (file.size > MAX_SIZE)
return { ok: false, error: "File too large (max 5MB)" };
if (!ALLOWED.includes(file.type))
return {
ok: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
};
const stored = await storeUploadedMedia(file);
if (!stored.ok) return { ok: false, error: stored.error };
const baseDir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { 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(baseDir + path.sep)) throw new Error("Invalid path");
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
revalidatePath("/api/media");
revalidatePath("/admin/media");
@@ -50,8 +45,6 @@ export async function deleteMedia(name: string): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const { unlink } = await import("node:fs/promises");
const baseDir = MEDIA_ROOT;
// A name that is not a bare file name never reaches the unlink.
if (name.includes("/") || name.includes("\\") || name.includes("..")) return;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) return;
try {
@@ -69,11 +62,22 @@ export async function uploadMediaAndReturn(
await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return "";
if (file.size > MAX_SIZE) return "";
if (!ALLOWED.includes(file.type)) return "";
const stored = await storeUploadedMedia(file);
if (!stored.ok) return "";
const baseDir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { 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(baseDir + path.sep)) return "";
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
revalidatePath("/api/media");
revalidatePath("/admin/media");
return `/api/media/${stored.name}`;
return `/api/media/${name}`;
}
+7 -27
View File
@@ -14,19 +14,10 @@ import {
import { PERMS } from "@/lib/permissions";
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import {
isSecretSettingKey,
SECRET_PLACEHOLDER,
} from "@/lib/services/setting-secrets";
import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
// Raw keys only the CMS core is allowed to own. Writing an arbitrary key from
// the generic "advanced key/value" form previously meant a staff member could
// overwrite `turnstile_secret`, `force_staff_2fa` or `min_staff_rank`.
const RAW_SETTING_KEY_RE = /^[a-z0-9][a-z0-9_.-]{0,127}$/;
function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value);
@@ -80,12 +71,11 @@ export async function updateSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
const raw = String(formData.get("value") ?? "").normalize("NFC");
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
// Blank on a secret means "keep what is stored", so the UI can render a
// placeholder without the risk of wiping the credential.
if (isSecretSettingKey(key) && raw === SECRET_PLACEHOLDER) return;
const value = isSecretSettingKey(key) ? raw : normalizeSettingValue(key, raw);
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value })
@@ -100,7 +90,7 @@ export async function createSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 128);
.slice(0, 255);
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
@@ -109,17 +99,7 @@ export async function createSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, 255);
// Managed keys go through `saveManagedSettings`; anything else must be a
// clearly namespaced custom key, and lockout/security settings are never
// writable through the free-form form.
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
if (
key === "force_staff_2fa" ||
key === "min_staff_rank" ||
key === "maintenance_enabled"
) {
return;
}
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
-65
View File
@@ -15,9 +15,6 @@ const core = vi.hoisted(() => ({
.trim(),
password: String(password ?? "").normalize("NFC"),
}),
isLoginLocked: vi.fn(async () => false),
recordLoginFailure: vi.fn(async () => false),
clearLoginLockout: vi.fn(async () => undefined),
}));
vi.mock("@/env", () => ({ env: {} }));
@@ -30,14 +27,8 @@ vi.mock("@/lib/services/captcha", () => ({
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { getBool: vi.fn() },
}));
vi.mock("@/lib/auth/login-lockout", () => ({
isLoginLocked: core.isLoginLocked,
recordLoginFailure: core.recordLoginFailure,
clearLoginLockout: core.clearLoginLockout,
}));
const user = (overrides = {}) => ({
id: 42,
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
@@ -54,9 +45,6 @@ beforeEach(() => {
core.verifyLoginPassword.mockResolvedValue({ valid: true });
core.isEmailUnverified.mockResolvedValue(false);
core.runDummyHashCheck.mockResolvedValue(undefined);
core.isLoginLocked.mockResolvedValue(false);
core.recordLoginFailure.mockResolvedValue(false);
core.clearLoginLockout.mockResolvedValue(undefined);
});
describe("precheckLogin", () => {
@@ -97,57 +85,4 @@ describe("precheckLogin", () => {
core.isEmailUnverified.mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("unverified");
});
it("returns locked for an account that is already locked out", async () => {
core.getLoginUser.mockResolvedValue(user());
core.isLoginLocked.mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("locked");
// The password is never verified while locked, so a correct password
// cannot walk a locked account back in.
expect(core.verifyLoginPassword).not.toHaveBeenCalled();
expect(core.clearLoginLockout).not.toHaveBeenCalled();
});
it("checks the lockout before verifying the password", async () => {
core.getLoginUser.mockResolvedValue(user());
const order: string[] = [];
core.getLoginUser.mockImplementation(async () => {
order.push("lookup");
return user();
});
core.isLoginLocked.mockImplementation(async () => {
order.push("lock");
return false;
});
core.verifyLoginPassword.mockImplementation(async () => {
order.push("verify");
return { valid: true };
});
expect(await precheckLogin("user", "pass")).toBe("ok");
expect(order).toEqual(["lookup", "lock", "verify"]);
});
it("records a failure and skips the clear when the password is wrong", async () => {
core.getLoginUser.mockResolvedValue(user());
core.verifyLoginPassword.mockResolvedValue({ valid: false });
core.recordLoginFailure.mockResolvedValue(false);
expect(await precheckLogin("user", "pass")).toBe("invalid");
expect(core.recordLoginFailure).toHaveBeenCalledWith(42);
expect(core.clearLoginLockout).not.toHaveBeenCalled();
});
it("clears the lockout after a successful authentication", async () => {
core.getLoginUser.mockResolvedValue(user());
expect(await precheckLogin("user", "pass")).toBe("ok");
expect(core.clearLoginLockout).toHaveBeenCalledWith(42);
expect(core.recordLoginFailure).not.toHaveBeenCalled();
});
it("does not lock or clear a bucket for an unknown account", async () => {
core.getLoginUser.mockResolvedValue(null);
expect(await precheckLogin("nonexistent", "pass")).toBe("invalid");
expect(core.isLoginLocked).not.toHaveBeenCalled();
expect(core.recordLoginFailure).not.toHaveBeenCalled();
expect(core.clearLoginLockout).not.toHaveBeenCalled();
});
});
+2 -18
View File
@@ -7,11 +7,6 @@ import {
runDummyHashCheck,
verifyLoginPassword,
} from "@/lib/auth/login-core";
import {
clearLoginLockout,
isLoginLocked,
recordLoginFailure,
} from "@/lib/auth/login-lockout";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -20,8 +15,7 @@ export type PrecheckResult =
| "invalid"
| "twofactor"
| "unverified"
| "captcha"
| "locked";
| "captcha";
/**
* Validates username+password WITHOUT creating a session, and reports whether a
@@ -44,9 +38,6 @@ export async function precheckLogin(
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
}
// A lockout must be checked BEFORE the password is verified: the success
// path clears the counter, which would otherwise let an already-locked
// account straight back in with the correct credentials.
const user = await getLoginUser(u);
if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check.
@@ -54,15 +45,8 @@ export async function precheckLogin(
return "invalid";
}
if (await isLoginLocked(user.id)) return "locked";
const res = await verifyLoginPassword(user, p);
if (!res.valid) {
await recordLoginFailure(user.id);
return "invalid";
}
await clearLoginLockout(user.id);
if (!res.valid) return "invalid";
if (await isEmailUnverified(user)) {
return "unverified";
+2 -9
View File
@@ -41,11 +41,7 @@ const {
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "users.edit" },
// Staff (rank 7) may act on anyone below the hotel's top rank.
getHighestRank: vi.fn(() => Promise.resolve(10)),
}));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
delete: vi.fn(() => ({ where: deleteWhere })),
@@ -113,10 +109,7 @@ beforeEach(() => {
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]);
// Rank rows for the per-id rank guard: every target sits below staff rank 7.
selectWhereResolved.mockResolvedValue([{ rank: 1 }]);
// Max slot of existing badges (consumed by the badge loop, not the guard).
selectWhereResolved.mockResolvedValueOnce([{ rank: 1 }]);
selectWhereResolved.mockResolvedValue([]);
});
describe("bulkUnban", () => {
+32 -101
View File
@@ -1,7 +1,6 @@
"use server";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { requirePermission } from "@/lib/admin/guard";
import {
Ban,
@@ -12,69 +11,18 @@ import {
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { getHighestRank, PERMS } from "@/lib/permissions";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
/**
* Bulk actions are plain server actions whose arguments come from the client,
* so every one of them validates the payload and the target ranks first. The
* helpers below are the whole "is this allowed" contract.
*/
const MAX_BULK_USERS = 200;
function parseUserIds(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
const ids = raw
.map((v) => (typeof v === "number" ? v : Number(v)))
.filter((v) => Number.isInteger(v) && v > 0);
return [...new Set(ids)].slice(0, MAX_BULK_USERS);
}
function toPositiveInt(raw: unknown): number | null {
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
function parseAmount(raw: unknown, max = 1_000_000): number | null {
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isInteger(n) && n > 0 && n <= max ? n : null;
}
function parseDuration(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isInteger(n) && n > 0 ? Math.min(n, 60 * 60 * 24 * 365) : 0;
}
async function guardBulkTargets(
staff: { id: number; rank: number },
userIds: number[],
): Promise<void> {
const highestRank = await getHighestRank();
const superAdmin = isDynamicSuperAdmin(staff.rank, highestRank);
if (superAdmin || userIds.length === 0) return;
const rows = await db
.select({ rank: User.rank })
.from(User)
.where(inArray(User.id, userIds));
const blocked = rows.filter((r) => r.rank >= staff.rank);
if (blocked.length > 0) {
throw new Error(
"Cannot act on a user at or above your rank — those ids were skipped",
);
}
}
export async function bulkUnban({
userIds,
}: {
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
await guardBulkTargets(staff, ids);
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const unbanned = Number(result[0]?.affectedRows ?? 0);
await logStaffActivity({
staffId: staff.id,
@@ -82,7 +30,10 @@ export async function bulkUnban({
description: `Unbanned ${unbanned} user(s)`,
targetType: "user",
});
return { ok: true as const, data: { unbanned, total: ids.length } };
return {
ok: true as const,
data: { unbanned, total: userIds.length },
};
}
export async function bulkBan({
@@ -95,14 +46,10 @@ export async function bulkBan({
duration: number;
}): Promise<ActionResult<{ banned: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
const seconds = parseDuration(duration);
const reasonText = typeof reason === "string" ? reason.slice(0, 255) : "";
await guardBulkTargets(staff, ids);
const now = Math.floor(Date.now() / 1000);
let banned = 0;
for (const userId of ids) {
for (const userId of userIds) {
try {
await db.insert(Ban).values({
userId,
@@ -110,8 +57,8 @@ export async function bulkBan({
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: seconds > 0 ? now + seconds : 0,
banReason: reasonText,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
});
banned++;
@@ -145,37 +92,33 @@ export async function bulkGiveCurrency({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
const value = parseAmount(amount);
if (!value) throw new Error("Invalid amount");
await guardBulkTargets(staff, ids);
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of ids) {
for (const userId of userIds) {
try {
if (type === "credits") {
await db
.update(User)
.set({ credits: sql`${User.credits} + ${value}` })
.set({ credits: sql`${User.credits} + ${amount}` })
.where(eq(User.id, userId));
await rcon.giveCredits(userId, value);
await rcon.giveCredits(userId, amount);
} else if (type === "pixels") {
await db
.insert(UsersCurrency)
.values({ userId, type: 0, amount: value })
.values({ userId, type: 0, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${value}` },
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.giveDuckets(userId, value);
await rcon.giveDuckets(userId, amount);
} else if (type === "points") {
await db
.insert(UsersCurrency)
.values({ userId, type: 101, amount: value })
.values({ userId, type: 101, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${value}` },
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.givePointsGotw(userId, value);
await rcon.givePointsGotw(userId, amount);
}
given++;
} catch {
@@ -186,7 +129,7 @@ export async function bulkGiveCurrency({
await logStaffActivity({
staffId: staff.id,
action: "bulk_give_currency",
description: `Gave ${value} ${type} to ${given} user(s)`,
description: `Gave ${amount} ${type} to ${given} user(s)`,
targetType: "user",
});
return {
@@ -209,21 +152,19 @@ export async function bulkGiveBadge({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
const code =
typeof badgeCode === "string" ? badgeCode.trim().slice(0, 64) : "";
if (!code) throw new Error("Invalid badge code");
await guardBulkTargets(staff, ids);
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of ids) {
for (const userId of userIds) {
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
if (!existing) {
@@ -232,10 +173,8 @@ export async function bulkGiveBadge({
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db
.insert(UsersBadges)
.values({ userId, slotId, badgeCode: code });
await rcon.giveBadge(userId, code);
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
}
given++;
} catch {
@@ -272,17 +211,12 @@ export async function bulkAdjustCurrency({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
if (!Number.isFinite(amount) || amount === 0) {
return { ok: false as const, error: "Amount must be a non-zero number" };
}
if (Math.abs(Math.trunc(amount)) > 1_000_000) {
return { ok: false as const, error: "Amount is too large" };
}
await guardBulkTargets(staff, ids);
if (amount > 0) {
const given = await bulkGiveCurrency({ userIds: ids, amount, type });
const given = await bulkGiveCurrency({ userIds, amount, type });
if (!given.ok) return given;
if (!given.data) {
return { ok: false as const, error: "Currency adjustment failed" };
@@ -301,7 +235,7 @@ export async function bulkAdjustCurrency({
let adjusted = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of ids) {
for (const userId of userIds) {
try {
if (type === "credits") {
const [user] = await db
@@ -365,11 +299,8 @@ export async function setTradeLock({
untilUnix: number;
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const id = toPositiveInt(userId);
if (!id) return { ok: false as const, error: "Invalid user" };
const until = Math.max(0, Math.min(Math.trunc(untilUnix), 2_000_000_000));
const until = Math.max(0, Math.trunc(untilUnix));
const locked = until > 0;
await guardBulkTargets(staff, [id]);
const [user] = await db
.select({
@@ -378,7 +309,7 @@ export async function setTradeLock({
online: User.online,
})
.from(User)
.where(eq(User.id, id))
.where(eq(User.id, userId))
.limit(1);
if (!user) {
return { ok: false as const, error: "User not found" };
@@ -400,7 +331,7 @@ export async function setTradeLock({
.where(eq(Sanctions.id, existing.id));
} else {
await tx.insert(Sanctions).values({
habboId: id,
habboId: userId,
tradeLockedUntil: until,
reason: locked ? "Trade lock (CMS)" : "",
});
@@ -438,5 +369,5 @@ export async function setTradeLock({
targetId: userId,
});
return { ok: true as const, data: { userId: id, untilUnix: until } };
return { ok: true as const, data: { userId, untilUnix: until } };
}
-29
View File
@@ -52,13 +52,6 @@ const mockSetMotto = vi.hoisted(() => vi.fn());
const mockSetRank = vi.hoisted(() => vi.fn());
const mockExecuteCommand = vi.hoisted(() => vi.fn());
const mockSendGift = vi.hoisted(() => vi.fn());
// The audit service is exercised separately; here it only has to be harmless.
// Mocked explicitly because the fake db has no `insert`, which used to leak an
// unhandled rejection out of the fire-and-forget audit call.
vi.mock("@/lib/services/audit", () => ({
logAudit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: {
send: mockSend,
@@ -462,25 +455,3 @@ describe("access control", () => {
});
});
});
describe("auditing", () => {
it("logs an entry for a currency grant", async () => {
const { logAudit } = await import("@/lib/services/audit");
await giveCredits({ userId: 1, credits: 100 });
expect(logAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: expect.any(String) }),
);
});
it("completes the command even when auditing rejects", async () => {
const { logAudit } = await import("@/lib/services/audit");
vi.mocked(logAudit).mockRejectedValueOnce(new Error("audit table missing"));
await expect(giveCredits({ userId: 1, credits: 100 })).resolves.toEqual({
ok: true,
data: {},
});
// Let the fire-and-forget promise settle; an unhandled rejection here is
// exactly the failure this guards against.
await new Promise((r) => setTimeout(r, 0));
});
});
-44
View File
@@ -7,35 +7,12 @@ import { db, queryRows, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?";
/** Currency amounts are capped: unbounded values break the hotel economy. */
const MAX_CURRENCY = 1_000_000;
/** Every mutation here gets an audit entry; rank changes and RCON most of all. */
function auditAction(
userId: number,
action: string,
targetId: number,
after: Record<string, unknown>,
): void {
// logAudit is async, so a surrounding try/catch cannot see its rejection —
// it would surface as an unhandled rejection and, in production, take the
// request down over a failing audit insert. Swallow it on the promise
// instead, which is what "auditing must never fail the command it
// describes" actually requires.
void Promise.resolve()
.then(() => logAudit({ userId, action, target: "User", targetId, after }))
.catch(() => {
/* auditing must never fail the command it describes */
});
}
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
}
@@ -143,16 +120,9 @@ const giveCreditsSchema = z.object({
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
if (ctx.data.credits > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
auditAction(Number(ctx.session.user.id), "give_credits", ctx.data.userId, {
userId: ctx.data.userId,
amount: ctx.data.credits,
});
revalidatePath(PATH);
return actionOk();
},
@@ -167,16 +137,9 @@ const giveAmountSchema = z.object({
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
if (ctx.data.amount > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
auditAction(Number(ctx.session.user.id), "give_duckets", ctx.data.userId, {
userId: ctx.data.userId,
amount: ctx.data.amount,
});
revalidatePath(PATH);
return actionOk();
},
@@ -186,16 +149,9 @@ export const giveDuckets = adminAction(
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
if (ctx.data.amount > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
auditAction(Number(ctx.session.user.id), "give_diamonds", ctx.data.userId, {
userId: ctx.data.userId,
amount: ctx.data.amount,
});
revalidatePath(PATH);
return actionOk();
},
-17
View File
@@ -10,13 +10,8 @@ const state = vi.hoisted(() => ({
deletes: [] as unknown[],
affectedDelete: 1,
emptyDeleteResult: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
// The real moderation module loads the word filter through the (mocked) db,
// which would silently change the rows the offline-message assertions read.
vi.mock("@/lib/services/moderation", () => ({ isAllowed: state.isAllowed }));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db");
@@ -320,18 +315,6 @@ describe("sendOfflineMessage", () => {
expect(redirected()).toBe("/messages?send_error=invalid");
});
it("rejects content blocked by the word filter before storing it", async () => {
state.friendships = [{ id: 1 }];
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
await redirects(() =>
sendOfflineMessage(fakeForm({ friendId: "2", message: "rude words" })),
);
expect(state.isAllowed).toHaveBeenCalledWith("rude words");
expect(state.inserts).toHaveLength(0);
expect(redirected()).toBe("/messages?send_error=invalid");
state.isAllowed.mockResolvedValue({ ok: true });
});
it("stores an offline message for a friend", async () => {
state.friendships = [{ id: 1 }];
await redirects(() =>
-3
View File
@@ -12,7 +12,6 @@ import {
User,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
type FriendOutcome =
| "accepted"
@@ -377,8 +376,6 @@ export async function sendOfflineMessage(formData: FormData): Promise<void> {
.limit(1);
if (!recipient) {
outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else {
await db.insert(MessengerOffline).values({
userId: friendId,
+5 -18
View File
@@ -1,14 +1,14 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const { selectLimit, selectWhere, insertOnDup, mockSendMail, mockRedirect } =
vi.hoisted(() => ({
const { selectLimit, insertOnDup, mockSendMail, mockRedirect } = vi.hoisted(
() => ({
selectLimit: vi.fn(),
insertOnDup: vi.fn().mockResolvedValue({}),
selectWhere: vi.fn(() => Promise.resolve([] as Array<{ id: number }>)),
mockSendMail: vi.fn(),
mockRedirect: vi.fn(),
}));
}),
);
vi.mock("next/navigation", () => ({
redirect: (...args: unknown[]) => {
@@ -24,17 +24,6 @@ vi.mock("@/lib/db", () => {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
// Matches the deterministic `.orderBy(asc(User.id))` list
// reads used to resolve duplicate addresses.
orderBy: vi.fn(() => ({
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(
resolve: (v: unknown) => void,
reject: (e: unknown) => void,
) {
return Promise.resolve(selectWhere()).then(resolve, reject);
},
})),
})),
})),
})),
@@ -81,14 +70,13 @@ beforeEach(() => {
describe("requestReset", () => {
it("sends a reset email when the user exists", async () => {
selectLimit.mockResolvedValue([{ id: 1 }]);
selectWhere.mockResolvedValue([{ id: 1 }]);
const fd = new FormData();
fd.set("email", "[email protected]");
await expect(requestReset(fd)).rejects.toThrow("redirect");
expect(selectWhere).toHaveBeenCalled();
expect(selectLimit).toHaveBeenCalled();
expect(insertOnDup).toHaveBeenCalled();
expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]",
@@ -99,7 +87,6 @@ describe("requestReset", () => {
it("does not send email when user is not found", async () => {
selectLimit.mockResolvedValue([]);
selectWhere.mockResolvedValue([]);
const fd = new FormData();
fd.set("email", "[email protected]");
+20 -50
View File
@@ -1,14 +1,11 @@
"use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -20,17 +17,6 @@ function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
/**
* Back to the reset form with a *code*, never with the human-readable message:
* a raw `?error=` value would be rendered on our own domain, which is a
* perfect phishing skeleton. The page maps each code to a translation.
*/
function errorRedirect(email: string, token: string, code: string): never {
return redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${code}`,
);
}
export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
@@ -54,23 +40,12 @@ export async function requestReset(formData: FormData): Promise<void> {
// Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const matches = await db
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.orderBy(asc(User.id));
if (matches.length > 1) {
logger.warn("Password reset address is not unique", {
email,
accountCount: matches.length,
using: matches[0]?.id,
});
}
const user = matches[0];
.limit(1);
if (user) {
// Duplicate addresses exist on legacy databases; resetting the
// *oldest* account keeps the choice deterministic instead of
// "whatever row the engine returns first".
const token = randomBytes(32).toString("hex");
const hashed = sha256(token);
const createdAt = new Date();
@@ -105,11 +80,13 @@ export async function resetPassword(formData: FormData): Promise<void> {
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
redirect(errorRedirect(email, token, "ratelimit"));
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
);
}
let error: "password" | "invalid" | "failed" | null = null;
if (password.length < 12) error = "password";
let error: string | null = null;
if (password.length < 6) error = "Password must be at least 6 characters";
if (!error) {
try {
@@ -130,29 +107,20 @@ export async function resetPassword(formData: FormData): Promise<void> {
row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) {
error = "invalid";
error = "This reset link is invalid or has expired";
} else {
const matches = await db
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.orderBy(asc(User.id));
const user = matches[0];
.limit(1);
if (!user) {
error = "invalid";
error = "Account not found";
} else {
const newHash = await hashPassword(password);
// A password change has to end every existing session: the
// popular reason for resetting is a compromised account, and a
// stolen cookie/API token must not outlive the reset.
await Promise.all([
db
.update(User)
.set({ password: newHash })
.where(eq(User.id, user.id)),
revokeUserCredentials(user.id),
]);
await invalidateLoginCache(email);
await db
.update(User)
.set({ password: await hashPassword(password) })
.where(eq(User.id, user.id));
await db
.delete(PasswordReset)
.where(eq(PasswordReset.email, email))
@@ -164,12 +132,14 @@ export async function resetPassword(formData: FormData): Promise<void> {
}
}
} catch {
error = "failed";
error = "Could not reset the password — try again";
}
}
if (error) {
redirect(errorRedirect(email, token, error));
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
);
}
redirect("/login?reset=1");
}
+3 -7
View File
@@ -175,10 +175,8 @@ export const setCmsPermissions = adminAction(
);
/**
* Re-apply the grant repair from migration 0018/0034:
* - ranks with admin.dashboard get all admin.*.view (read-only: the sidebar
* needs to open, nothing more — a blanket `admin.%` grant here is what
* promoted rank 6 to full admin)
* Re-apply the same grant repair as migration 0018:
* - ranks with admin.dashboard get all admin.*
* - ranks >= 6 get admin.*.view + dashboard
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
*/
@@ -189,9 +187,7 @@ export const repairAdminNavAclGrants = adminAction(
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`acl_roles\` ar
-- View slugs only: widening this to all admin.* turned "can open the
-- panel" into "is a full admin" for every mid rank (see 0034).
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view'
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
WHERE EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
+2 -25
View File
@@ -184,7 +184,6 @@ describe("register", () => {
expect(result).toEqual({
error: "Username must be at least 3 characters",
ok: false,
code: "usernameMinLength",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -192,7 +191,6 @@ describe("register", () => {
it("rejects usernames containing characters outside the allowed set", async () => {
const result = await register(PREV, buildForm({ username: "bad name!" }));
expect(result.error).toContain("letters, numbers, underscore and hyphen");
expect(result.code).toBe("usernamePattern");
expect(state.insert).not.toHaveBeenCalled();
});
@@ -201,7 +199,6 @@ describe("register", () => {
expect(result).toEqual({
error: "Enter a valid email address",
ok: false,
code: "emailValid",
});
});
@@ -210,7 +207,6 @@ describe("register", () => {
expect(result).toEqual({
error: "Password must be at least 12 characters",
ok: false,
code: "passwordMinLength",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -224,7 +220,6 @@ describe("register", () => {
}),
);
expect(result.error).toContain("uppercase");
expect(result.code).toBe("passwordUpper");
});
it("rejects passwords without a digit", async () => {
@@ -236,7 +231,6 @@ describe("register", () => {
}),
);
expect(result.error).toContain("digit");
expect(result.code).toBe("passwordDigit");
});
it("rejects passwords without a special character", async () => {
@@ -248,7 +242,6 @@ describe("register", () => {
}),
);
expect(result.error).toContain("special");
expect(result.code).toBe("passwordSpecial");
});
it("rejects mismatched password confirmations", async () => {
@@ -256,18 +249,13 @@ describe("register", () => {
PREV,
buildForm({ password_confirmation: "Different1" }),
);
expect(result).toEqual({
error: "Passwords do not match",
ok: false,
code: "passwordsMatch",
});
expect(result).toEqual({ error: "Passwords do not match", ok: false });
});
it("throttles sign-ups per IP", async () => {
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
const result = await runValidRegistration();
expect(result.error).toContain("Too many sign-up attempts");
expect(result.code).toBe("rateLimited");
expect(state.insert).not.toHaveBeenCalled();
});
@@ -285,7 +273,6 @@ describe("register", () => {
expect(result).toEqual({
error: "Captcha verification failed. Please try again.",
ok: false,
code: "captchaFailed",
});
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
expect(state.insert).not.toHaveBeenCalled();
@@ -303,7 +290,6 @@ describe("register", () => {
expect(result).toEqual({
error: "You must accept the terms and conditions to register.",
ok: false,
code: "termsRequired",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -312,11 +298,7 @@ describe("register", () => {
state.checkVpn.mockResolvedValue({ blocked: true });
state.siteGet.mockResolvedValueOnce("Custom VPN message");
const result = await runValidRegistration();
expect(result).toEqual({
error: "Custom VPN message",
ok: false,
code: "vpnBlocked",
});
expect(result).toEqual({ error: "Custom VPN message", ok: false });
expect(state.insert).not.toHaveBeenCalled();
});
@@ -335,7 +317,6 @@ describe("register", () => {
state.countTotal = 2;
const result = await runValidRegistration();
expect(result.error).toContain("maximum number of accounts");
expect(result.code).toBe("maxAccountsPerIp");
expect(state.insert).not.toHaveBeenCalled();
});
@@ -359,7 +340,6 @@ describe("register", () => {
expect(result).toEqual({
error: "That username is already taken",
ok: false,
code: "usernameTaken",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -370,7 +350,6 @@ describe("register", () => {
expect(result).toEqual({
error: "Registration is temporarily unavailable",
ok: false,
code: "unavailable",
});
expect(state.logger.warn).toHaveBeenCalledWith(
"Username uniqueness check failed during registration",
@@ -386,7 +365,6 @@ describe("register", () => {
expect(result).toEqual({
error: "That username is already taken",
ok: false,
code: "usernameTaken",
});
expect(state.logger.error).not.toHaveBeenCalled();
});
@@ -395,7 +373,6 @@ describe("register", () => {
state.insert.mockRejectedValueOnce(new Error("db exploded"));
const result = await runValidRegistration();
expect(result.error).toContain("Could not create the account");
expect(result.code).toBe("createFailed");
expect(state.logger.error).toHaveBeenCalledWith(
"Account creation failed",
expect.objectContaining({ message: "db exploded" }),
+7 -72
View File
@@ -121,72 +121,19 @@ const registerSchema = z
path: ["passwordConfirmation"],
});
/**
* Stable, locale-independent reason for a failed sign-up. The client maps these
* onto `pages.register.<code>` so the form speaks the visitor's language; the
* English `error` string stays as a fallback and for API/log consumers.
*/
export type RegisterErrorCode =
| "usernameMinLength"
| "usernameMaxLength"
| "usernamePattern"
| "usernameReserved"
| "usernameTaken"
| "emailValid"
| "emailDisposable"
| "passwordMinLength"
| "passwordMaxLength"
| "passwordUpper"
| "passwordLower"
| "passwordDigit"
| "passwordSpecial"
| "passwordsMatch"
| "termsRequired"
| "captchaFailed"
| "rateLimited"
| "vpnBlocked"
| "maxAccountsPerIp"
| "unavailable"
| "createFailed"
| "invalidInput";
/** Maps the schema's English messages onto locale-independent codes. */
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
"Username must be at least 3 characters": "usernameMinLength",
"Username must be at most 25 characters": "usernameMaxLength",
"Username may only contain letters, numbers, underscore and hyphen":
"usernamePattern",
"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 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",
"Password must contain at least one special character": "passwordSpecial",
"Passwords do not match": "passwordsMatch",
};
// 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";
export interface RegisterState {
error: string | null;
ok: boolean;
/** Locale-independent reason, present on every failure. */
code?: RegisterErrorCode;
}
export async function register(
_prevState: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
error,
ok: false,
code,
});
const fail = (error: string): RegisterState => ({ error, ok: false });
const raw = {
username: String(formData.get("username") ?? "")
.normalize("NFC")
@@ -208,8 +155,7 @@ export async function register(
const parsed = registerSchema.safeParse(raw);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid input";
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
}
const { username, mail, password, look } = parsed.data;
@@ -220,7 +166,6 @@ export async function register(
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
return fail(
"Too many sign-up attempts. Please wait a few minutes and try again.",
"rateLimited",
);
}
@@ -229,25 +174,18 @@ export async function register(
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip)))
return fail(
"Captcha verification failed. Please try again.",
"captchaFailed",
);
return fail("Captcha verification failed. Please try again.");
}
// Terms acceptance check.
if (!raw.termsAccepted)
return fail(
"You must accept the terms and conditions to register.",
"termsRequired",
);
return fail("You must accept the terms and conditions to register.");
// VPN/proxy block (only when enabled in /admin/vpn).
if ((await checkVpn(ip)).blocked) {
return fail(
(await siteSettings.get("vpn_block_message", "")) ||
"Registrations from VPN/proxy connections are not allowed.",
"vpnBlocked",
);
}
@@ -262,7 +200,6 @@ export async function register(
if (Number(row?.total ?? 0) >= max)
return fail(
"You have reached the maximum number of accounts for your connection.",
"maxAccountsPerIp",
);
}
@@ -273,11 +210,10 @@ export async function register(
.from(User)
.where(eq(User.username, username))
.limit(1);
if (existing)
return fail("That username is already taken", "usernameTaken");
if (existing) return fail("That username is already taken");
} catch {
logger.warn("Username uniqueness check failed during registration");
return fail("Registration is temporarily unavailable", "unavailable");
return fail("Registration is temporarily unavailable");
}
const now = Math.floor(Date.now() / 1000);
@@ -297,7 +233,7 @@ export async function register(
} catch (err) {
const code = (err as { cause?: { code?: string } }).cause?.code;
if (code === "ER_DUP_ENTRY") {
return fail("That username is already taken", "usernameTaken");
return fail("That username is already taken");
}
logger.error("Account creation failed", {
code,
@@ -305,7 +241,6 @@ export async function register(
});
return fail(
"Could not create the account. Please try again or contact staff.",
"createFailed",
);
}
+2 -5
View File
@@ -76,17 +76,14 @@ describe("rooms actions", () => {
});
state.del.mockResolvedValue([{ affectedRows: 1 }]);
state.update.mockResolvedValue([{ affectedRows: 1 }]);
// The item/room ownership lookups must find their row.
state.roomRows = [{ name: "Lobby" }, { id: 4 }];
});
it("requires the ROOMS_EDIT permission for updateRoomItem", async () => {
// `custom` is not an allow-listed column, so it must never reach `.set()`.
await updateRoomItem({ roomId: 9, itemId: 4, rot: 4, custom: "x" });
await updateRoomItem({ roomId: 9, itemId: 4, custom: "x" });
expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit");
expect(state.update).toHaveBeenCalledWith(
Items,
{ rot: 4 },
{ custom: "x" },
expect.anything(),
);
expect(state.logStaffActivity).toHaveBeenCalledWith(
+20 -63
View File
@@ -9,63 +9,16 @@ import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { notify } from "@/lib/services/webhook";
// Only these columns may be patched from the client. Spreading the whole payload
// into `.set()` let a caller rewrite roomId/userId/extraData of any row, which
// is mass assignment and IDOR in one.
const ROOM_ITEM_FIELDS = [
"wallPos",
"x",
"y",
"z",
"rot",
"extraData",
"wiredData",
"limitedData",
"guildId",
] as const;
const ROOM_FIELDS = ["name", "description", "state", "usersMax"] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
function toPositiveInt(value: unknown): number | null {
const n = typeof value === "number" ? value : Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
}
export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
const roomId = toPositiveInt(payload.roomId);
const itemId = toPositiveInt(payload.itemId);
if (!roomId || !itemId) {
throw new Error("Invalid room or item id");
}
// The item must belong to the room the staff member is editing.
const [item] = await db
.select({ id: Items.id })
.from(Items)
.where(and(eq(Items.id, itemId), eq(Items.roomId, roomId)))
.limit(1);
if (!item) throw new Error("Item not found in this room");
const { roomId, itemId, ...data } = payload as {
roomId: number;
itemId: number;
[key: string]: unknown;
};
await db
.update(Items)
.set(
pickAllowed(payload, ROOM_ITEM_FIELDS) as Partial<
typeof Items.$inferInsert
>,
)
.set(data as Partial<typeof Items.$inferInsert>)
.where(eq(Items.id, itemId));
await logStaffActivity({
staffId: staff.id,
@@ -164,20 +117,24 @@ export async function deleteRoom({ id }: { id: number }) {
revalidatePath("/admin/rooms");
}
export async function updateRoom({ id, ...data }: Record<string, unknown>) {
export async function updateRoom({
id,
...data
}: {
id: number;
name?: string;
description?: string;
state?: string;
usersMax?: number;
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
const roomId = toPositiveInt(id);
if (!roomId) throw new Error("Invalid room id");
await db
.update(Rooms)
.set(pickAllowed(data, ROOM_FIELDS) as Partial<typeof Rooms.$inferInsert>)
.where(eq(Rooms.id, roomId));
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
action: "room_update",
description: `Updated room #${roomId}`,
description: `Updated room #${id}`,
targetType: "room",
targetId: roomId,
targetId: id,
});
revalidatePath(`/admin/rooms/${roomId}`);
revalidatePath(`/admin/rooms/${id}`);
}
-29
View File
@@ -14,13 +14,8 @@ const state = vi.hoisted(() => ({
selectQueue: [] as Queue,
rows: [] as Array<Record<string, unknown>>,
failInsert: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
// The real moderation module loads the word filter through the (mocked) db
// select queue, which would shift the rows the forum assertions rely on.
vi.mock("@/lib/services/moderation", () => ({ isAllowed: state.isAllowed }));
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
@@ -215,7 +210,6 @@ describe("postThread", () => {
state.failInsert = false;
state.selectQueue = [];
state.rows = [];
state.isAllowed.mockResolvedValue({ ok: true });
state.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
);
@@ -289,18 +283,6 @@ describe("postThread", () => {
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects content blocked by the word filter before hitting the db", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
state.selectQueue = [[{ id: 10 }]];
await expect(postThread(threadForm())).rejects.toThrow(
"/guilds/10/forum/new?error=invalid",
);
expect(state.isAllowed).toHaveBeenCalledWith(
"Welcome thread Hello from the community",
);
expect(state.insert).not.toHaveBeenCalled();
});
it("reports not_found when the guild does not exist", async () => {
state.selectQueue = [[]];
await expect(postThread(threadForm())).rejects.toThrow(
@@ -342,7 +324,6 @@ describe("replyToThread", () => {
state.failInsert = false;
state.selectQueue = [];
state.rows = [];
state.isAllowed.mockResolvedValue({ ok: true });
state.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
);
@@ -380,16 +361,6 @@ describe("replyToThread", () => {
expect(state.update.mock.calls[0][1]).toMatchObject({ postsCount: 1 });
});
it("rejects a reply blocked by the word filter before hitting the db", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
state.selectQueue = [[{ id: 20, locked: 0, postsCount: 3 }]];
await expect(replyToThread(replyForm())).rejects.toThrow(
"/guilds/10/forum/20?error=invalid",
);
expect(state.isAllowed).toHaveBeenCalledWith("A thoughtful reply");
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects missing or non-positive ids by redirecting to /guilds", async () => {
await expect(replyToThread(replyForm({ guildId: "abc" }))).rejects.toThrow(
"/guilds",
-5
View File
@@ -13,7 +13,6 @@ import {
MessengerFriendships,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
// guilds_forums_comments.message which is TEXT. Keep the first post's message
@@ -236,8 +235,6 @@ export async function postThread(formData: FormData): Promise<void> {
.slice(0, MESSAGE_MAX);
if (!subject || !message) {
outcome = "invalid";
} else if (!(await isAllowed(`${subject} ${message}`)).ok) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
@@ -329,8 +326,6 @@ export async function replyToThread(formData: FormData): Promise<void> {
.slice(0, MESSAGE_MAX);
if (!message) {
outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
+2 -25
View File
@@ -78,22 +78,6 @@ async function verifyTwoFactorCode(
export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-begin:${id}`, 5, 30_000)).ok)
redirect("/settings/2fa?error=ratelimit");
// Re-running this action while 2FA is confirmed would be a silent *downgrade*
// (the new secret is stored unconfirmed, and unconfirmed means "login gate
// off"), so the existing setup has to be disabled through the proper flow
// first: a valid code, not just an authenticated session.
const [current] = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
.from(User)
.where(eq(User.id, id))
.limit(1);
if (current?.twoFactorConfirmedAt)
redirect("/settings/2fa?error=alreadyenabled");
const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes();
@@ -120,19 +104,12 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
const { ok, updatedRecoveryCodes } = await verifyTwoFactorCode(id, code);
const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode");
// A recovery code spends itself on use, so persist the remainder together
// with the confirmation instead of dropping the caller's own update.
await db
.update(User)
.set({
twoFactorConfirmedAt: new Date(),
...(updatedRecoveryCodes !== undefined
? { twoFactorRecoveryCodes: updatedRecoveryCodes }
: {}),
})
.set({ twoFactorConfirmedAt: new Date() })
.where(eq(User.id, id));
redirect("/settings/2fa?enabled=1");
}
-18
View File
@@ -8,7 +8,6 @@ const state = vi.hoisted(() => ({
updateCall: undefined as unknown,
rconSetMotto: vi.fn(),
failDbUpdate: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
const databaseErrorClass = vi.hoisted(
@@ -64,10 +63,6 @@ vi.mock("@/lib/services/rcon", () => ({
rcon: { setMotto: state.rconSetMotto },
}));
vi.mock("@/lib/services/moderation", () => ({
isAllowed: state.isAllowed,
}));
const mockRevalidatePath = vi.hoisted(() => vi.fn());
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
@@ -103,7 +98,6 @@ beforeEach(() => {
state.updateCall = undefined;
state.rconSetMotto.mockResolvedValue(true);
state.failDbUpdate = false;
state.isAllowed.mockResolvedValue({ ok: true });
});
describe("updateMotto", () => {
@@ -147,18 +141,6 @@ describe("updateMotto", () => {
});
});
describe("updateMotto word filter", () => {
it("rejects a motto blocked by the word filter before persisting", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
await expect(updateMotto(mockingForm("bad motto"))).rejects.toThrow(
databaseErrorClass,
);
expect(state.isAllowed).toHaveBeenCalledWith("bad motto");
expect(state.updateCall).toBeUndefined();
expect(state.rconSetMotto).not.toHaveBeenCalled();
});
});
describe("updateMottoAction", () => {
it("denies unauthenticated callers", async () => {
state.auth.mockResolvedValue(null);
-8
View File
@@ -6,7 +6,6 @@ import { z } from "zod";
import { db, User } from "@/lib/db";
import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
import { isAllowed } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
const MOTTO_MAX = 127;
@@ -17,14 +16,7 @@ const mottoSchema = z.object({
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
});
async function assertMottoAllowed(motto: string): Promise<void> {
if (!(await isAllowed(motto)).ok) {
throw new DatabaseError("Motto not allowed");
}
}
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
await assertMottoAllowed(ctx.data.motto);
try {
await db
.update(User)
-7
View File
@@ -62,9 +62,6 @@ vi.mock("@/lib/permissions", () => ({
USERS_BAN: "users.ban",
USERS_RESET_PASSWORD: "users.reset_password",
},
// Staff in the fixtures is rank 7 and the hotel's top rank is 10, so rank
// guards act as "below-your-own-rank only".
getHighestRank: vi.fn(() => Promise.resolve(10)),
}));
vi.mock("@/lib/safe-action", () => ({
@@ -80,10 +77,6 @@ vi.mock("@/lib/services/audit", () => ({
logAudit: vi.fn(),
}));
vi.mock("@/lib/auth/session-revocation", () => ({
revokeUserCredentials: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/services/webhook", () => ({
notify: vi.fn(),
}));
+5 -36
View File
@@ -3,10 +3,8 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import {
Ban,
db,
@@ -15,7 +13,7 @@ import {
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { getHighestRank, PERMS } from "@/lib/permissions";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -56,9 +54,8 @@ export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data;
const actorRank = ctx.session.user.rank;
const highestRank = await getHighestRank();
if (rank >= actorRank && !isDynamicSuperAdmin(actorRank, highestRank)) {
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
@@ -133,7 +130,7 @@ export const updateUser = adminAction(
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
!isDynamicSuperAdmin(ctx.session.user.rank, await getHighestRank())
ctx.session.user.rank < 7
) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
@@ -147,15 +144,7 @@ export const updateUser = adminAction(
motto: string;
credits: number;
pixels: number;
mailVerified?: string;
}>;
// A changed address has to prove itself again: leaving mail_verified
// set would keep every mail send (resets, notifications) pointed at an
// inbox nobody confirmed, and would silently bypass the "verified
// accounts only" gate.
if (patch.mail !== undefined && patch.mail !== targetUser.mail) {
patch.mailVerified = "0";
}
if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id));
}
@@ -342,14 +331,7 @@ async function guardRank(targetUserId: number, sessionRank: number) {
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found");
// The owner is whoever holds the hotel's highest rank *today*. The old
// `sessionRank < 7` shortcut handed every rank-7 account owner powers on
// any hotel whose top rank is 8+, which makes it a plain escalation.
const highestRank = await getHighestRank();
if (
target.rank >= sessionRank &&
!isDynamicSuperAdmin(sessionRank, highestRank)
) {
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target;
@@ -376,9 +358,6 @@ export const resetPassword = adminAction(
.update(User)
.set({ password: hashed })
.where(eq(User.id, ctx.data.userId));
// A staff-issued password must also end the user's live sessions: this
// action exists precisely for "account compromised" situations.
await revokeUserCredentials(ctx.data.userId);
invalidateLoginCache(target.username);
logAudit({
@@ -440,19 +419,9 @@ const alertUserSchema = z.object({
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success)
throw new ActionError("Failed to send alert. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_alert",
target: "User",
targetId: ctx.data.userId,
after: { message: ctx.data.message, username: target.username },
});
return actionOk();
},
);
-186
View File
@@ -1,186 +0,0 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
clientIp: vi.fn(async () => "203.0.113.7"),
rateLimit: vi.fn(async () => ({ ok: true, retryAfter: 0 })),
captchaConfig: vi.fn(async () => ({ provider: "none", field: "" })),
// Mirrors the real verifier: a missing token never passes.
verifyCaptcha: vi.fn(async (token: string | null) => Boolean(token)),
sendVerification: vi.fn(async () => undefined),
rows: [] as Array<Record<string, unknown>>,
rateLimitedFor: null as string | null,
failDb: false,
}));
vi.mock("@/lib/rate-limit", () => ({
clientIp: state.clientIp,
rateLimit: vi.fn(async (key: string) => {
state.rateLimitedFor = key;
return state.rateLimit();
}),
}));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: state.captchaConfig,
verifyCaptcha: state.verifyCaptcha,
}));
vi.mock("@/lib/auth/email-verification", () => ({
sendVerification: state.sendVerification,
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db");
return {
...schema,
db: createFakeDb(() => {
if (state.failDb) throw new Error("db down");
return state.rows;
}),
};
});
import { resendVerification } from "./verify";
const form = (fields: Record<string, string>) => {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.set(k, v);
return f;
};
const prev = { ok: false, error: null };
beforeEach(() => {
vi.clearAllMocks();
state.clientIp.mockResolvedValue("203.0.113.7");
state.rateLimit.mockResolvedValue({ ok: true, retryAfter: 0 });
state.captchaConfig.mockResolvedValue({ provider: "none", field: "" });
state.verifyCaptcha.mockImplementation(async (t) => Boolean(t));
state.sendVerification.mockResolvedValue(undefined);
state.rows = [];
state.rateLimitedFor = null;
state.failDb = false;
});
describe("resendVerification", () => {
it("rejects a malformed address", async () => {
const res = await resendVerification(prev, form({ email: "nope" }));
expect(res).toEqual({ ok: false, error: "invalid" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("sends for an unverified account", async () => {
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(
prev,
form({ email: "[email protected]" }),
);
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
});
it("answers identically for an unknown address so it cannot be probed", async () => {
state.rows = [];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("does not mail an already verified account", async () => {
state.rows = [{ id: 5, mailVerified: "1" }];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("rate limits on the ip", async () => {
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 60 });
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "rateLimited" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("rate limits on the address so rotating ips cannot mail-bomb", async () => {
state.rateLimit
.mockResolvedValueOnce({ ok: true, retryAfter: 0 })
.mockResolvedValueOnce({ ok: false, retryAfter: 300 });
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "rateLimited" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("reports unavailable when the lookup throws", async () => {
state.failDb = true;
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "unavailable" });
});
});
describe("resendVerification captcha", () => {
beforeEach(() => {
state.captchaConfig.mockResolvedValue({
provider: "turnstile",
field: "cf-turnstile-response",
});
});
it("rejects a missing token when a provider is configured", async () => {
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "captcha" });
expect(state.verifyCaptcha).toHaveBeenCalledWith(null, "203.0.113.7");
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("rejects a failing token", async () => {
state.verifyCaptcha.mockResolvedValue(false);
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(
prev,
form({
email: "[email protected]",
"cf-turnstile-response": "bad-token",
}),
);
expect(res).toEqual({ ok: false, error: "captcha" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("accepts a valid token and mails the account", async () => {
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(
prev,
form({ email: "[email protected]", "cf-turnstile-response": "good-token" }),
);
expect(res).toEqual({ ok: true, error: null });
expect(state.verifyCaptcha).toHaveBeenCalledWith(
"good-token",
"203.0.113.7",
);
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
});
it("checks the captcha before the account lookup", async () => {
state.verifyCaptcha.mockResolvedValue(false);
state.rows = [{ id: 5, mailVerified: "0" }];
await resendVerification(prev, form({ email: "[email protected]" }));
const order: string[] = [];
state.verifyCaptcha.mockImplementation(async () => {
order.push("captcha");
return false;
});
await resendVerification(prev, form({ email: "[email protected]" }));
order.push("done");
expect(order).toEqual(["captcha", "done"]);
});
it("does not verify a captcha when no provider is configured", async () => {
state.captchaConfig.mockResolvedValue({ provider: "none", field: "" });
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.verifyCaptcha).not.toHaveBeenCalled();
});
});
-74
View File
@@ -1,74 +0,0 @@
"use server";
import { eq } from "drizzle-orm";
import { sendVerification } from "@/lib/auth/email-verification";
import { db, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
export interface ResendVerificationState {
ok: boolean;
error: string | null;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Re-send a verification e-mail for an address the visitor typed on /verify.
*
* Deliberately reports success even when no matching unverified account exists:
* a distinct failure would let anyone probe which addresses are registered. The
* identical-privacy behaviour also applies to the e-mail templates, which are
* only sent for real accounts. Rate limiting plus captcha are the spam defence:
* this endpoint triggers real outbound mail, so an unverified address must not
* be usable as a free mail cannon.
*/
export async function resendVerification(
_prevState: ResendVerificationState,
formData: FormData,
): Promise<ResendVerificationState> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
if (!EMAIL_RE.test(email)) {
return { ok: false, error: "invalid" };
}
const ip = await clientIp();
if (!(await rateLimit(`verify:resend:${ip}`, 3, 10 * 60_000)).ok) {
return { ok: false, error: "rateLimited" };
}
// Same cooldown keyed on the address, so rotating IPs cannot be used to
// mail-bomb an arbitrary inbox with "verify your email".
if (!(await rateLimit(`verify:resend:email:${email}`, 3, 10 * 60_000)).ok) {
return { ok: false, error: "rateLimited" };
}
// Captcha runs before any lookup or send, and answers with the same
// `captcha` code the login form uses so the UI can point at the widget.
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "")
.normalize("NFC")
.trim();
if (!(await verifyCaptcha(token || null, ip))) {
return { ok: false, error: "captcha" };
}
}
try {
const [user] = await db
.select({ id: User.id, mailVerified: User.mailVerified })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (user && user.mailVerified !== "1") {
await sendVerification(email);
}
} catch {
return { ok: false, error: "unavailable" };
}
return { ok: true, error: null };
}
+2 -2
View File
@@ -110,7 +110,7 @@ export default async function ApplyStaffPage({
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
return (
<section className="page-grid">
<main className="page-grid">
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
@@ -233,6 +233,6 @@ export default async function ApplyStaffPage({
})}
</div>
)}
</section>
</main>
);
}
+2 -2
View File
@@ -89,7 +89,7 @@ export default async function ApplyTeamPage({
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
return (
<section className="page-grid">
<main className="page-grid">
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
@@ -199,6 +199,6 @@ export default async function ApplyTeamPage({
})}
</div>
)}
</section>
</main>
);
}
+2 -2
View File
@@ -30,7 +30,7 @@ export default async function BadgesPage() {
.catch(() => []);
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard icon="🏅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={badges.length === 0}>
@@ -76,6 +76,6 @@ export default async function BadgesPage() {
</div>
)}
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -52,7 +52,7 @@ export default async function BannedPage() {
});
return (
<section style={{ maxWidth: 560, margin: "2rem auto" }}>
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}>
<p style={{ marginTop: 0 }}>{t("body")}</p>
{reason ? (
@@ -65,6 +65,6 @@ export default async function BannedPage() {
{t("contactStaff")}
</p>
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -54,7 +54,7 @@ export default function CommunityPage() {
const t = useTranslations("pages.community");
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} />
<div className="card-grid sm-2 lg-3">
@@ -84,6 +84,6 @@ export default function CommunityPage() {
</Link>
))}
</div>
</section>
</main>
);
}
+2 -2
View File
@@ -398,7 +398,7 @@ export default function DevelopersPage() {
const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0);
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard
icon="🧩"
title="Developer API"
@@ -479,6 +479,6 @@ export default function DevelopersPage() {
</div>
</ContentCard>
))}
</section>
</main>
);
}
+2 -2
View File
@@ -102,7 +102,7 @@ export default async function DrawBadgePage({
}
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard
icon="🎨"
title="Draw a Badge"
@@ -231,6 +231,6 @@ export default async function DrawBadgePage({
</div>
)}
</ContentCard>
</section>
</main>
);
}
@@ -33,8 +33,6 @@ export function EventRegisterButton({
run(() => registerForEvent({ eventId }), {
successMessage: t("registerSuccess"),
errorMessage: t("registerError"),
// registerForEvent revalidates /events and /events/<id>.
revalidated: true,
})
}
>
+2 -2
View File
@@ -142,7 +142,7 @@ export default async function EventDetailPage({
else if (isFull) disabledReason = t("eventFull");
return (
<section className="page-grid">
<main className="page-grid">
<p className="muted" style={{ margin: 0 }}>
<Link href="/events">{t("back")}</Link>
</p>
@@ -259,6 +259,6 @@ export default async function EventDetailPage({
</ul>
</ContentCard>
) : null}
</section>
</main>
);
}
+2 -2
View File
@@ -69,7 +69,7 @@ async function EventsPage({
const href = (page: number) =>
`/events?${new URLSearchParams({ status: result?.status ?? "all", week: result?.week ?? "", mine: params.mine ?? "", page: String(page) })}`;
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard>
@@ -272,7 +272,7 @@ async function EventsPage({
</nav>
</ContentCard>
)}
</section>
</main>
);
}
+5 -42
View File
@@ -1,4 +1,3 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset";
@@ -7,15 +6,6 @@ import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
import { captchaConfig } from "@/lib/services/captcha";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.forgot");
return {
title: t("title"),
description: t("subtitle"),
robots: { index: false, follow: false },
};
}
export default async function ForgotPage({
searchParams,
}: {
@@ -27,39 +17,12 @@ export default async function ForgotPage({
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<section style={{ maxWidth: 420, margin: "2rem auto" }}>
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}>
{sent ? (
<>
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
{t("sentNotice")}
</p>
{/* A mail that never arrived must be retryable from here,
otherwise the visitor is stuck on a dead end. */}
<form
action={requestReset}
style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}
>
<input
name="email"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
required
/>
<CaptchaWidget
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
<button type="submit" className="btn btn-primary">
{t("sendAnotherLink")}
</button>
</form>
</>
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
{t("sentNotice")}
</p>
) : (
<form
action={requestReset}
@@ -103,6 +66,6 @@ export default async function ForgotPage({
<Link href="/login">{t("backToLogin")}</Link>
</p>
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -102,7 +102,7 @@ export default async function FriendsPage({
: null;
return (
<section className="page-grid">
<main className="page-grid">
{removed === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.removed")}
@@ -180,6 +180,6 @@ export default async function FriendsPage({
</div>
)}
</ContentCard>
</section>
</main>
);
}
@@ -103,11 +103,11 @@ export default async function GuildForumThreadPage({
} catch (error) {
publicReadFailure("guild.thread")(error);
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} />
</ContentCard>
</section>
</main>
);
}
@@ -173,7 +173,7 @@ export default async function GuildForumThreadPage({
: null;
return (
<section className="page-grid">
<main className="page-grid">
{replied === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.replied")}
@@ -317,6 +317,6 @@ export default async function GuildForumThreadPage({
{t("loginToReply")} <Link href="/login">{t("loginLink")}</Link>
</p>
)}
</section>
</main>
);
}
@@ -70,7 +70,7 @@ export default async function NewThreadPage({
: null;
return (
<section className="page-grid">
<main className="page-grid">
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
@@ -132,6 +132,6 @@ export default async function NewThreadPage({
</div>
</form>
</ContentCard>
</section>
</main>
);
}
+4 -4
View File
@@ -67,11 +67,11 @@ export default async function GuildForumPage({
} catch (error) {
publicReadFailure("guild.forum")(error);
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum`} />
</ContentCard>
</section>
</main>
);
}
@@ -135,7 +135,7 @@ export default async function GuildForumPage({
const usernameById = new Map(users.map((u) => [u.id, u.username]));
return (
<section className="page-grid">
<main className="page-grid">
{posted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
@@ -240,6 +240,6 @@ export default async function GuildForumPage({
</table>
)}
</ContentCard>
</section>
</main>
);
}
+4 -4
View File
@@ -55,11 +55,11 @@ export default async function GuildPage({
} catch (error) {
publicReadFailure("guild.detail")(error);
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}`} />
</ContentCard>
</section>
</main>
);
}
@@ -139,7 +139,7 @@ export default async function GuildPage({
const created = formatDate(new Date(guild.dateCreated * 1000), "date");
return (
<section className="page-grid">
<main className="page-grid">
<p style={{ margin: 0 }}>
<Link href="/guilds">{t("allGuilds")}</Link>
</p>
@@ -251,6 +251,6 @@ export default async function GuildPage({
</div>
)}
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -50,7 +50,7 @@ export default async function GuildsPage() {
const guilds = await getGuilds();
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={!guilds?.length}>
@@ -98,6 +98,6 @@ export default async function GuildsPage() {
</div>
)}
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -103,7 +103,7 @@ export default async function HelpCategoryPage({
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return (
<section className="page-grid">
<main className="page-grid">
<p style={{ margin: 0 }}>
<Link href="/help">{t("back")}</Link>
</p>
@@ -162,6 +162,6 @@ export default async function HelpCategoryPage({
</div>
</ContentCard>
) : null}
</section>
</main>
);
}
+2 -2
View File
@@ -127,7 +127,7 @@ export default async function HelpCenterPage() {
}
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard
icon="❓"
title={t("title")}
@@ -278,6 +278,6 @@ export default async function HelpCenterPage() {
</div>
)}
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -157,7 +157,7 @@ export default async function HelpTicketDetailPage({
];
return (
<section className="page-grid">
<main className="page-grid">
{replied === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.replied")}
@@ -302,6 +302,6 @@ export default async function HelpTicketDetailPage({
{t("closedHint")}
</p>
)}
</section>
</main>
);
}
+2 -2
View File
@@ -67,7 +67,7 @@ export default async function HelpTicketsPage({
: null;
return (
<section className="page-grid">
<main className="page-grid">
{created === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.created")}
@@ -167,6 +167,6 @@ export default async function HelpTicketsPage({
</table>
)}
</ContentCard>
</section>
</main>
);
}
+16 -28
View File
@@ -1,6 +1,4 @@
import dynamic from "next/dynamic";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { CloudsField } from "@/components/clouds-field";
import MotionPageWrapper from "@/components/motion-page-wrapper";
@@ -9,7 +7,6 @@ import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
import { publicClientMessages } from "@/lib/i18n-client-messages";
const RadioPlayerGate = dynamic(
() => import("@/components/public/radio-player-gate"),
@@ -23,39 +20,30 @@ const RadioPlayerGate = dynamic(
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*
* The message provider lives here rather than only in the root layout: nested
* NextIntlClientProviders replace the parent set instead of merging, so this is
* where the public catalogue is installed — without the ~177 KB of staff-tool
* namespaces that no page in this group can reach.
*/
export default async function SiteLayout({
children,
}: {
children: ReactNode;
}) {
const [session, locale, messages] = await Promise.all([
auth(),
getLocale(),
getMessages(),
]);
const clientMessages = publicClientMessages(messages);
const session = await auth();
return (
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<>
<CloudsField />
{/* Site chrome is public: hiding it all for anonymous visitors used to
strand them — from /news, /leaderboard or /shop there was no way to
reach any other page at all. */}
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
{session?.user?.id ? (
<>
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
</>
) : null}
<main>
<div
data-theme-block="content_grid"
@@ -74,6 +62,6 @@ export default async function SiteLayout({
<div data-theme-block="radio_player">
<RadioPlayerGate />
</div>
</NextIntlClientProvider>
</>
);
}
+9 -26
View File
@@ -10,7 +10,6 @@ import {
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.leaderboard");
@@ -53,17 +52,7 @@ function formatValue(key: TabKey, value: number): string {
return value.toLocaleString();
}
type Row = { userId: number; username: string; look: string; value: number };
/**
* Users who hid their wallet must not appear in the currency tabs: the profile
* page already honours that, and a leaderboard that ignores it makes the
* setting meaningless.
*/
async function withoutHiddenWallets(rows: Row[]): Promise<Row[]> {
const privacy = await loadProfilePrivacyMap(rows.map((r) => r.userId));
return rows.filter((r) => privacy.get(r.userId)?.wallet !== false);
}
type Row = { username: string; look: string; value: number };
async function loadCreditsRows(): Promise<Row[]> {
try {
@@ -73,7 +62,6 @@ async function loadCreditsRows(): Promise<Row[]> {
() =>
db
.select({
id: User.id,
username: User.username,
look: User.look,
credits: User.credits,
@@ -83,14 +71,11 @@ async function loadCreditsRows(): Promise<Row[]> {
.limit(20),
{ staleMs: 120000 },
);
return await withoutHiddenWallets(
users.map((u) => ({
userId: u.id,
username: u.username,
look: u.look,
value: u.credits,
})),
);
return users.map((u) => ({
username: u.username,
look: u.look,
value: u.credits,
}));
} catch {
return [];
}
@@ -104,7 +89,6 @@ async function loadCurrencyRows(type: number): Promise<Row[]> {
() =>
db
.select({
userId: User.id,
username: User.username,
look: User.look,
value: UsersCurrency.amount,
@@ -115,7 +99,7 @@ async function loadCurrencyRows(type: number): Promise<Row[]> {
.orderBy(desc(UsersCurrency.amount))
.limit(20),
{ staleMs: 120000 },
).then(withoutHiddenWallets);
);
} catch {
return [];
}
@@ -132,7 +116,6 @@ async function loadSettingsRows(
() =>
db
.select({
userId: User.id,
username: User.username,
look: User.look,
value: column,
@@ -183,7 +166,7 @@ export default async function LeaderboardPage({
: null;
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard
icon="📊"
title={t("title")}
@@ -262,6 +245,6 @@ export default async function LeaderboardPage({
</div>
)}
</ContentCard>
</section>
</main>
);
}
+198 -196
View File
@@ -1,47 +1,20 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { count, desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login");
const title = t("title");
const description = t("subtitle");
return {
title,
description,
// Sign-in is a dead end for crawlers and duplicates the homepage copy.
robots: { index: false, follow: false },
openGraph: { title, description, type: "website" },
};
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{
from?: string;
registered?: string;
reset?: string;
}>;
}) {
export default async function LoginPage() {
const t = await getTranslations("pages.login");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
@@ -50,173 +23,202 @@ export default async function LoginPage({
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from);
// Already signed in: the form has nothing to do here. Honour `from` first so
// an admin bounced off /admin lands back where they were heading.
const session = await auth();
if (session?.user?.id) redirect(redirectTo);
const [online, recentUsers, latestUsers] = await Promise.all([
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
cached("online_count", 10_000, () =>
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8)
.catch(() => []),
db
.select({ username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8)
.catch(() => []),
]);
// `/login?registered=1` is where the sign-up form lands when it could not
// auto sign-in (e-mail verification still pending). Without this notice the
// visitor would only see an empty login form and no sign their account
// exists.
const notice =
sp.registered === "1" ? (
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("registeredSuccess")}
</p>
) : sp.reset === "1" ? (
// `?reset=1` comes from a successful password reset; saying so matters
// because the visitor just changed their password and a silent form
// reads like the reset failed.
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("passwordChanged")}
</p>
) : undefined;
return (
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={t("title")}
subtitle={t("subtitle")}
notice={notice}
form={
<LoginForm
redirectTo={redirectTo}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<AuthTopBar hotelName={hotelName} logo={logo} />
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={t("whoIsOnline")}
latestTitle={t("newestCitizens")}
/>
</AuthPageFrame>
<Reveal>
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
{/* Left panel */}
<div className="lg:w-96 shrink-0 space-y-4">
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden"
>
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
priority
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
background:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
color:
"var(--color-primary-readable, var(--color-primary))",
}}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
{recentUsers.length > 0 && (
<SurfaceCard
title={t("whoIsOnline")}
icon={ICON_NAV_GOODY}
bodyClassName="p-4"
className="card-glow"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
}}
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span
className="w-full truncate text-center text-[9px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</SurfaceCard>
)}
{latestUsers.length > 0 && (
<SurfaceCard
title={t("newestCitizens")}
icon={ICON_FRIENDS}
bodyClassName="p-4"
className="card-glow"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{latestUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
}}
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span
className="w-full truncate text-center text-[9px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</SurfaceCard>
)}
</div>
{/* Right: form */}
<div className="flex-1 lg:sticky lg:top-6">
<SurfaceCard
title={t("title")}
icon={ICON_NAV_ME}
bodyClassName="p-6 sm:p-7"
>
<p
className="text-sm mb-6"
style={{ color: "var(--color-text-muted)" }}
>
{t("subtitle")}
</p>
<LoginForm
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
</SurfaceCard>
</div>
</div>
</Reveal>
</div>
);
}
+2 -2
View File
@@ -30,7 +30,7 @@ export default async function LogoPage() {
),
);
return (
<section
<main
style={{
display: "grid",
gap: "1.5rem",
@@ -45,6 +45,6 @@ export default async function LogoPage() {
/>
<LogoGenerator initialText={initialText} canSaveToSite={canSaveToSite} />
</section>
</main>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ export default async function MaintenancePage() {
]);
return (
<section style={{ maxWidth: 560, margin: "2rem auto" }}>
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard
icon="🛠️"
title={t("title", { hotel })}
@@ -25,6 +25,6 @@ export default async function MaintenancePage() {
{t("staffCanLogIn")}
</p>
</ContentCard>
</section>
</main>
);
}
+2 -2
View File
@@ -82,7 +82,7 @@ export default async function MarketplacePage() {
const total = offers.reduce((sum, o) => sum + o.price, 0);
return (
<section className="page-grid">
<main className="page-grid">
<ContentCard icon="🛍️" title={t("title")} subtitle={t("subtitle")}>
<div className="stat-grid">
<StatBlock
@@ -148,6 +148,6 @@ export default async function MarketplacePage() {
</div>
)}
</ContentCard>
</section>
</main>
);
}
+6 -8
View File
@@ -47,14 +47,14 @@ async function MePage({
data = await loadUserDashboard(userId);
} catch {
return (
<section>
<main>
<SurfaceCard className="p-6">
<p role="alert">{t("loadError")}</p>
<Link href="/me" className="btn btn-outline">
{t("retry")}
</Link>
</SurfaceCard>
</section>
</main>
);
}
// Daily reward state (settings + schedule + the user's last claim). Never
@@ -63,14 +63,14 @@ async function MePage({
const user = data.userRows[0];
if (!user)
return (
<section>
<main>
<SurfaceCard className="p-6">
<p role="alert">{t("loadError")}</p>
<Link href="/login" className="btn btn-outline">
{t("login")}
</Link>
</SurfaceCard>
</section>
</main>
);
const {
hotelName,
@@ -145,7 +145,7 @@ async function MePage({
? error
: "error";
return (
<section className={styles.dashboard}>
<main className={styles.dashboard}>
{claimed && (
<p role="status" className={styles.feedback}>
{t("claimed")}
@@ -181,8 +181,6 @@ async function MePage({
width={100}
height={140}
className={styles.avatar}
loading="eager"
fetchPriority="high"
/>
<div className={styles.identity}>
<p className="muted">{t("welcome", { hotel: hotelName })}</p>
@@ -471,7 +469,7 @@ async function MePage({
</SurfaceCard>
</aside>
</div>
</section>
</main>
);
}
+2 -2
View File
@@ -177,7 +177,7 @@ export default async function MessagesPage({
: null;
return (
<section className="page-grid">
<main className="page-grid">
{accepted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.accepted")}
@@ -391,6 +391,6 @@ export default async function MessagesPage({
</div>
)}
</ContentCard>
</section>
</main>
);
}
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more