1 Commits
Author SHA1 Message Date
remco 7fe2713b34 Add renovate.json
Gitea Actions Runner Test / test-job (push) Successful in 5s
CI / check (pull_request) Failing after 21s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-02 07:00:06 +00:00
353 changed files with 9012 additions and 12341 deletions

No files matched your search

+67
View File
@@ -78,6 +78,73 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
# Override for tests/staging (production uses the public endpoint by default). # Override for tests/staging (production uses the public endpoint by default).
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4 CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
# --- CROWDSEC API (community reputation auto-block, optional) ---
# Free CTI API key: https://app.crowdsec.net/ → Settings → CTI API Keys.
# When set, the anti-DDoS gate checks the community reputation of repeat
# offenders (CTI GET /smoke/{ip}) and immediately hard-blocks known-bad IPs.
# Lookups only happen for IPs that already tripped a rate bucket and are
# cached in Redis for 1h, so quota usage stays minimal.
CROWDSEC_API_KEY=
# Runtime toggle for reputation-based auto-blocking (also overridable live
# from the admin panel). Requires CROWDSEC_API_KEY.
CROWDSEC_AUTO_BLOCK_ENABLED=true
# Minimum malevolence score 0-5 (CrowdSec scale; 4-5 = "malicious") before an
# IP is treated as known-bad. IPs with false-positive tags are never blocked.
CROWDSEC_BLOCK_SCORE=4
# How long a CrowdSec-confirmed bad IP stays blocked (seconds).
CROWDSEC_BLOCK_TTL_SECONDS=86400
# Endpoint — override only for tests/staging.
CROWDSEC_CTI_BASE_URL=https://cti.api.crowdsec.net/v2
# Daily enrichment-call ceiling (freemium plan ≈ 10k/day). Once today's
# counter reaches it, reputation lookups pause until tomorrow so a spread
# DDoS cannot silently burn the whole quota. 0 = unlimited.
CROWDSEC_CTI_DAILY_QUOTA=10000
# How many new community-reputation blocks within a 5-minute window justify an
# ops alert (quota/backoff/report alerts all use HEALTH_ALERT_COOLDOWN_MIN).
CROWDSEC_ALERT_BLOCK_BURST=10
# --- CROWDSEC SIGNAL PUSH (share our blocks back, optional) ---
# Opt-in: pushes blocked IPs + behaviors to the CrowdSec Central API (CAPI) so
# the community blocklist protects other members too. Set to "true" to enable.
# Requires watcher credentials — either set both CROWDSEC_REPORT_MACHINE_ID
# (48 chars, [A-Za-z0-9]) and CROWDSEC_REPORT_PASSWORD now, or leave them
# unset and let the app generate a stable pair persisted in Redis automatically.
CROWDSEC_REPORT_ENABLED=false
CROWDSEC_REPORT_MACHINE_ID=
CROWDSEC_REPORT_PASSWORD=
# Optional: attachment key from https://app.crowdsec.net → Console settings —
# links our watcher to your account so pushed signals show up there.
CROWDSEC_REPORT_ENROLL_KEY=
# Central API base — override only for tests/staging.
CROWDSEC_CAPI_BASE_URL=https://api.crowdsec.net/v3
# --- CROWDSEC LOCAL (opt-in engine on this Docker host, no proxy changes) ---
# App-layer LAPI bouncer: the anti-DDoS gate asks the local engine per client
# IP (short-cached) and blocks ban/captcha decisions before its own buckets.
# Start everything with `bash cms security`; it writes the key below into .env
# and starts the CrowdSec engine bound to 127.0.0.1. Set to "true" to load the
# bouncer without the local engine (not recommended).
CROWDSEC_LOCAL_ENABLED=false
# Host access-log directory mounted into the engine for detection (Nginx only).
CROWDSEC_NGINX_LOG_DIR=/var/log/nginx
# Change LAPI port AND LAPI URL together when 18080 is already taken.
CROWDSEC_LAPI_PORT=18080
CROWDSEC_LAPI_URL=http://127.0.0.1:18080
# Generated by `bash cms security`; keep in .env, never commit a value.
CROWDSEC_LAPI_API_KEY=
# IP blocklist sync (`bash cms security blocklists`): space-separated URLs, by
# default Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
# blocklist.de, Emerging Threats, abuse.ch Feodo/SSLBL/URLhaus, IPsum,
# Firehol ipsets and Tor exit nodes. Requires internet to fetch; detection and
# blocking stay local.
#CROWDSEC_BLOCKLIST_SOURCES=https://www.spamhaus.org/drop/drop.txt https://example.org/list.txt
# Expiration for each blocklist decision (re-synced keeps them fresh).
#CROWDSEC_BLOCKLIST_DURATION=24h
# Combined cap per sync (safety valve against excessive decisions).
#CROWDSEC_BLOCKLIST_MAX_DECISIONS=1000000
# Comma-separated IPs/CIDRs that a sync must always skip (allowlist).
#CROWDSEC_BLOCKLIST_ALLOW=1.2.3.4,10.0.0.0/8
# --- PATHS --- # --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges BADGE_UPLOAD_DIR=./public/assets/images/badges
EMULATOR_JAR_PATH=./emulator/Arcturus.jar EMULATOR_JAR_PATH=./emulator/Arcturus.jar
+6 -12
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ gitea.repository }} repository: ${{ gitea.repository }}
token: ${{ gitea.token }} token: ${{ gitea.token }}
@@ -33,12 +33,6 @@ jobs:
- name: Toolchain check - name: Toolchain check
run: node scripts/check-node-toolchain.mjs 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -64,7 +58,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ gitea.repository }} repository: ${{ gitea.repository }}
token: ${{ gitea.token }} token: ${{ gitea.token }}
@@ -92,7 +86,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ gitea.repository }} repository: ${{ gitea.repository }}
token: ${{ gitea.token }} token: ${{ gitea.token }}
@@ -108,7 +102,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ gitea.repository }} repository: ${{ gitea.repository }}
token: ${{ gitea.token }} token: ${{ gitea.token }}
@@ -141,7 +135,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ gitea.repository }} repository: ${{ gitea.repository }}
token: ${{ gitea.token }} token: ${{ gitea.token }}
@@ -175,7 +169,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ gitea.repository }} repository: ${{ gitea.repository }}
token: ${{ gitea.token }} 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 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: stages:
- test - test
- build - build
-14
View File
@@ -1,14 +0,0 @@
{
"WARNING": "This file is automatically generated by Gitea Runner. Do not edit it manually unless you know what you are doing. Removing this file will cause Gitea Runner to re-register as a new runner.",
"id": 22,
"uuid": "ffb52201-e18e-4a0f-96e9-cf8a0b849365",
"name": "v5-runner",
"token": "0484b5a82d3bda9a62972a6d2646ca7cb90bc4e2",
"address": "https://gitlab.epicnabbo.nl/",
"labels": [
"self-hosted:host",
"ubuntu-latest:docker://node:18-bullseye",
"debian-latest:docker://debian:bullseye-slim"
],
"ephemeral": false
}
View File
Whitespace-only changes.
+27 -35
View File
@@ -1,50 +1,40 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Pin the runtime to the supported engine; update both stages deliberately.
FROM node:26.10.0-alpine AS migrations FROM node:26.10.0-alpine AS migrations
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Keep the bootstrap aligned with package.json packageManager.
# Installeer git, bash en pnpm v12. bash is nodig voor # The apk cache is persisted in a BuildKit cache mount so git is not
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH); # re-downloaded on every build.
# Alpine levert geen bash mee.
RUN --mount=type=cache,target=/var/cache/apk \ RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache git bash \ apk add --no-cache git \
&& npm install -g pnpm@12.10.1 && npm install -g pnpm@11.25.0
# The pnpm store is kept in a BuildKit cache mount that persists across builds
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent # on the builder. This is what stops disk usage from growing unbounded: the
ENV PNPM_HOME="/usr/local/share/pnpm" # downloaded dependency store is shared and reused instead of being copied into
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH" # a fresh image layer on every build. Unlike an image layer it is also prunable
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
# pnpm fetch: download all deps into the shared cache-mounted store.
# Voer de installatie uit met de pnpm v12 store cache-mount RUN --mount=type=cache,target=/pnpm \
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ pnpm fetch --ignore-scripts
pnpm install --frozen-lockfile --ignore-scripts # Install offline from the cache-mounted store; the store itself stays in the
# build cache between builds.
RUN --mount=type=cache,target=/pnpm \
pnpm install --frozen-lockfile --ignore-scripts --offline
COPY . . COPY . .
ARG NEXT_DEPLOYMENT_ID="unknown" ARG NEXT_DEPLOYMENT_ID="unknown"
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID" LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
FROM migrations AS builder FROM migrations AS builder
ARG NEXT_DEPLOYMENT_ID="unknown" ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID" ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# Fixture values exist only for this build command; production secrets are runtime-only.
# The build runs the webpack builder (see the `build` script in package.json). # Cache Next.js build output and webpack caches so rebuilds only redo the
# Turbopack's compiler is a single native process that grows past 12GB RSS on # changed parts.
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
# --max-old-space-size cap does NOT help, because that memory is native
# Turbopack memory rather than the V8 heap.
#
# `pnpm run build` goes through scripts/with-memory-cap.sh. BuildKit's build
# container has /sys/fs/cgroup mounted read-only (no cgroup MemoryMax) and
# `ulimit -v` breaks V8-based builds (see the script header), so this stage
# explicitly opts out of the cap. The real bound here is the webpack builder
# + the V8 heap cap above, and the build runs isolated in its own container,
# not on the host; the host itself is protected by the same wrapper through
# systemd.
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV CMS_MEMORY_CAP_BACKEND=none
# Bouw de Next.js applicatie met caching
RUN --mount=type=cache,target=/app/.next/cache \ RUN --mount=type=cache,target=/app/.next/cache \
DATABASE_URL="mysql://build:[email protected]:9/build" \ DATABASE_URL="mysql://build:[email protected]:9/build" \
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \ HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
@@ -72,6 +62,8 @@ COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migr
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
USER nextjs USER nextjs
EXPOSE 3002 EXPOSE 3002
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
# health; docker-compose overrides this with its own probe if needed.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
ENTRYPOINT ["/sbin/tini", "--"] ENTRYPOINT ["/sbin/tini", "--"]
+133 -125
View File
@@ -10,21 +10,14 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Component | Version | Notes | | Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- | | --------------- | -------------- | ---------------------------------------- |
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) | | Node.js | 26.9.0 | Current release pinned in `.nvmrc` |
| pnpm | 12.10.1 | Pinned via `packageManager` | | pnpm | >= 11.25.0 | Recommended package manager |
| npm | >= 11.x | Supported alternative | | npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative | | yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator | | MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment | | Docker | 24+ | Optional — for containerized deployment |
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE | | Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
Core stack: **Next.js 16.4.0** (App Router) · **React 19.3.0** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.3** · **Zod 4.6.5** · **Vitest 5.0.3** · **Biome 2.5.15** · **Playwright 1.63.0**.
> Builds must run through the `pnpm` scripts. This host has no swap and
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
> kernel and can take the database and the live release down with it. See
> [Memory-capped commands](#memory-capped-commands).
--- ---
## Quick Start ## Quick Start
@@ -109,10 +102,6 @@ pnpm build && pnpm start # or: npm run build && npm start
Open `http://localhost:3002` in your browser. Open `http://localhost:3002` in your browser.
> Always go through these scripts. `next build` is refused outright when it is
> not running under the memory cap — see
> [Memory-capped commands](#memory-capped-commands).
### 6. First Login ### 6. First Login
1. Register an account at `/register`, or log in with an existing emulator account. 1. Register an account at `/register`, or log in with an existing emulator account.
@@ -594,7 +583,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
### Requirements (host) ### Requirements (host)
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables). - Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (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` ### Configuration — `/docker/Polaris-imager/.env`
@@ -849,6 +838,132 @@ Open **DevOps → Anti-DDoS protection**
--- ---
## Local CrowdSec Engine (opt-in)
The repository ships a self-contained CrowdSec engine that runs on the same
Docker host. It runs `crowdsecurity/crowdsec:v1.8.1` in its own Compose project
and exposes **LAPI only** on `127.0.0.1:18080`. When enabled, the app-layer
anti-DDoS gate (`src/lib/crowdsec-local.ts`) asks the local LAPI per client IP
(short-cached) and blocks `ban` / `captcha` decisions before its own rate
buckets run. No reverse-proxy, Traefik, Cloudflare or firewall configuration is
changed.
The engine boots in **LAPI-only mode** (`DISABLE_AGENT=true`): it does not
consume the host Nginx access log and needs no outbound access to
`crowdsec.net`, which is often blocked on hardened hosts. Combined with
`DISABLE_ONLINE_API=true` (no CrowdSec Central API) the engine needs no account
and no inbound internet — blocking comes from the imported blocklists
(Step 4) and the app's own rate buckets. Re-enable the agent only on a host
with outbound internet by removing `DISABLE_AGENT: "true"` from
`deployment/crowdsec/compose.crowdsec.yml`.
### Step 1 — Enable the engine and register the bouncer
```bash
bash cms security
```
This generates `CROWDSEC_LAPI_API_KEY` (random 64 hex chars), writes the
CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer
against the local LAPI. The engine does **not** enroll into the CrowdSec
Central API (`DISABLE_ONLINE_API=true`) and runs without the agent
(`DISABLE_AGENT=true`), so it never phones home.
### Step 2 — Restart the CMS so it loads the bouncer credentials
A CI-managed `epicnext-cms-app` picks the new `.env` values up on its next
deployment. For a clone running via the updater:
```bash
bash cms update --skip-pull
```
or restart the container directly (`docker compose restart cms`). Without the
restart the gate has not loaded the LAPI URL/key yet.
### Step 3 — Verify
```bash
bash cms security status
```
Expect `CROWDSEC_LOCAL_ENABLED=yes` and `LAPI health: OK (127.0.0.1:18080)`.
In the admin panel, **DevOps → Anti-DDoS protection** shows live block
statistics split per origin (`community` vs `local`).
### Step 4 — Stop the engine again (optional)
```bash
bash cms security disable
```
Stops the container and sets `CROWDSEC_LOCAL_ENABLED=false`. Volumes and the
`.env` key are kept.
### Step 5 — Load external IP blocklists (optional)
The engine has no built-in lists, so provide your own via a one-shot sync
(fetches the sources, replaces every previous `cscli-import` decision):
```bash
bash cms security blocklists
```
Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch
Feodo/SSLBL, Darklist, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
(26 sources). URLhaus was removed because its `text_online` feed lists URLs,
not IPs; a malformed token in it could otherwise expand into a bogus
huge CIDR. The validator only accepts whole-line bare IPs or proper CIDRs,
enforces sane prefix bounds and drops reserved/private/loopback space, so a
bad source entry can never block the origin or internal traffic. The largest
commercial/crowdsourced lists (AbuseIPDB, MaxMind, Cisco Talos, AlienVault
OTX) are not included because they require an account or API key; IPsum
already aggregates ~30 additional feeds. No account is needed, but internet
access is — only for fetching; detection and blocking remain local. Sync
hourly as a cron job:
```bash
bash cms security blocklists-install-cron
```
Removal: `bash cms security blocklists-uninstall-cron`. Dry-run without
touching LAPI: `bash cms security blocklists --dry-run`. Override the sources,
duration, a combined cap or an allowlist in `.env`
(`CROWDSEC_BLOCKLIST_SOURCES`, `CROWDSEC_BLOCKLIST_DURATION`,
`CROWDSEC_BLOCKLIST_MAX_DECISIONS`, `CROWDSEC_BLOCKLIST_ALLOW`). Existing
`cscli-import` decisions are replaced on every sync, so removed entries
expire.
### Environment variables
| Variable | Default | Purpose |
| ---------------------------- | ----------------------------- | ------------------------------------ |
| `CROWDSEC_LOCAL_ENABLED` | `false` | Master switch for the local stack |
| `CROWDSEC_LAPI_URL` | `http://127.0.0.1:18080` | LAPI endpoint (loopback only) |
| `CROWDSEC_LAPI_PORT` | `18080` | Host port the engine maps to LAPI |
| `CROWDSEC_LAPI_API_KEY` | — | Bouncer key; required when enabled |
| `CROWDSEC_LAPI_TIMEOUT_MS` | `500` | Per-decision request timeout |
| `CROWDSEC_LAPI_RETRY_MS` | `500` | Backoff before retrying LAPI |
| `CROWDSEC_NGINX_LOG_DIR` | `/var/log/nginx` | Access-log directory for the engine |
### Notes and limitations
- Changing the port means updating `CROWDSEC_LAPI_PORT` **and**
`CROWDSEC_LAPI_URL` together, then re-running `bash cms security`.
- Rotate the key by editing `CROWDSEC_LAPI_API_KEY` in `.env`, running
`bash cms security` again (re-registers the bouncer) and restarting the CMS.
- The gate is **fail-closed at startup** when the feature is enabled without a
key (startup aborts with a clear message). At runtime a LAPI network error
**fails open** (traffic is allowed, decisions paused); a 403 from LAPI
pauses local decisions for 5 minutes.
- This bouncer is **application-layer**: it sheds known-bad IPs at the CMS
process only. It does not drop traffic before the origin, does not protect
other host ports/services, and depends on the client IP being trustworthy at
the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate
limits) for defense before the origin.
---
## Production Deployment (blue/green) ## Production Deployment (blue/green)
@@ -886,24 +1001,6 @@ that the CI deploy path deliberately uses `docker run` rather than compose, so
the resource limits are declared in **both** places — limits that only existed the resource limits are declared in **both** places — limits that only existed
in compose would never apply to a real release. 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 ### The nginx upstream is the switch
nginx does not know about container names; it reads a plain list of backends from nginx does not know about container names; it reads a plain list of backends from
@@ -962,99 +1059,15 @@ branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
--- ---
## Memory-capped commands
This host runs with `vm.overcommit_memory=0` **and no swap**. When a process
asks for more memory than is free, the kernel does not wait — it calls the
OOM-killer immediately, and it picks its victim across the **whole machine**,
not just the offending process. An uncapped build does not merely fail: it can
take the MariaDB process, nginx and the live release down with it.
Every heavy command therefore runs inside its own cgroup with a hard
`MemoryMax`, via `scripts/with-memory-cap.sh`. If the build outgrows its
ceiling, only that cgroup is killed — the build fails, the site keeps serving.
| 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 ## Scripts
| Command | Description | | Command | Description |
| ------------------------- | -------------------------------------------------- | | ------------------------- | -------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) | | `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build (memory-capped) | | `pnpm build` | Production build |
| `pnpm start` | Start production server | | `pnpm start` | Start production server |
| `pnpm typecheck` | Run TypeScript type checking | | `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run all tests (Vitest) | | `pnpm test` | Run all tests (Vitest) |
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
| `pnpm test:e2e` | Playwright end-to-end tests |
| `pnpm test:integration` | Integration tests (own Vitest config) |
| `pnpm test:housekeeping` | Housekeeping feature tests |
| `pnpm lint` | Lint and format check (Biome) |
| `pnpm biome:lint` | Alias of `pnpm lint` |
| `pnpm format` | Format files in place (Biome) |
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
| `pnpm i18n:check` | Audit CMS translation coverage |
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
| `pnpm db:migrate` | Apply pending SQL migrations | | `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status | | `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB | | `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
@@ -1064,16 +1077,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:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container | | `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) | | `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + report per-route bundle sizes | | `pnpm analyze` | Build + open bundle analyzer |
| `pnpm performance:report` | Re-render the performance report from a build |
| `pnpm jobs:worker` | Start background task worker | | `pnpm jobs:worker` | Start background task worker |
| `pnpm 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. > 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 +1206,7 @@ The CMS automatically translates furniture names and descriptions to **13 langua
pnpm dev # Start with hot reload pnpm dev # Start with hot reload
pnpm typecheck # Type check all files pnpm typecheck # Type check all files
pnpm test # Run test suite pnpm test # Run test suite
pnpm analyze # Build and report per-route bundle sizes pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format 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": { "css": {
+3 -2
View File
@@ -4,6 +4,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "${1:-help}" in case "${1:-help}" in
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;; install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
update) shift; exec bash "$DIR/scripts/docker-update.sh" "$@" ;; update) shift; exec bash "$DIR/scripts/docker-update.sh" "$@" ;;
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' ;; security) shift; exec bash "$DIR/scripts/crowdsec-setup.sh" "$@" ;;
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;; help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' 'bash cms security Configure the opt-in local CrowdSec stack (enable|status|disable|blocklists)' ;;
*) echo "Unknown command. Use: bash cms install | update | security" >&2; exit 1 ;;
esac esac
+4
View File
@@ -0,0 +1,4 @@
filenames:
- /var/log/nginx/access.log
labels:
type: nginx
+41
View File
@@ -0,0 +1,41 @@
services:
crowdsec:
image: crowdsecurity/crowdsec:${CROWDSEC_VERSION:-v1.8.1}
container_name: epicnext-crowdsec
restart: unless-stopped
profiles: ["security"]
environment:
DISABLE_AGENT: "true"
BOUNCER_KEY_cms: ${CROWDSEC_LAPI_API_KEY:?CROWDSEC_LAPI_API_KEY must be set}
DISABLE_ONLINE_API: "true"
GID: "${CROWDSEC_GID:-0}"
TZ: "${TZ:-UTC}"
ports:
- "${CROWDSEC_LAPI_BIND_HOST:-127.0.0.1}:${CROWDSEC_LAPI_PORT:-18080}:8080"
volumes:
- ./acquis.d:/etc/crowdsec/acquis.d:ro
- ${CROWDSEC_NGINX_LOG_DIR:-/var/log/nginx}:/var/log/nginx:ro
- crowdsec-config:/etc/crowdsec
- crowdsec-data:/var/lib/crowdsec/data
security_opt:
- no-new-privileges:true
pids_limit: 256
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test:
[
"CMD-SHELL",
"wget -q -O - http://127.0.0.1:8080/health >/dev/null 2>&1",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
crowdsec-config:
crowdsec-data:
+1 -133
View File
@@ -78,23 +78,6 @@ map "$cms_cc_class|$cms_skip_cache" $cms_public_cc {
"~^3\|0" "public, max-age=300, s-maxage=300, stale-while-revalidate=600"; "~^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 ─── # ─── Mime fix ───
types { types {
application/json jsonc; application/json jsonc;
@@ -179,22 +162,6 @@ server {
ssl_early_data on; ssl_early_data on;
add_header Alt-Svc 'h3=":9443"; ma=86400' always; 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; index index.html;
# ─── Security Headers ─── # ─── Security Headers ───
@@ -393,113 +360,14 @@ server {
return 404; 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/ { location /camera/ {
alias /var/www/Camera/; alias /var/www/Camera/;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Tag "cms-camera"; 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 = /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 ─── # ─── Static Next.js Assets ───
location /_next/static/ { location /_next/static/ {
@@ -1,39 +0,0 @@
[Unit]
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
# and health probes). This is NOT optional: a web process alone does not
# establish that scheduled work runs. The CMS reports it as a failed
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
# missing, which is exactly what happened while nothing supervised this.
#
# It runs on the host rather than in a container on purpose: the schedule
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
# and it must survive CMS deploys (a container is replaced on every release).
Description=AtomNext CMS scheduled-job worker
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
After=network-online.target docker.service mariadb.service
Wants=network-online.target
# Start ordering only; the worker tolerates the database being briefly absent
# and retries, so do not make it hard-fail when mariadb is slow to boot.
Wants=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/var/www/atom-nexst
Environment=NODE_ENV=production
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
# bad DATABASE_URL) from spinning.
Restart=always
RestartSec=10
# Give a crashed job time to finish its DB transaction before the next start,
# otherwise a mid-transaction kill can loop on the same failure.
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cms-jobs-worker
[Install]
WantedBy=multi-user.target
-19
View File
@@ -1,19 +0,0 @@
[Service]
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
# inherits that soft limit, so worker_connections 2048 could not actually be
# reached and every start logged:
# "2048 worker_connections exceed open file resource limit: 1024"
# Raise both soft and hard to 65536 so the master's rlimit covers
# worker_connections before nginx is even started.
LimitNOFILE=65536
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
# down until someone noticed. nginx is the only thing serving the site, so it
# must come back on its own. `on-failure` restarts only abnormal exits, which
# keeps an operator-initiated `systemctl stop` from being undone.
Restart=on-failure
RestartSec=2
# Give in-flight requests time to drain on stop/reload instead of severing
# keepalive connections and long-polling SSE streams mid-response.
TimeoutStopSec=30
+40 -10
View File
@@ -1,22 +1,30 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Next.js CMS — blue/green # Next.js CMS — blue/green
#
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
#
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
# nieuwe replica, dan blijft de oude gewoon draaien.
#
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
# afwijkende velden opnieuw in.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
x-cms: &cms x-cms: &cms
image: epicnext-cms:${CMS_RELEASE:-local} 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: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown} NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# Runtime host networking IS required: blue/green needs per-release host ports network: host
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
network_mode: host network_mode: host
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
# volgt. Met 10s werden streams en imports afgekapt.
stop_grace_period: 15s stop_grace_period: 15s
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -28,11 +36,20 @@ x-cms: &cms
- /var/www/Gamedata:/var/www/Gamedata - /var/www/Gamedata:/var/www/Gamedata
# ── Resource limits ── # ── Resource limits ──
mem_limit: 6g # De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
memswap_limit: 7g # gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
# volledig door twee replicas worden opgeëist.
mem_limit: 4g
memswap_limit: 5g
cpus: 2.0 cpus: 2.0
pids_limit: 512 pids_limit: 512
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 15s interval: 15s
@@ -41,6 +58,7 @@ x-cms: &cms
start_period: 40s start_period: 40s
services: services:
# Blauwe replica: host-poort 3002.
cms: cms:
<<: *cms <<: *cms
container_name: epicnext-cms container_name: epicnext-cms
@@ -48,6 +66,8 @@ services:
- HOSTNAME=0.0.0.0 - HOSTNAME=0.0.0.0
- PORT=3002 - PORT=3002
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
cms-green: cms-green:
<<: *cms <<: *cms
container_name: epicnext-cms-green container_name: epicnext-cms-green
@@ -56,6 +76,7 @@ services:
- HOSTNAME=0.0.0.0 - HOSTNAME=0.0.0.0
- PORT=3003 - PORT=3003
# ── Byparr (Cloudflare bypass for clone sources) ──
byparr: byparr:
image: ghcr.io/thephaseless/byparr:latest image: ghcr.io/thephaseless/byparr:latest
container_name: byparr container_name: byparr
@@ -63,10 +84,19 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
pids_limit: 256 pids_limit: 256
healthcheck: healthcheck:
test: ["CMD", "curl", "http://localhost:8191/health"] test: ["CMD", "curl", "http://localhost:8191/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 30s start_period: 30s
# De database draait niet meer in Docker. `mariadb-turbo` is verwijderd: de
# service is nooit gestart, de volume bestond niet, en de echte MariaDB draait
# al als host-proces op 127.0.0.1:3306. De optimalisatie-vlaggen daar stonden
# dus al langer niets meer in beheer.
+18
View File
@@ -85,6 +85,24 @@ The direct template intentionally records the CDN/edge socket address when place
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall. `pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
## Opt-in: CrowdSec on the same Docker host
A self-contained CrowdSec engine ships in `deployment/crowdsec`. It runs `crowdsecurity/crowdsec:v1.8.1` in its own Compose project in **LAPI-only mode** (`DISABLE_AGENT=true`) and exposes LAPI only on `127.0.0.1:18080`. No reverse-proxy, Traefik, Cloudflare or firewall configuration is changed.
```sh
bash cms security
```
The command generates `CROWDSEC_LAPI_API_KEY`, writes the CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer. The anti-DDoS gate then consults the local LAPI per client IP (short-cached) and blocks `ban`/`captcha` decisions before its own rate buckets. `bash cms security status` reports engine state and `bash cms security disable` stops the engine and flips the toggle off.
The engine does not enroll into the CrowdSec Central API (`DISABLE_ONLINE_API=true`) and runs without the agent (`DISABLE_AGENT=true`), so it needs no account and no outbound access to `crowdsec.net` (often blocked on hardened hosts). Blocking comes from the imported blocklists plus the app's own rate buckets; it does not parse the host Nginx log. The app still has its separate opt-in traffic-sharing channel via `CROWDSEC_REPORT_ENABLED`. Change `CROWDSEC_LAPI_PORT` and `CROWDSEC_LAPI_URL` together when `18080` is already in use. `CROWDSEC_NGINX_LOG_DIR` is honored for when the agent is re-enabled.
This bouncer is application-layer: it sheds known-bad IPs at the CMS process and only for traffic that reaches the Next.js proxy. It does not drop traffic before the origin, does not protect other host ports/services, and depends on the client IP being trustworthy at the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate limits) for defense before the origin.
The running CMS loads the new env values on its next restart or deployment. For a CI-managed `epicnext-cms-app`, the next deploy (which sources `.env`) applies them; for a clone, `bash cms update --skip-pull` restarts it. `.env` now holds the LAPI key — keep its permissions restrictive.
External IP blocklists (Spamhaus, DShield, CINS, blocklist.de, abuse.ch, IPsum, Firehol, Tor exit nodes, …) can be synced into the local LAPI with `bash cms security blocklists`, and hourly with `bash cms security blocklists-install-cron` (no account, but internet to fetch). Configure via `CROWDSEC_BLOCKLIST_*`.
## Routine and selected-release updates ## Routine and selected-release updates
```sh ```sh
+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. 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 **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope. - Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes. - Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total. - Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from 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. - This report makes no claims about execution cost, LCP, hydration time or real-user performance.
## Initial limits ## Initial limits
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred. The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit | | Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
| --- | ---: | ---: | ---: | ---: | | --- | ---: | ---: | ---: | ---: |
@@ -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 await page
.locator('input[autocomplete="current-password"]') .locator('input[autocomplete="current-password"]')
.press("Enter"); .press("Enter");
// The login page honours `?from=`, so an admin bounced off /admin lands await expect(page).toHaveURL(/\/me(?:\?|$)/);
// back where they were heading instead of on /me. The step below
// navigates there explicitly anyway; this asserts the redirect target.
await expect(page).toHaveURL(/\/admin\/articles\/new(?:\?|$)/);
const session = await context.request const session = await context.request
.get("/api/auth/session") .get("/api/auth/session")
.then((response) => response.json()); .then((response) => response.json());
+6 -4
View File
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = { const file = {
name: "fixture_chair.hab", name: "fixture_chair.nitro",
mimeType: "application/octet-stream", mimeType: "application/octet-stream",
buffer: Buffer.from("isolated upload fixture"), buffer: Buffer.from("isolated upload fixture"),
}; };
@@ -23,7 +23,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
expect(route.request().postData()).toContain('name="classname"'); expect(route.request().postData()).toContain('name="classname"');
expect(route.request().postData()).toContain("fixture_chair"); expect(route.request().postData()).toContain("fixture_chair");
expect(route.request().postData()).toContain( expect(route.request().postData()).toContain(
'filename="fixture_chair.hab"', 'filename="fixture_chair.nitro"',
); );
await route.fulfill({ json: { ok: true, attachmentId } }); await route.fulfill({ json: { ok: true, attachmentId } });
}); });
@@ -38,7 +38,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ json: { ok: true } }); await route.fulfill({ json: { ok: true } });
}); });
await page.getByLabel("Choose the original .hab file").setInputFiles(file); await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
await expect( await expect(
page.getByText("Matching original file attached", { exact: true }), page.getByText("Matching original file attached", { exact: true }),
).toBeVisible(); ).toBeVisible();
@@ -94,7 +94,9 @@ for (const scenario of [
}) })
: route.abort("failed"), : 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("alert")).toContainText(scenario.message);
await expect( await expect(
page.getByRole("button", { 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();
});
+2 -17
View File
@@ -156,14 +156,7 @@ beforeAll(async () => {
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`; process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
delete process.env.SKIP_ENV_VALIDATION; delete process.env.SKIP_ENV_VALIDATION;
delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY;
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and Object.assign(process.env, { NODE_ENV: "test" });
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
// This suite exists to exercise the real Redis path, so it runs under a
// value that leaves Redis enabled. "development" is used because it is the
// only non-production value src/env.ts accepts. Vitest's own environment is
// still configured via vitest.integration.config.ts. Object.assign is used
// because process.env.NODE_ENV is typed read-only.
Object.assign(process.env, { NODE_ENV: "development" });
process.env.HOTEL_NAME = "Integration"; process.env.HOTEL_NAME = "Integration";
await connection.query( await connection.query(
@@ -387,8 +380,7 @@ describe("Redis application cache", () => {
let fetches = 0; let fetches = 0;
const fetch = async () => ({ revision: ++fetches }); const fetch = async () => ({ revision: ++fetches });
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 }); expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
// Second read is served from cache, so the origin is not consulted again. expect(await appRedis?.get(key)).toBe('{"revision":1}');
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
expect(await appRedis?.ttl(key)).toBeGreaterThan(0); expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
cache.invalidateMemory(key); cache.invalidateMemory(key);
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 }); expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
@@ -409,9 +401,6 @@ describe("Redis application cache", () => {
expect(await cache.cached(first, 60_000, async () => "updated")).toBe( expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
"updated", "updated",
); );
// `second`'s memory copy was dropped too, but its Redis entry survives, so
// the read is served from the shared cache and never recomputes. This is
// what makes the two entries independent.
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe( expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
"second", "second",
); );
@@ -629,9 +618,6 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(existing.status).toBe("draft"); expect(existing.status).toBe("draft");
expect(existing.publishedAt).toBeNull(); expect(existing.publishedAt).toBeNull();
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull(); expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
// A draft has no public article, so this read is a negative result that
// gets cached. Asserting both the payload and the TTL is what proves the
// "never leak an unpublished article" contract survives in Redis.
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY); const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`; const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
expect(await appRedis?.get(negativeKey)).toBe("null"); expect(await appRedis?.get(negativeKey)).toBe("null");
@@ -851,7 +837,6 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull(); expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
const negativeRevision = await redis.get(NEWS_REVISION_KEY); const negativeRevision = await redis.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`; const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
// Cached negative results are stored as the JSON encoding of null.
expect(await redis.get(negativeKey)).toBe("null"); expect(await redis.get(negativeKey)).toBe("null");
const publish = articleForm({ const publish = articleForm({
id: String(existing.id), id: String(existing.id),
+1 -46
View File
@@ -1,47 +1,7 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { PHASE_PRODUCTION_BUILD } from "next/constants";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
/**
* This host runs with `vm.overcommit_memory=0` and no swap, so a process that
* asks for more memory than is free gets OOM-killed by the kernel immediately.
* The killer picks its victim across the WHOLE machine — an unbounded build can
* take down the database, nginx and the live release with it.
*
* `scripts/with-memory-cap.sh` runs a heavy command in its own cgroup with a
* hard `MemoryMax`, so only that build dies and the site keeps serving. Every
* script in package.json goes through it.
*
* The one hole that leaves is running the builder by hand: `npx next build`,
* `pnpm exec next build`, or an IDE/agent task invoking it directly skips the
* wrapper entirely and is unbounded. This guard closes that. `next build`
* loads the config, so refusing here stops the build before it allocates
* anything. See the header of scripts/with-memory-cap.sh.
*/
function assertMemoryCapped(phase: string): void {
if (phase !== PHASE_PRODUCTION_BUILD) return;
if (process.env.CMS_MEMORY_CAPPED === "1") return;
throw new Error(
[
"Refusing to run an uncapped production build.",
"",
"On this host an unbounded `next build` gets OOM-killed by the kernel,",
"and the killer may take the database, nginx or the live release with it.",
"",
"Use the capped build instead:",
" pnpm build",
"",
"It runs the builder through scripts/with-memory-cap.sh, which puts it in",
"its own cgroup with a MemoryMax, so a runaway build fails alone.",
"",
"Already inside an isolated environment (Docker, a CI runner) where the",
"container itself is the boundary? Set CMS_MEMORY_CAPPED=1 explicitly.",
].join("\n"),
);
}
const getGitCommit = () => { const getGitCommit = () => {
try { try {
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
@@ -214,9 +174,4 @@ const nextConfig: NextConfig = {
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
// Exported as a function so the build phase is known before the config is export default withNextIntl(nextConfig);
// used. next-intl only accepts a plain object, so it is applied here.
export default function config(phase: string) {
assertMemoryCapped(phase);
return withNextIntl(nextConfig);
}
+35 -35
View File
@@ -5,10 +5,10 @@
"engines": { "engines": {
"node": ">=26.10.0 <27" "node": ">=26.10.0 <27"
}, },
"packageManager": "pnpm@12.10.1", "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
"scripts": { "scripts": {
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev", "dev": "pnpm assets:editor && next dev",
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack", "build": "pnpm assets:editor && next build",
"start": "next start", "start": "next start",
"toolchain:check": "node scripts/check-node-toolchain.mjs", "toolchain:check": "node scripts/check-node-toolchain.mjs",
"lint": "biome check .", "lint": "biome check .",
@@ -16,9 +16,9 @@
"format": "biome format --write .", "format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts", "diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts", "jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false", "test": "vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run", "test:coverage": "vitest run",
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect", "db:introspect": "drizzle-kit introspect",
"db:bulk": "tsx scripts/bulk-import-json.ts", "db:bulk": "tsx scripts/bulk-import-json.ts",
@@ -30,27 +30,27 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs", "gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts", "hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "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", "assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high", "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:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs", "i18n:audit": "node scripts/audit-cms-translations.mjs",
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test", "test:e2e": "playwright test",
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts", "test:news:real": "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": "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:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs", "performance:report": "node scripts/performance-report.mjs",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts" "test:integration": "vitest run --config vitest.integration.config.ts"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "1.8.0", "@base-ui/react": "1.8.0",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.21", "@formatjs/icu-messageformat-parser": "3.5.20",
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.1", "@tanstack/react-query": "5.104.0",
"@tanstack/react-virtual": "3.14.13", "@tanstack/react-virtual": "3.14.13",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -59,53 +59,53 @@
"drizzle-orm": "0.45.3", "drizzle-orm": "0.45.3",
"hash-wasm": "4.12.0", "hash-wasm": "4.12.0",
"ioredis": "6.0.0", "ioredis": "6.0.0",
"isomorphic-dompurify": "^4.5.0", "isomorphic-dompurify": "^4.4.0",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"jszip": "3.10.2", "jszip": "3.10.2",
"lucide-react": "1.52.0", "lucide-react": "1.48.0",
"lzma-wasm": "1.0.7", "lzma-wasm": "1.0.7",
"motion": "14.0.0", "motion": "13.4.4",
"music-metadata": "12.0.0", "music-metadata": "11.16.1",
"mysql2": "3.24.5", "mysql2": "3.24.4",
"next": "16.4.0", "next": "16.3.6",
"next-auth": "5.0.0-beta.32", "next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9", "next-intl": "4.14.7",
"otplib": "13.5.0", "otplib": "13.5.0",
"pino": "10.4.0", "pino": "10.3.1",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-hook-form": "7.89.0", "react-hook-form": "7.89.0",
"resend": "6.32.1", "resend": "6.30.0",
"server-only": "0.0.1", "server-only": "0.0.1",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"sonner": "2.0.8", "sonner": "2.0.8",
"tailwind-merge": "3.7.0", "tailwind-merge": "3.7.0",
"tinymce": "8.9.3", "tinymce": "8.9.2",
"zod": "4.6.5" "zod": "4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@babel/parser": "8.0.7", "@babel/parser": "7.29.9",
"@biomejs/biome": "2.5.15", "@biomejs/biome": "2.5.14",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@tailwindcss/typography": "0.5.20", "@tailwindcss/typography": "0.5.20",
"@types/node": "26.6.4", "@types/node": "26.6.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "5.0.3", "@vitest/coverage-v8": "5.0.2",
"drizzle-kit": "0.31.11", "drizzle-kit": "0.31.11",
"esbuild": "0.28.2", "esbuild": "0.28.2",
"msw": "^2.15.0", "msw": "2.15.0",
"pino-pretty": "13.2.0", "pino-pretty": "13.1.3",
"postcss": "8.5.29", "postcss": "8.5.28",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"testcontainers": "12.2.0", "testcontainers": "12.1.0",
"tsx": "4.23.15", "tsx": "4.23.15",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.3.3", "vite": "8.3.1",
"vitest": "5.0.3" "vitest": "5.0.2"
} }
} }
+391 -465
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -16,4 +16,3 @@ overrides:
glob: '^11.0.0' glob: '^11.0.0'
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15' '@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': '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. const CACHE = "atom-v3";
// const API_CACHE = "atom-api-v3";
// The app used to register a worker that cached /_next/static/ and /assets/
// cache-first under a cache name with no build id. A release could not
// invalidate it, so browsers replayed the previous release's chunks against
// the new HTML and React never hydrated, which is what left the Catalog
// Studio on a blank white page. It also bought nothing: nginx already sends
// those two prefixes as `max-age=31536000, immutable` and their filenames are
// content-hashed, so the HTTP cache already handles them correctly.
//
// This file must stay at /sw.js and keep its install/activate handlers.
// Simply deleting it would leave every existing registration alive and in
// control of the page, with the old caching still running. Instead this
// worker does the opposite of what it used to: it deletes every cache and
// unregisters itself, then reloads open clients so they stop being
// controlled by it.
//
// Nothing registers it any more (see src/app/layout.tsx), so this only ever
// runs for browsers that already have a worker installed. Once a visitor
// loads the site again it uninstalls itself and never comes back.
//
// The manifest is unrelated and untouched — it is generated by
// src/app/manifest.ts and keeps the site installable without a worker.
self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("install", () => self.skipWaiting());
@@ -28,13 +7,61 @@ self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches caches
.keys() .keys()
.then((keys) => Promise.all(keys.map((key) => caches.delete(key)))) .then((keys) =>
.then(() => self.registration.unregister()) Promise.all(
.then(() => keys
self.clients.matchAll({ type: "window", includeUncontrolled: true }), .filter((k) => k !== CACHE && k !== API_CACHE)
.map((k) => caches.delete(k)),
),
) )
.then((clients) => { .then(() => self.clients.claim()),
for (const client of clients) client.navigate(client.url);
}),
); );
}); });
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (
url.pathname.startsWith("/assets/") ||
url.pathname.startsWith("/_next/static/")
) {
event.respondWith(
caches.open(CACHE).then((cache) =>
cache.match(req).then(
(hit) =>
hit ||
fetch(req).then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
}),
),
),
);
return;
}
if (
url.pathname.startsWith("/api/home") ||
url.pathname.startsWith("/api/online") ||
url.pathname.startsWith("/api/radio/config")
) {
event.respondWith(
caches.open(API_CACHE).then((cache) =>
fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
})
.catch(() => cache.match(req)),
),
);
return;
}
if (req.mode === "navigate") {
event.respondWith(fetch(req));
}
});
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
ENV_FILE="$DIR/.env"
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
PROJECT_NAME="epicnext-crowdsec"
CONTAINER_NAME="epicnext-crowdsec"
DEFAULT_DURATION="24h"
DEFAULT_MAX_DECISIONS="250000"
DEFAULT_SOURCES=(
# DDoS / abuse stoplists
"https://www.spamhaus.org/drop/drop.txt"
"https://www.spamhaus.org/drop/edrop.txt"
"https://www.dshield.org/block.txt"
"https://cinsscore.com/list/ci-badguys.txt"
"https://blocklist.greensnow.co/greensnow.txt"
"https://www.stopforumspam.com/downloads/toxic_ip_cidr.txt"
"https://www.binarydefense.com/banlist.txt"
# Brute force / credential stuffing
"https://lists.blocklist.de/lists/all.txt"
"https://lists.blocklist.de/lists/ssh.txt"
"https://lists.blocklist.de/lists/apache.txt"
"https://rules.emergingthreats.net/blockrules/compromised-ips.txt"
"https://danger.rulez.sk/projects/bruteforceblocker/blist.php"
# Malware C2 / botnets
"https://feodotracker.abuse.ch/downloads/ipblocklist.txt"
"https://sslbl.abuse.ch/blacklist/sslipblacklist.txt"
"https://www.botvrij.eu/data/ioclist.ip-dst.raw"
# Live SSH/spam attackers (last 48h), bare IPs only
"https://www.darklist.de/raw.php"
# Aggregated threat intel
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt"
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/2.txt"
# Firehol ipsets (security scanners, abusers, proxies, anonymous)
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level2.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_abusers_1d.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_abusers_30d.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_proxies.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_anonymous.netset"
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level3.netset"
# Tor exit nodes
"https://check.torproject.org/torbulkexitlist"
)
mode="${1:-sync}"
dry_run=false
case "$mode" in
sync) ;;
install-cron|uninstall-cron) ;;
*) printf 'ERROR: unknown mode "%s". Modes: sync [--dry-run] | install-cron | uninstall-cron\n' "$mode" >&2; exit 1 ;;
esac
[[ "${2:-}" = --dry-run ]] && dry_run=true
umask 077
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
for command in docker curl flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
exec 9>"$DIR/.deploy.lock"
flock -w 30 9 || fail "Another installation, update or sync is running."
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
env_get() {
local key="$1" line
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
esac
done < "$ENV_FILE"
return 1
}
compose_cmd() {
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
}
container_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
}
cscli_exec() {
compose_cmd exec -T crowdsec cscli "$@"
}
fetch_sources() {
local target="$1"
local sources=( "${DEFAULT_SOURCES[@]}" )
IFS=' ' read -r -a parsed <<< "${CROWDSEC_BLOCKLIST_SOURCES:-}"
[[ "${#parsed[@]}" -gt 0 ]] && sources=( "${parsed[@]}" )
local index=0 url
for url in "${sources[@]}"; do
[[ -n "$url" ]] || continue
index=$((index + 1))
if ! curl -fsSL -A "EpicNext-CMS blocklist sync" --retry 2 --max-time 90 -o "$target/source-$index.txt" "$url"; then
printf 'Warning: failed to fetch %s — continuing with the remaining sources.\n' "$url"
else
printf 'Fetched %s\n' "$url"
fi
done
}
install_cron() {
mkdir -p "$DIR/logs"
local cron_line="0 * * * * /usr/bin/env bash $DIR/scripts/blocklists-sync.sh >> $DIR/logs/blocklists-sync.log 2>&1"
if crontab -l 2>/dev/null | grep -Fq "$DIR/scripts/blocklists-sync.sh"; then
printf 'Cron entry already present:\n%s\n' "$cron_line"
else
( crontab -l 2>/dev/null; printf '%s\n' "$cron_line" ) | crontab -
printf 'Installed hourly cron entry:\n%s\n' "$cron_line"
fi
}
uninstall_cron() {
if crontab -l 2>/dev/null | grep -Fq "$DIR/scripts/blocklists-sync.sh"; then
crontab -l 2>/dev/null | grep -Fv "$DIR/scripts/blocklists-sync.sh" | crontab -
printf 'Removed cron entry matching %s.\n' "$DIR/scripts/blocklists-sync.sh"
else
printf 'No cron entry to remove.\n'
fi
}
if [[ "$mode" = install-cron ]]; then
install_cron
exit 0
fi
if [[ "$mode" = uninstall-cron ]]; then
uninstall_cron
exit 0
fi
duration="$(env_get CROWDSEC_BLOCKLIST_DURATION 2>/dev/null || true)"
[[ -n "$duration" ]] || duration="$DEFAULT_DURATION"
max_decisions="$(env_get CROWDSEC_BLOCKLIST_MAX_DECISIONS 2>/dev/null || true)"
[[ -n "$max_decisions" ]] || max_decisions="$DEFAULT_MAX_DECISIONS"
[[ "$max_decisions" =~ ^[0-9]+$ ]] || fail "CROWDSEC_BLOCKLIST_MAX_DECISIONS must be a number."
allowlist="${CROWDSEC_BLOCKLIST_ALLOW:-$(env_get CROWDSEC_BLOCKLIST_ALLOW 2>/dev/null || true)}"
work="$(mktemp -d "$DIR/.blocklists.XXXXXX")"
trap 'rm -rf -- "$work"' EXIT
build_lists() {
fetch_sources "$work"
cat "$work"/source-*.txt 2>/dev/null | awk '{print $1}' \
| grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$|^([0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F:]*[0-9a-fA-F](/[0-9]{1,3})?$' \
| awk '
# Only globally routable attacker space may become a decision. Reserved,
# private, loopback, link-local, CGNAT, test and multicast ranges never
# represent an external attacker and must not be imported (they could
# otherwise block the origin itself or internal traffic).
function isReserved4(prefix, a, b, c) {
if (a == 0 || a == 127 || a >= 224) return 1
if (a == 10) return 1
if (a == 100 && (prefix < 10 || (prefix >= 10 && b >= 64 && b <= 127))) return 1
if (a == 169 && (prefix < 16 || (prefix >= 16 && b == 254))) return 1
if (a == 172 && (prefix < 12 || (prefix >= 12 && b >= 16 && b <= 31))) return 1
if (a == 192 && b == 168) return 1
if (a == 192 && b == 0) return 1
if ((a == 198 && (b == 18 || b == 19)) || (a == 198 && b == 51 && c == 100)) return 1
if (a == 203 && b == 0 && c == 113) return 1
return 0
}
function isReserved6(line, prefix, first, h) {
if (prefix < 32) return 1
if (line ~ /^::/) return 1
first = tolower(line); sub(/^::?/, "", first); sub(/:.*/, "", first)
h = "0x" substr(first, 1, 2)
if (h >= 252) return 1 # ULA fc00::/7, link-local fe80::/10, multicast ff00::/8
return 0
}
{
if (index($0, "/") > 0) {
n = split($0, seg, "/")
if (n != 2 || seg[2] !~ /^[0-9]+$/) next
if (index(seg[1], ":") > 0) {
pref = seg[2] + 0
if (pref < 32 || pref > 128) next
if (isReserved6(seg[1], pref)) next
print
next
}
pref = seg[2] + 0
if (pref < 8 || pref > 32) next
split(seg[1], oct, ".")
ok = 1
for (i = 1; i <= 4; i++) {
if (oct[i] !~ /^[0-9]+$/ || oct[i] + 0 > 255) { ok = 0; break }
if (length(oct[i]) > 1 && oct[i] ~ /^0/) { ok = 0; break }
}
if (!ok) next
if (isReserved4(pref, oct[1] + 0, oct[2] + 0, oct[3] + 0)) next
print
next
}
if (index($0, ":") > 0) {
if (isReserved6($0, 128)) next
print
next
}
n = split($0, part, ".")
if (n != 4) next
ok = 1
for (i = 1; i <= 4; i++) {
if (part[i] !~ /^[0-9]+$/ || part[i] + 0 > 255) { ok = 0; break }
if (length(part[i]) > 1 && part[i] ~ /^0/) { ok = 0; break }
}
if (!ok) next
if (isReserved4(32, part[1] + 0, part[2] + 0, part[3] + 0)) next
print
}' \
| sort -u > "$work/candidates.txt"
if [[ -n "$allowlist" ]]; then
printf '%s\n' "$allowlist" | tr ',' '\n' | while IFS= read -r line; do printf '%s\n' "$line"; done | sort -u > "$work/allow.txt"
comm -23 "$work/candidates.txt" "$work/allow.txt" > "$work/final.txt"
else
cp "$work/candidates.txt" "$work/final.txt"
fi
if [[ "$(wc -l < "$work/final.txt" | tr -d ' ')" -gt "$max_decisions" ]]; then
sort -u "$work/final.txt" | head -n "$max_decisions" > "$work/final.limited.txt" || true
mv "$work/final.limited.txt" "$work/final.txt"
printf 'Note: capped the combined list at %s decisions (CROWDSEC_BLOCKLIST_MAX_DECISIONS).\n' "$max_decisions"
fi
count_total=$(wc -l < "$work/final.txt" | tr -d ' ')
count_ip=$(grep -cv '/' "$work/final.txt" || true)
count_range=$(grep -c '/' "$work/final.txt" || true)
if [[ "$count_total" -lt 1 ]]; then
fail "No valid addresses could be parsed from the configured sources. Configure CROWDSEC_BLOCKLIST_SOURCES."
fi
}
build_lists
printf 'Parsed %s targets (%s IPs, %s ranges).\n' "$count_total" "$count_ip" "$count_range"
if $dry_run; then
printf 'Dry run: would replace the cscli-import decisions with these %s targets.\n' "$count_total"
exit 0
fi
container_running || fail "The CrowdSec engine is not running. Start it first with: bash cms security"
printf 'Removing previous cscli-import decisions...\n'
cscli_exec decisions delete --origin cscli-import >/dev/null 2>&1 || true
{
printf 'duration,scope,value\n'
awk -v d="$duration" '{ if (index($0, "/") > 0) printf "%s,range,%s\n", d, $0; else printf "%s,ip,%s\n", d, $0 }' "$work/final.txt"
} > "$work/import.csv"
printf 'Importing %s decisions into the local LAPI (duration %s)...\n' "$count_total" "$duration"
cscli_exec decisions import -i - --format csv --batch 1000 < "$work/import.csv"
printf 'Done. The app bouncer picks these up within a few seconds.\n'
+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")) { if (!process.argv.includes("--static")) {
const active = process.versions.node; assert.equal(
const upperBound = `${major + 1}.0.0`; process.versions.node,
assert.ok( pinnedVersion,
cmpVersion(active, pinnedVersion) >= 0 && "the active Node.js runtime must match .nvmrc",
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`,
); );
} }
-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 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 # 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 # oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
# niet stilvalt op een upgrade. # niet stilvalt op een upgrade.
@@ -92,156 +85,29 @@ detect_blue_green() {
return 0 return 0
} }
# Welke poort is op dit moment ÉCHT live? # 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
# Volgorde van vertrouwen: # slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het # in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg. # terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
#
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
# container die toevallig een andere release draaide.
read_active_port() { read_active_port() {
local port="" pointed="" local live="" port="" result=""
local count=0
if [ -r "$upstream_file" ]; then for port in "$slot_a_port" "$slot_b_port"; do
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)" if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
fi live="$live $port"
if [ -n "$port" ] && answers_health "$port"; then
printf '%s' "$port"
return 0
fi
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
# enige andere gezonde slot, anders is er niets om op te bouwen.
for candidate in "$slot_a_port" "$slot_b_port"; do
[ "$candidate" = "$port" ] && continue
if answers_health "$candidate"; then
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
printf '%s' "$candidate"
return 0
fi fi
done done
for port in $live; do count=$((count + 1)); result="$port"; done
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een if [ "$count" -eq 1 ]; then
# rollback-poging toch het vorige slot kan starten. printf '%s' "$result"
if [ -n "$port" ]; then
printf '%s' "$port"
return 0 return 0
fi fi
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
printf '%s' "$slot_a_port" case "$port" in
} "$slot_b_port") printf '%s' "$slot_b_port" ;;
*) printf '%s' "$slot_a_port" ;;
# Poort-bezetting controleren vóór het starten van de kandidaat.
#
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
# container op diezelfde poort beantwoordt. Dat levert een misleidende
# "expected release never became healthy" op in plaats van de echte oorzaak.
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
# hem vasthoudt.
assert_port_free() {
local port="$1" name="$2"
local holders=""
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
# hoeft te zien.
if type ss >/dev/null 2>&1; then
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
fi
[ -z "$holders" ] && return 0
echo "Port $port is already in use, cannot start candidate $name" >&2
printf '%s\n' "$holders" >&2
# Noem exact het container dat de poort vasthoudt.
#
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
# onschuldige containers begraven.
local squatter="squatter_pids"
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
if [ -n "$squatter_pids" ]; then
local pid cid owner=""
for pid in $squatter_pids; do
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
[ -n "$cid" ] || continue
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
done
fi
echo "" >&2
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
# naam die toevallig op een van deze poorten draait — meestal een
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
# dus niet geraakt.
case "$owner" in
*"/$name"*|*"/$slot_b_container"*)
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
*)
cat >&2 <<EOF
This port is held by a container that is not a blue/green slot, so the deploy
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
the other slot until cutover.
Remove the stray container and re-run the deploy:
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
If it comes back after a reboot, it is started by docker-compose.yml rather
than by this script; delete or disable that service.
EOF
;;
esac 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. # Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
@@ -287,7 +153,6 @@ switch_upstream() {
# gelden. # gelden.
start_candidate() { start_candidate() {
local port="$1" name="$2" local port="$1" name="$2"
assert_port_free "$port" "$name"
( (
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -318,7 +183,7 @@ finish() {
if [ "$cutover_started" -eq 0 ]; then if [ "$cutover_started" -eq 0 ]; then
# De live release draait nog ongestoord; alleen de kandidaat opruimen. # De live release draait nog ongestoord; alleen de kandidaat opruimen.
echo "Deployment failed before cutover; the live release was never stopped" >&2 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 logs "$new_container" --tail 50 >&2 || true
docker rm -f "$new_container" || true docker rm -f "$new_container" || true
fi fi
@@ -326,9 +191,9 @@ finish() {
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de # nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien. # kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
echo "Deployment failed after cutover; rolling back to port $old_port" >&2 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 "$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 [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
if healthy "$old_port"; then if healthy "$old_port"; then
echo "Rollback verified on port $old_port" echo "Rollback verified on port $old_port"
@@ -395,27 +260,11 @@ if ! grep -qs '^DATABASE_URL=' .env; then
exit 1 exit 1
fi fi
# De image krijgt het label van $sha, en `verify-deployed-release.mjs` controleert
# later alleen díe label. Zonder deze check bouwt een vuile werkboom dus een image
# die zegt release $sha te zijn terwijl er ongecommitte code in zit — precies het
# scenario "rebuilden levert geen nieuwe code". `docker-update.sh` deed dit al.
# `--untracked-files=normal` laat gitignored artefacten (.next, coverage,
# build-reports) buiten beschouwing; die worden toch niet meegebouwd.
if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
echo "Error: de werkboom is niet schoon, dus de image zou een verkeerd release-label krijgen." >&2
echo " Commit of stash de wijzigingen en draai opnieuw." >&2
echo " De live release is niet aangeraakt." >&2
git status --short >&2
exit 1
fi
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm exec playwright install chromium pnpm exec playwright install chromium
echo "Building $image" echo "Building $image"
# No --network=host: BuildKit only grants it via --allow=network.host, and the DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current check_current
# Read reports from the already-built image; do not start an extra application. # Read reports from the already-built image; do not start an extra application.
@@ -489,28 +338,15 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
# The application writes everything under storage/ as uid 33, but storage is a # The avatar/badge disk cache lives on the host bind and is written by uid 33
# host bind so the image's own ownership is irrelevant. Any path that is not # inside the container. Root-owned directories make every cache write fail
# uid 33 makes the write fail with EACCES, and because most of these writes are # silently, which turns each avatar into a fresh live render.
# inside a try/catch the failure is silent: the avatar cache just never fills
# (each avatar becomes a fresh live render) and the catalog export reports
# "delivery failed" while the emulator never receives the update. The old code
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
nitro-cleanup config-backups nitro-scale32-backups; do
target="$deploy_dir/storage/$owned_dir"
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
[ -d "$target" ] || continue
chown -R 33:33 "$target" 2>/dev/null || true
done
# The avatar/badge cache needs its leaf directories to exist before first use;
# the cache misses (and re-renders live) rather than erroring when they do not.
for cache_dir in avatars badges; do 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 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 mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
fi fi
done done
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
if [ "$blue_green" -eq 1 ]; then if [ "$blue_green" -eq 1 ]; then
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live, # 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" docker rm -f "$new_container"
fi fi
# 2. Een compose-replica die ooit is achtergebleven zit hier nog op de # 2. Start de kandidaat ernaast. De live release draait ononderbroken door.
# doel-poort. Hij draait niet live en wordt dus opgeruimd, zodat de release
# niet op een bezette poort stukloopt.
retire_compose_replicas "$old_port"
# 3. Start de kandidaat ernaast. De live release draait ononderbroken door.
candidate_attempted=1 candidate_attempted=1
start_candidate "$new_port" "$new_container" 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 # release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
# cutover de productie breken in plaats van ervoor. # cutover de productie breken in plaats van ervoor.
healthy "$new_port" healthy "$new_port"
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha" 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 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 cutover_started=1
switch_upstream "$new_port" switch_upstream "$new_port"
echo "Cut over to port $new_port; retiring port $old_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. # waarop er geen enkele container draait.
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
docker stop "$old_container" 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_IMAGE="$image"
export NEWS_E2E_RELEASE="$sha" export NEWS_E2E_RELEASE="$sha"
build_attempted=1 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" . --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
ENV_FILE="$DIR/.env"
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
PROJECT_NAME="epicnext-crowdsec"
CONTAINER_NAME="epicnext-crowdsec"
DEFAULT_PORT="18080"
mode="${1:-enable}"
case "$mode" in
enable|--enable) ;;
status|--status) ;;
disable|--disable) ;;
blocklists|blocklists-install-cron|blocklists-uninstall-cron) ;;
*) echo "Usage: bash cms security [enable|status|disable|blocklists|blocklists-install-cron|blocklists-uninstall-cron]" >&2; exit 1 ;;
esac
umask 077
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
for command in docker flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
docker info >/dev/null 2>&1 || fail "Docker is not reachable."
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
exec 9>"$DIR/.deploy.lock"
flock -w 30 9 || fail "Another installation or update is running."
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
case "$mode" in
blocklists) exec bash "$DIR/scripts/blocklists-sync.sh" sync "${2:-}" ;;
blocklists-install-cron) exec bash "$DIR/scripts/blocklists-sync.sh" install-cron ;;
blocklists-uninstall-cron) exec bash "$DIR/scripts/blocklists-sync.sh" uninstall-cron ;;
esac
env_get() {
local key="$1" line
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
esac
done < "$ENV_FILE"
return 1
}
env_set() {
local key="$1" value="$2" tmp
tmp="$(mktemp "$DIR/.env.crowdsec.XXXXXX")"
if awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="=";done=0} { if ($1==k) { print k "=" v; done=1 } else print } END { if (!done) print k "=" v }' "$ENV_FILE" > "$tmp"; then
chmod 600 "$tmp"
mv -f -- "$tmp" "$ENV_FILE"
else
rm -f -- "$tmp"
fail "Could not update .env"
fi
}
compose_cmd() {
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
}
health_probe() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
else
compose_cmd exec -T crowdsec wget -q -O - "$url" >/dev/null 2>&1
fi
}
container_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
}
if [[ "$mode" = disable || "$mode" = --disable ]]; then
set +e
compose_cmd stop crowdsec
rc=$?
set -e
[[ $rc -eq 0 ]] || printf 'CrowdSec engine was not running or could not be stopped.\n'
env_set CROWDSEC_LOCAL_ENABLED false
printf 'CrowdSec local stack disabled. The engine container is stopped; volumes and .env key were kept.\n'
exit 0
fi
if [[ "$mode" = status || "$mode" = --status ]]; then
enabled=no
[[ "$(env_get CROWDSEC_LOCAL_ENABLED 2>/dev/null || true)" = true ]] && enabled=yes
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
[[ -z "$port" ]] && port="$DEFAULT_PORT"
printf 'CROWDSEC_LOCAL_ENABLED=%s\n' "$enabled"
if container_running; then
printf 'Engine: running\n'
if health_probe "http://127.0.0.1:$port/health"; then
printf 'LAPI health: OK (127.0.0.1:%s)\n' "$port"
else
printf 'LAPI health: UNREACHABLE (127.0.0.1:%s)\n' "$port"
fi
else
printf 'Engine: not running\n'
printf 'Start with: bash cms security\n'
fi
exit 0
fi
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
[[ -n "$port" ]] || port="$DEFAULT_PORT"
[[ "$port" =~ ^[0-9]{1,5}$ ]] || fail "CROWDSEC_LAPI_PORT must be a port number."
if (( port < 1024 || port > 65535 )); then
fail "CROWDSEC_LAPI_PORT must be within 1024-65535."
fi
key="$(env_get CROWDSEC_LAPI_API_KEY 2>/dev/null || true)"
[[ -n "$key" ]] || key="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
url="$(env_get CROWDSEC_LAPI_URL 2>/dev/null || true)"
[[ -n "$url" ]] || url="http://127.0.0.1:$port"
log_dir="${CROWDSEC_NGINX_LOG_DIR:-$(env_get CROWDSEC_NGINX_LOG_DIR 2>/dev/null || true)}"
[[ -n "$log_dir" ]] || log_dir="/var/log/nginx"
if ! container_running && command -v ss >/dev/null 2>&1; then
if ss -ltn "( sport = :$port )" 2>/dev/null | grep -q LISTEN; then
fail "Port $port is already in use. Set CROWDSEC_LAPI_PORT (and CROWDSEC_LAPI_URL) in .env to a free port and re-run."
fi
fi
if [[ ! -r "$log_dir/access.log" ]]; then
printf 'Warning: %s/access.log is not readable. The engine will run but has no detections until an access log is available.\n' "$log_dir"
fi
env_set CROWDSEC_LOCAL_ENABLED true
env_set CROWDSEC_LAPI_URL "$url"
env_set CROWDSEC_LAPI_PORT "$port"
env_set CROWDSEC_LAPI_API_KEY "$key"
env_set CROWDSEC_NGINX_LOG_DIR "$log_dir"
compose_cmd config --quiet || fail "CrowdSec Compose configuration is invalid; fix CROWDSEC_* settings in .env."
set +e
compose_cmd up -d --wait crowdsec
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
compose_cmd up -d crowdsec
fi
attempt=0
while ! health_probe "http://127.0.0.1:$port/health"; do
attempt=$((attempt + 1))
[[ $attempt -lt 30 ]] || fail "CrowdSec LAPI did not become healthy on port $port."
sleep 2
done
printf 'CrowdSec engine running in LAPI-only mode on 127.0.0.1:%s (container %s).\n' "$port" "$CONTAINER_NAME"
compose_cmd exec -T crowdsec cscli bouncers list >/dev/null 2>&1 \
&& printf 'Bouncer "cms" was registered against the local LAPI.\n' \
|| printf 'Warning: could not list bouncers. Diagnose with: docker compose exec -T %s cscli bouncers list\n' "$CONTAINER_NAME"
printf 'Restart the CMS container (or run your next deployment) so it loads the new bouncer env. For a clone: bash cms update --skip-pull\n'
+2 -94
View File
@@ -3,21 +3,15 @@
# #
# Modes: # Modes:
# (default) — post-deploy cleanup (safe, fast): # (default) — post-deploy cleanup (safe, fast):
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting # - Build cache older than 72h, capped at 4 GB max used space.
# least-recently-used entries. This cap is the actual bound.
# - Unreferenced images older than 7 days (keeps rollback images around). # - Unreferenced images older than 7 days (keeps rollback images around).
# - Stopped containers older than 24h. # - Stopped containers older than 24h.
# - Dangling images, which are always unreferenced.
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
# --force — emergency mode ("never let the disk max out"): drops everything # --force — emergency mode ("never let the disk max out"): drops everything
# with no age windows: # with no age windows:
# - ALL unreferenced build cache, # - ALL unreferenced build cache,
# - ALL unreferenced images (no age grace), # - ALL unreferenced images (no age grace),
# - ALL stopped containers. # - ALL stopped containers.
# #
# The default mode escalates to --force on its own when / drops below 8 GB free,
# so the bound holds even if this stops running on schedule.
#
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database. # Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
# Idempotent; exits 0 when Docker is unavailable. # Idempotent; exits 0 when Docker is unavailable.
set -Eeuo pipefail set -Eeuo pipefail
@@ -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" printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true docker system df >>"$LOG_FILE" 2>&1 || true
# A hard ceiling on the root filesystem is what actually bounds the growth, so
# the emergency path is reached on disk pressure rather than only on a timer.
# The image/container passes stay age-gated: a rollback image and a stopped
# container are cheap to keep for a week and expensive to lose.
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
# 8 GB free is comfortable for a database plus a release swap.
if (( FREE_KB < 8 * 1024 * 1024 )); then
FORCE=1
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
"$(now)" "$FREE_KB" >>"$LOG_FILE"
fi
if (( FORCE )); then if (( FORCE )); then
docker builder prune -af >>"$LOG_FILE" 2>&1 || true docker builder prune -af >>"$LOG_FILE" 2>&1 || true
docker image prune -af >>"$LOG_FILE" 2>&1 || true docker image prune -af >>"$LOG_FILE" 2>&1 || true
docker container prune -f >>"$LOG_FILE" 2>&1 || true docker container prune -f >>"$LOG_FILE" 2>&1 || true
else else
# --max-used-space and --filter are mutually exclusive in buildx: passing docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
# both makes the cap a no-op and the cache grows without bound. The cap alone
# is the bound, and it evicts least-recently-used entries to get there.
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
fi fi
# Dangling images have no tag and no container, so nothing can reference them.
# They are what repeated local builds leave behind.
docker image prune -f >>"$LOG_FILE" 2>&1 || true
# ── Interrupted git gc leftovers ─────────────────────────────────
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
# nothing reclaims it: git only clears those on the next successful gc. One such
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
# than a day are considered, so a gc running right now is never touched.
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
while IFS= read -r -d '' tmp; do
size=$(du -h "$tmp" | cut -f1)
rm -f "$tmp"
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
fi
# ── Orphaned browser profiles ─────────────────────────────────────
# byparr launches a real Firefox per request, and each launch leaves a
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
# days on this host, ~1.7 GB/day.
#
# Deleting a profile out from under a running browser kills that job, so live
# ones are identified the only way that is reliable rather than by age: a
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
# pointing into the directory. Anything not referenced that way, and untouched
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
#
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
# age window that is safe for the leak is far too wide for the disk.
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
for container in ${BYPARR_CONTAINERS:-byparr}; do
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
removed=$(
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
set -u
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
base="${1:-/tmp}"
live_file=$(mktemp)
# Live profiles are the ones a running process still holds open.
for p in $(ps -eo pid= 2>/dev/null); do
ls -l "/proc/$p/fd" 2>/dev/null
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
count=0
for dir in "$base"/playwright_firefoxdev_profile-*; do
[ -d "$dir" ] || continue
# Never touch something a process is still using.
grep -Fxq "$dir" "$live_file" && continue
# A profile a browser is still writing to is not an orphan
# yet, even if the directory itself looks old.
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
continue
fi
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
done
rm -f "$live_file"
printf "%s" "$count"
' sh /tmp 2>/dev/null || printf '0'
)
if [[ "${removed:-0}" -gt 0 ]]; then
printf '[%s] removed %s orphaned browser profiles from %s\n' \
"$(now)" "$removed" "$container" >>"$LOG_FILE"
fi
done
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE" printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true docker system df >>"$LOG_FILE" 2>&1 || true
+23 -89
View File
@@ -1,13 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest"; import { it } from "vitest";
import {
detectMemoryLimitMb,
heapLimitMb,
runtimeNodeOptions,
} from "./docker-start.mjs";
it("imports runtime validation without starting the CMS", () => { it("imports runtime validation without starting the CMS", () => {
const result = spawnSync( const result = spawnSync(
@@ -22,88 +16,28 @@ it("imports runtime validation without starting the CMS", () => {
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
}); });
describe("heap limit", () => { it("rejects the local CrowdSec bouncer without a key", () => {
it("leaves headroom for the memory V8 does not account for", () => { const result = spawnSync(
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling. process.execPath,
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867); [
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300); "--input-type=module",
}); "-e",
"import {validateRuntime} from './scripts/docker-start.mjs';try{validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true'});process.exit(1)}catch(error){if(!String(error.message).includes('CROWDSEC_LAPI_API_KEY'))throw error}",
it("clamps to a floor and a ceiling", () => { ],
// Too small to run a Next.js server at all: floor wins. { encoding: "utf8" },
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512); );
// A huge or absent limit must not turn into a 100 GB heap. assert.equal(result.status, 0, result.stderr);
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
expect(heapLimitMb(Number.NaN)).toBe(8192);
expect(heapLimitMb(0)).toBe(8192);
});
}); });
describe("cgroup detection", () => { it("accepts a complete local CrowdSec configuration", () => {
const asReader = (contents) => (path) => { const result = spawnSync(
if (!(path in contents)) throw new Error(`ENOENT: ${path}`); process.execPath,
return contents[path]; [
}; "--input-type=module",
"-e",
it("reads the cgroup v2 limit", () => { "import {validateRuntime} from './scripts/docker-start.mjs';validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true',CROWDSEC_LAPI_API_KEY:'fixture-key',CROWDSEC_LAPI_URL:'http://127.0.0.1:18080'})",
expect( ],
detectMemoryLimitMb( { encoding: "utf8" },
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }), );
), assert.equal(result.status, 0, result.stderr);
).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",
);
});
}); });
+17 -70
View File
@@ -1,70 +1,7 @@
// Fail before listening if an installation has no valid runtime configuration. // Fail before listening if an installation has no valid runtime configuration.
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
/** Fraction of the container memory limit V8 is allowed to use for its heap.
* The rest has to cover native allocations the JS heap cannot account for:
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
* RSC rendering. */
const HEAP_FRACTION = 0.7;
const MIN_HEAP_MB = 512;
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
* for 4300 MB, and a backstop at or below that would silently turn the fraction
* into a fixed number and make the two limits disagree. This exists purely so a
* nonsensical cgroup reading cannot ask for an unbounded heap. */
const MAX_HEAP_MB = 8192;
export function heapLimitMb(cgroupLimitBytes) {
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
return MAX_HEAP_MB;
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
}
/**
* Read this container's memory ceiling from cgroup v2, falling back to v1.
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
* container on a 24 GB host lets the heap grow past the limit and the kernel
* OOM-kills the process mid-request — which is what produced the
* `next-build (v16)` kills in the host logs. A container that GCs before it
* reaches the ceiling degrades to a slower page instead of a killed process.
*/
export function detectMemoryLimitMb(readFile = readFileSync) {
const candidates = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
for (const path of candidates) {
let raw;
try {
raw = readFile(path, "utf8").trim();
} catch {
continue;
}
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
// sentinel of a very large number on some kernels.
if (raw === "max" || raw === "") continue;
const bytes = Number(raw);
if (!Number.isFinite(bytes) || bytes <= 0) continue;
// A host-sized "limit" means no cgroup ceiling was applied.
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
return heapLimitMb(bytes);
}
return heapLimitMb(Number.NaN);
}
export function runtimeNodeOptions(
existing = "",
heapMb = detectMemoryLimitMb(),
) {
const flag = `--max-old-space-size=${heapMb}`;
if (!existing.trim()) return flag;
// Respect an explicit operator override; only add the cap when absent.
if (existing.includes("--max-old-space-size")) return existing;
return `${existing} ${flag}`;
}
export function validateRuntime(settings) { export function validateRuntime(settings) {
const invalid = []; const invalid = [];
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture") if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
@@ -86,6 +23,22 @@ export function validateRuntime(settings) {
invalid.push(key); invalid.push(key);
} }
} }
const localEnabled = ["true", "1"].includes(
String(settings.CROWDSEC_LOCAL_ENABLED ?? "")
.trim()
.toLowerCase(),
);
if (localEnabled) {
if (!settings.CROWDSEC_LAPI_API_KEY?.trim())
invalid.push("CROWDSEC_LAPI_API_KEY");
if (settings.CROWDSEC_LAPI_URL) {
try {
new URL(settings.CROWDSEC_LAPI_URL);
} catch {
invalid.push("CROWDSEC_LAPI_URL");
}
}
}
if (invalid.length) if (invalid.length)
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`); throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
} }
@@ -96,13 +49,7 @@ if (
) { ) {
try { try {
validateRuntime(process.env); validateRuntime(process.env);
const heapMb = detectMemoryLimitMb(); const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
const child = spawn(process.execPath, ["server.js"], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nodeOptions },
});
for (const signal of ["SIGTERM", "SIGINT"]) for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => child.kill(signal)); process.on(signal, () => child.kill(signal));
child.on("error", () => { child.on("error", () => {
+4 -21
View File
@@ -58,27 +58,10 @@ trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
# This host belongs to CI: the blue/green deploy owns both host ports (3002 and # An existing CI deployment is a different owner of the same host port.
# 3003) and one of the two slot containers is always the live release. Compose if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
# may only run where CI does not. die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
#
# Checking epicnext-cms-app alone was not enough. After a cutover to the green
# slot the blue container is stopped, renamed and deleted, so the guard stopped
# firing while the host stayed CI-managed. `docker compose up` then recreated a
# replica named epicnext-cms on port 3002 — the blue slot, exactly where the next
# candidate has to start — and every later release failed on a busy port until
# someone removed that container by hand (see logs/docker-update.cron.log).
# Therefore: both slot containers count, and so does the nginx upstream, which is
# the only thing that still marks the host as blue/green when a slot is idle.
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
fi fi
for slot_container in epicnext-cms-app epicnext-cms-green; do
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
fi
done
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first." [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating." git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
@@ -123,7 +106,7 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE" docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
docker tag "$remote_migration_image" "$migration_image" docker tag "$remote_migration_image" "$migration_image"
else else
docker build --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 docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
fi fi
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")" expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
+4 -4
View File
@@ -1,10 +1,9 @@
import "./load-env"; import "./load-env";
import { createHash } from "node:crypto"; 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 path from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
import { readFurniData } from "@/lib/services/furni-data"; import { readFurniData } from "@/lib/services/furni-data";
const ICON_DIR = path.join( const ICON_DIR = path.join(
@@ -192,10 +191,11 @@ async function main(): Promise<void> {
); );
let nitroMissing = 0; let nitroMissing = 0;
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
for (const cls of catalogClasses) { for (const cls of catalogClasses) {
if (!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(); await db.$client.end();
process.exit(0); 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 import { drainOperationEffects } from "../src/features/operations/worker";
// order, and `../src/features/operations/worker` reaches `@/env`, which parses import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
// process.env at import time. With this import further down the tree, load-env
// ran *after* the schema validation had already thrown on a missing
// DATABASE_URL, so the worker could only ever start from an environment that
// already exported the config — which is why `pnpm jobs:worker` died
// immediately and nothing supervised it.
import "./load-env"; import "./load-env";
import * as nodeFs from "node:fs";
import * as nodePath from "node:path";
import { Cron } from "croner"; import { Cron } from "croner";
import { lt, sql } from "drizzle-orm"; import { lt, sql } from "drizzle-orm";
import { env } from "../src/env"; import { env } from "../src/env";
import { drainOperationEffects } from "../src/features/operations/worker";
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db"; import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
import { logger } from "../src/lib/logger"; import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis"; import { redis } from "../src/lib/redis";
@@ -26,7 +18,6 @@ import {
diskLevel, diskLevel,
parseDfOutput, parseDfOutput,
} from "../src/lib/services/disk-usage"; } from "../src/lib/services/disk-usage";
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
import { publishDueArticles } from "../src/lib/services/news-scheduler"; import { publishDueArticles } from "../src/lib/services/news-scheduler";
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup"; import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
import { rcon } from "../src/lib/services/rcon"; 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> { async function backupEmulatorJar(): Promise<void> {
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return; if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
const fs = await import("node:fs"); const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs; await import("node:fs");
const { resolve } = await import("node:path"); const { resolve } = await import("node:path");
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
if (!jarPath) {
// Configured but unusable: say so once, loudly, instead of every night
// logging an opaque copyFile ENOENT that reads like a permissions bug.
logger.error(
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
);
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-"); const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const backupFile = resolve( const backupFile = resolve(
env.EMULATOR_BACKUP_DIR, env.EMULATOR_BACKUP_DIR,
@@ -244,11 +179,10 @@ async function backupEmulatorJar(): Promise<void> {
} }
try { try {
copyFileSync(jarPath, backupFile); copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
logger.info("Backed up emulator JAR", { logger.info("Backed up emulator JAR", {
module: "jobs", module: "jobs",
backupFile, backupFile,
source: jarPath,
}); });
const keep = env.EMULATOR_BACKUP_KEEP ?? 7; const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
-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 if [[ ! -d /var/log/nginx ]]; then
install -d -o root -g adm -m 750 /var/log/nginx install -d -o root -g adm -m 750 /var/log/nginx
fi 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 for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
[[ -f "$f" ]] || touch "$f" [[ -f "$f" ]] || touch "$f"
done done
+13 -62
View File
@@ -79,27 +79,6 @@ function filesFrom(values) {
return values.map(normalizeAsset); return values.map(normalizeAsset);
} }
/**
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
* a real file has to be separated from its id. An id is rejected by
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
* suffix), which makes it a usable filter — but only for ids. A malformed
* *path* must still fail loudly rather than be silently dropped, or a broken
* manifest would quietly under-report a route's real weight.
*/
function assetFilesFromChunkList(values) {
if (!Array.isArray(values))
throw new Error("Unsupported JavaScript chunk list.");
const files = [];
for (const value of values) {
if (typeof value !== "string")
throw new Error("Non-string JavaScript asset.");
if (/^\d+$/.test(value)) continue;
files.push(normalizeAsset(value));
}
return files;
}
export function measureRoute({ export function measureRoute({
budget, budget,
appPath, appPath,
@@ -107,44 +86,18 @@ export function measureRoute({
clientManifest, clientManifest,
readAsset, readAsset,
}) { }) {
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
// not — it only records chunks per client module — so after the build moved
// to webpack (3d828a61) every route reported "unavailable" and the report
// silently stopped measuring anything. Fall back to the same source Next's
// own `static-routes-info` uses for webpack builds.
const entries = clientManifest?.entryJSFiles; const entries = clientManifest?.entryJSFiles;
const webpackModules = clientManifest?.clientModules; if (!entries || typeof entries !== "object" || Array.isArray(entries))
let filesBySource;
let webpackLayout = false;
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
filesBySource = Object.entries(entries);
} else if (webpackModules && typeof webpackModules === "object") {
webpackLayout = true;
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
filesBySource = [];
for (const node of Object.values(webpackModules)) {
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
// One shared origin label instead of the module path: the per-chunk
// `sources` list is written into report.json, and webpack records
// absolute node_modules paths for every client module on the route.
filesBySource.push(["client-module", node.chunks]);
}
if (filesBySource.length === 0)
throw new Error(
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
);
} else {
throw new Error( throw new Error(
"entryJSFiles is unavailable; this manifest layout is not supported.", "entryJSFiles is unavailable; this manifest layout is not supported.",
); );
} const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
const bootstrap = filesFrom( const bootstrap = filesFrom(
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles, buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
); );
@@ -157,9 +110,8 @@ export function measureRoute({
origins.set(file, sources); origins.set(file, sources);
}; };
for (const file of bootstrap) add(file, "bootstrap"); for (const file of bootstrap) add(file, "bootstrap");
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom; for (const [entry, values] of Object.entries(entries))
for (const [entry, values] of filesBySource) for (const file of filesFrom(values)) add(file, entry);
for (const file of readChunkList(values)) add(file, entry);
const size = (file, sources) => { const size = (file, sources) => {
const bytes = readAsset(file); const bytes = readAsset(file);
return { return {
@@ -296,13 +248,12 @@ export function collectReport(nextDir, config, metadata = {}) {
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.", "Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
nodeVersion: process.version, nodeVersion: process.version,
zlibVersion: process.versions.zlib, zlibVersion: process.versions.zlib,
manifestFormat: manifestFormat: "Next App Router client-reference entryJSFiles",
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
definition: 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.", gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
excluded: excluded:
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from 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, routes,
}; };
} }
-129
View File
@@ -216,135 +216,6 @@ describe("route JS measurement", () => {
); );
expect(collectReport(dir, config).routes[0].status).toBe("unavailable"); expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
}); });
// The regression: after the build moved to webpack (3d828a61) the manifest
// has no entryJSFiles, only clientModules[*].chunks. Every route then
// reported "unavailable" and the report measured nothing at all while still
// exiting zero, so the budgets silently stopped being enforced.
describe("webpack manifests without entryJSFiles", () => {
const webpackManifest = {
clientModules: {
"[project]/src/components/header.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"6726",
"static/chunks/header-entry.js?dpl=abc",
],
},
"[project]/src/app/(site)/news/page.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"7777",
"/_next/static/chunks/page.js?dpl=abc",
],
},
// Async-only modules are recorded with an empty chunk list.
"[project]/src/components/lazy.tsx": { chunks: [] },
},
};
const webpackFiles = {
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
// bootstrap assets must exist or the read fails.
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
"static/chunks/header-entry.js": Buffer.from("header"),
"static/chunks/page.js": Buffer.from("page"),
"static/chunks/polyfill.js": Buffer.from("legacy"),
};
const measure = () =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: webpackManifest,
readAsset: (file) => webpackFiles[file],
});
it("derives the envelope from clientModules and ignores chunk ids", () => {
const row = measure();
expect(row.status).toBe("measured");
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
// are not assets and must not throw or be counted.
expect(row.initial.chunkCount).toBe(5);
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
"static/chunks/4269-shared.js",
"static/chunks/header-entry.js",
"static/chunks/page.js",
"static/chunks/runtime.js",
"static/chunks/shared.js",
]);
// Same bytes as the Turbopack fixture would produce for these files.
expect(row.initial.rawBytes).toBe(
Object.values(webpackFiles)
.filter((b) => !b.includes("legacy"))
.reduce((n, b) => n + b.length, 0),
);
});
it("counts a chunk reached by several client modules only once", () => {
const shared = measure().initial.chunks.find(
(f) => f.path === "static/chunks/4269-shared.js",
);
expect(shared).toBeDefined();
expect(measure().initial.chunkCount).toBe(5);
});
it("does not leak absolute module paths into the report", () => {
const sources = new Set(
measure().initial.chunks.flatMap((chunk) => chunk.sources),
);
// Only the two known origin labels; no node_modules path may appear.
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
});
it("still fails rather than under-reporting a malformed chunk path", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
clientModules: {
x: { chunks: ["static/chunks/../../etc/passwd"] },
},
},
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Unsupported JavaScript asset");
});
it("reports unavailable when webpack recorded no chunks at all", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: { clientModules: { x: { chunks: [] } } },
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Neither entryJSFiles nor clientModules");
});
it("prefers entryJSFiles when a manifest carries both", () => {
// A future Next version could emit both; the explicit list wins
// because it is per-segment and therefore the tighter envelope.
const row = measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
...webpackManifest,
entryJSFiles: {
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
},
},
readAsset: (file) => webpackFiles[file],
});
expect(row.initial.chunkCount).toBe(3);
});
});
it("does not deduplicate shared files across independent cold route totals", () => { it("does not deduplicate shared files across independent cold route totals", () => {
const row = measureRoute({ const row = measureRoute({
budget, budget,
+91
View File
@@ -3,6 +3,7 @@ import {
copyFileSync, copyFileSync,
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readFileSync,
rmSync, rmSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
@@ -84,3 +85,93 @@ it.skipIf(!hasCompose)(
}, },
30_000, 30_000,
); );
it("documents the local CrowdSec switches in .env.example", () => {
const examples = readFileSync(path.join(root, ".env.example"), "utf8");
for (const key of [
"CROWDSEC_LOCAL_ENABLED",
"CROWDSEC_LAPI_URL",
"CROWDSEC_LAPI_PORT",
"CROWDSEC_LAPI_API_KEY",
"CROWDSEC_NGINX_LOG_DIR",
]) {
expect(examples).toContain(key);
}
});
it.skipIf(!hasCompose)(
"renders the standalone CrowdSec stack with a loopback-only LAPI",
() => {
const directory = mkdtempSync(path.join(tmpdir(), "cms-crowdsec-"));
try {
mkdirSync(path.join(directory, "deployment/crowdsec/acquis.d"), {
recursive: true,
});
copyFileSync(
path.join(root, "deployment/crowdsec/compose.crowdsec.yml"),
path.join(directory, "deployment/crowdsec/compose.crowdsec.yml"),
);
copyFileSync(
path.join(root, "deployment/crowdsec/acquis.d/nginx.yaml"),
path.join(directory, "deployment/crowdsec/acquis.d/nginx.yaml"),
);
writeFileSync(
path.join(directory, ".env"),
[
"CROWDSEC_LAPI_API_KEY=fixture-key",
"CROWDSEC_LAPI_PORT=18080",
"CROWDSEC_LAPI_URL=http://127.0.0.1:18080",
"CROWDSEC_NGINX_LOG_DIR=/var/log/nginx",
].join("\n"),
);
const environment = { ...process.env };
for (const key of Object.keys(environment))
if (
key.startsWith("COMPOSE_") ||
key.startsWith("CROWDSEC_") ||
key.startsWith("TZ")
)
delete environment[key];
const result = spawnSync(
"docker",
[
"compose",
"--project-name",
"crowdsec-fixture",
"--env-file",
".env",
"-f",
"deployment/crowdsec/compose.crowdsec.yml",
"--profile",
"security",
"config",
"--format",
"json",
],
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
);
expect(result.status, result.stderr).toBe(0);
const config = JSON.parse(result.stdout);
const service = config.services.crowdsec;
expect(service).toBeDefined();
expect(service.image).toContain("crowdsecurity/crowdsec:");
expect(service.environment.BOUNCER_KEY_cms).toBe("fixture-key");
expect(service.environment.DISABLE_ONLINE_API).toBe("true");
expect(
service.ports.some(
(published) =>
published.host_ip === "127.0.0.1" &&
published.published === "18080" &&
published.target === 8080,
),
).toBe(true);
const targets = service.volumes.map((volume) => volume.target);
expect(targets).toContain("/var/log/nginx");
expect(targets).toContain("/etc/crowdsec/acquis.d");
expect(service.healthcheck.test.join(" ")).toContain("wget");
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
30_000,
);
+3 -28
View File
@@ -1,35 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Setup cron jobs for maintenance # Setup cron jobs for maintenance
#
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
# script that pipes one job at a time silently drops every other scheduled job.
# Entries are matched by their command, so re-running this is idempotent.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Adds one schedule line to the crontab unless its command is already present.
add_job() {
local schedule="$1" command
command="${schedule##* }"
local current
current="$(crontab -l 2>/dev/null || true)"
if grep -Fq "$command" <<<"$current"; then
echo "Already scheduled: $command"
return
fi
if [[ -z "$current" ]]; then
printf '%s\n' "$schedule" | crontab -
else
printf '%s\n%s\n' "$current" "$schedule" | crontab -
fi
echo "Scheduled: $schedule"
}
# Run backup every day at 03:00 # Run backup every day at 03:00
add_job "0 3 * * * $SCRIPT_DIR/backup.sh" echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly # Run prune every Sunday at 04:00
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
# run would let ~12 GB accumulate before anything reclaimed it.
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
echo "Cron jobs configured." echo "Cron jobs configured."
-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
+60
View File
@@ -15,6 +15,14 @@ import {
setLastCloudflareVerify, setLastCloudflareVerify,
verifyCloudflareConnection, verifyCloudflareConnection,
} from "@/lib/cloudflare-api"; } from "@/lib/cloudflare-api";
import {
setLastCrowdsecVerify,
verifyCrowdsecConnection,
} from "@/lib/crowdsec-api";
import {
setLastCrowdsecReport,
verifyCrowdsecReporting,
} from "@/lib/crowdsec-report";
import { db, WebsiteSetting } from "@/lib/db"; import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -33,6 +41,17 @@ function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
return Math.floor(n); return Math.floor(n);
} }
function clampInt(
raw: FormDataEntryValue | null,
fallback: number,
min: number,
max: number,
): number {
const n = Number(str(raw));
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] { function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
const tiers: AntiddosBlockTier[] = []; const tiers: AntiddosBlockTier[] = [];
for (const part of str(raw).split(",")) { for (const part of str(raw).split(",")) {
@@ -99,6 +118,17 @@ function configFromForm(formData: FormData): AntiddosConfig {
defaults.globalHaltMs, defaults.globalHaltMs,
), ),
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1", cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
crowdsecAutoBlock: str(formData.get("cs_auto_block")) === "1",
crowdsecBlockScore: clampInt(
formData.get("cs_block_score"),
defaults.crowdsecBlockScore,
0,
5,
),
crowdsecBlockTtlSeconds: positiveInt(
formData.get("cs_block_ttl_sec"),
defaults.crowdsecBlockTtlSeconds,
),
}; };
} }
@@ -123,6 +153,9 @@ async function persistSettings(config: AntiddosConfig): Promise<void> {
], ],
["antiddos_global_halt_ms", String(config.globalHaltMs)], ["antiddos_global_halt_ms", String(config.globalHaltMs)],
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"], ["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
["antiddos_cs_auto_block", config.crowdsecAutoBlock ? "1" : "0"],
["antiddos_cs_block_score", String(config.crowdsecBlockScore)],
["antiddos_cs_block_ttl", String(config.crowdsecBlockTtlSeconds)],
]; ];
await Promise.all( await Promise.all(
entries.map(([key, value]) => entries.map(([key, value]) =>
@@ -195,6 +228,7 @@ export async function unbanAntiddosIp(formData: FormData): Promise<void> {
await Promise.all([ await Promise.all([
redis.del(`antiddos:block:${ip}`), redis.del(`antiddos:block:${ip}`),
redis.del(`antiddos:block:meta:${ip}`), redis.del(`antiddos:block:meta:${ip}`),
redis.del(`crowdsec:report:${ip}`),
redis.del(`antiddos:v:${ip}`), redis.del(`antiddos:v:${ip}`),
]); ]);
} }
@@ -231,6 +265,32 @@ export async function removeCloudflareRule(formData: FormData): Promise<void> {
revalidatePath("/admin/devops/antiddos"); revalidatePath("/admin/devops/antiddos");
} }
/** Test the configured CrowdSec API credentials against the CTI endpoint. */
export async function verifyCrowdsecConfiguration(): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
const status = await verifyCrowdsecConnection();
await setLastCrowdsecVerify(status);
logger.info("CrowdSec API configuration verified", {
staff: staff.username,
ok: status.ok,
message: status.message,
});
revalidatePath("/admin/devops/antiddos");
}
/** Test the CrowdSec signal-push (CAPI watcher) channel. */
export async function verifyCrowdsecReportingConfiguration(): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
const status = await verifyCrowdsecReporting();
await setLastCrowdsecReport(status);
logger.info("CrowdSec reporting configuration verified", {
staff: staff.username,
ok: status.ok,
message: status.message,
});
revalidatePath("/admin/devops/antiddos");
}
/** Test the configured Cloudflare API credentials against the zone. */ /** Test the configured Cloudflare API credentials against the zone. */
export async function verifyCloudflareConfiguration(): Promise<void> { export async function verifyCloudflareConfiguration(): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_VIEW); const staff = await requirePermission(PERMS.SETTINGS_VIEW);
+29 -68
View File
@@ -10,77 +10,38 @@ import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> { export async function giveBadge(formData: FormData): Promise<void> {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
// Support comma-separated userIds and/or codes for bulk operations const userId = Number(formData.get("userId"));
const userIdInput = String(formData.get("userId") ?? "").trim(); const code = String(formData.get("code") ?? "")
const codeInput = String(formData.get("code") ?? "").trim(); .normalize("NFC")
const userIds = userIdInput .trim()
? userIdInput .slice(0, 32);
.split(",") if (!(userId > 0) || code.length === 0) return;
.map((s) => s.trim())
.filter(Boolean)
: [];
const codes = codeInput
? codeInput
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (userIds.length === 0 || codes.length === 0) { // Fire the emulator command so the badge appears live for online users.
return; await rcon.giveBadge(userId, code);
}
// Process each user/code combination // Persist the badge directly so it survives a relog / offline grant.
let granted = false; // users_badges has no unique (user_id, badge_code) constraint, so guard
for (const userId of userIds) { // against duplicates and compute the next free slot ourselves.
for (const code of codes) { try {
if (!(Number(userId) > 0) || code.length === 0) continue; const [existing] = await db
.select({ id: UsersBadges.id })
// Truncate badge code to 32 characters. .from(UsersBadges)
const truncatedCode = code.slice(0, 32); .where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
// Fire the emulator command so the badge appears live for online users. )
try { .limit(1);
await rcon.giveBadge(Number(userId), truncatedCode); if (!existing) {
} catch { const [agg] = await db
// ignore rcon errors per pair .select({ maxSlot: max(UsersBadges.slotId) })
} .from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
// Persist the badge directly so it survives a relog / offline grant. const slotId = (agg?.maxSlot ?? 0) + 1;
// users_badges has no unique (user_id, badge_code) constraint, so guard await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
// 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;
} }
} 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 path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage"; import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
/** const MAX_SIZE = 5 * 1024 * 1024; // 5MB
* Store an uploaded media file under MEDIA_ROOT. const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
*
* 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 };
}
export async function uploadMedia( export async function uploadMedia(
formData: FormData, formData: FormData,
@@ -37,9 +16,25 @@ export async function uploadMedia(
await requirePermission(PERMS.PAGES_EDIT); await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { ok: false, error: "No file provided" }; 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); const baseDir = MEDIA_ROOT;
if (!stored.ok) return { ok: false, error: stored.error }; // 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("/api/media");
revalidatePath("/admin/media"); revalidatePath("/admin/media");
@@ -50,8 +45,6 @@ export async function deleteMedia(name: string): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT); await requirePermission(PERMS.PAGES_EDIT);
const { unlink } = await import("node:fs/promises"); const { unlink } = await import("node:fs/promises");
const baseDir = MEDIA_ROOT; 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); const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) return; if (!filePath.startsWith(baseDir + path.sep)) return;
try { try {
@@ -69,11 +62,22 @@ export async function uploadMediaAndReturn(
await requirePermission(PERMS.PAGES_EDIT); await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file || file.size === 0) return ""; if (!file || file.size === 0) return "";
if (file.size > MAX_SIZE) return "";
if (!ALLOWED.includes(file.type)) return "";
const stored = await storeUploadedMedia(file); const baseDir = MEDIA_ROOT;
if (!stored.ok) return ""; // 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("/api/media");
revalidatePath("/admin/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 { PERMS } from "@/lib/permissions";
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache"; import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets"; import { clearBadgeCache } from "@/lib/services/habboassets";
import {
isSecretSettingKey,
SECRET_PLACEHOLDER,
} from "@/lib/services/setting-secrets";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS); 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 { function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) { if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value); return normalizeHabboGamedataHotel(value);
@@ -80,12 +71,11 @@ export async function updateSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const raw = String(formData.get("value") ?? "").normalize("NFC"); const value = normalizeSettingValue(
if (!key || !RAW_SETTING_KEY_RE.test(key)) return; key,
// Blank on a secret means "keep what is stored", so the UI can render a String(formData.get("value") ?? "").normalize("NFC"),
// placeholder without the risk of wiping the credential. );
if (isSecretSettingKey(key) && raw === SECRET_PLACEHOLDER) return; if (!key) return;
const value = isSecretSettingKey(key) ? raw : normalizeSettingValue(key, raw);
await db await db
.insert(WebsiteSetting) .insert(WebsiteSetting)
.values({ key, value }) .values({ key, value })
@@ -100,7 +90,7 @@ export async function createSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 128); .slice(0, 255);
const value = normalizeSettingValue( const value = normalizeSettingValue(
key, key,
String(formData.get("value") ?? "").normalize("NFC"), String(formData.get("value") ?? "").normalize("NFC"),
@@ -109,17 +99,7 @@ export async function createSetting(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
// Managed keys go through `saveManagedSettings`; anything else must be a if (!key) return;
// 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;
}
await db await db
.insert(WebsiteSetting) .insert(WebsiteSetting)
.values({ key, value, comment: comment || null }) .values({ key, value, comment: comment || null })
-65
View File
@@ -15,9 +15,6 @@ const core = vi.hoisted(() => ({
.trim(), .trim(),
password: String(password ?? "").normalize("NFC"), password: String(password ?? "").normalize("NFC"),
}), }),
isLoginLocked: vi.fn(async () => false),
recordLoginFailure: vi.fn(async () => false),
clearLoginLockout: vi.fn(async () => undefined),
})); }));
vi.mock("@/env", () => ({ env: {} })); vi.mock("@/env", () => ({ env: {} }));
@@ -30,14 +27,8 @@ vi.mock("@/lib/services/captcha", () => ({
vi.mock("@/lib/services/site-settings", () => ({ vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { getBool: vi.fn() }, siteSettings: { getBool: vi.fn() },
})); }));
vi.mock("@/lib/auth/login-lockout", () => ({
isLoginLocked: core.isLoginLocked,
recordLoginFailure: core.recordLoginFailure,
clearLoginLockout: core.clearLoginLockout,
}));
const user = (overrides = {}) => ({ const user = (overrides = {}) => ({
id: 42,
password: "hash", password: "hash",
twoFactorConfirmedAt: null, twoFactorConfirmedAt: null,
mail: null, mail: null,
@@ -54,9 +45,6 @@ beforeEach(() => {
core.verifyLoginPassword.mockResolvedValue({ valid: true }); core.verifyLoginPassword.mockResolvedValue({ valid: true });
core.isEmailUnverified.mockResolvedValue(false); core.isEmailUnverified.mockResolvedValue(false);
core.runDummyHashCheck.mockResolvedValue(undefined); core.runDummyHashCheck.mockResolvedValue(undefined);
core.isLoginLocked.mockResolvedValue(false);
core.recordLoginFailure.mockResolvedValue(false);
core.clearLoginLockout.mockResolvedValue(undefined);
}); });
describe("precheckLogin", () => { describe("precheckLogin", () => {
@@ -97,57 +85,4 @@ describe("precheckLogin", () => {
core.isEmailUnverified.mockResolvedValue(true); core.isEmailUnverified.mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("unverified"); 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, runDummyHashCheck,
verifyLoginPassword, verifyLoginPassword,
} from "@/lib/auth/login-core"; } from "@/lib/auth/login-core";
import {
clearLoginLockout,
isLoginLocked,
recordLoginFailure,
} from "@/lib/auth/login-lockout";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -20,8 +15,7 @@ export type PrecheckResult =
| "invalid" | "invalid"
| "twofactor" | "twofactor"
| "unverified" | "unverified"
| "captcha" | "captcha";
| "locked";
/** /**
* Validates username+password WITHOUT creating a session, and reports whether a * 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"; 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); const user = await getLoginUser(u);
if (!user) { if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check. // Prevent timing-based enumeration: always run a dummy hash check.
@@ -54,15 +45,8 @@ export async function precheckLogin(
return "invalid"; return "invalid";
} }
if (await isLoginLocked(user.id)) return "locked";
const res = await verifyLoginPassword(user, p); const res = await verifyLoginPassword(user, p);
if (!res.valid) { if (!res.valid) return "invalid";
await recordLoginFailure(user.id);
return "invalid";
}
await clearLoginLockout(user.id);
if (await isEmailUnverified(user)) { if (await isEmailUnverified(user)) {
return "unverified"; return "unverified";
+2 -9
View File
@@ -41,11 +41,7 @@ const {
}); });
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
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/db", () => ({ vi.mock("@/lib/db", () => ({
db: { db: {
delete: vi.fn(() => ({ where: deleteWhere })), delete: vi.fn(() => ({ where: deleteWhere })),
@@ -113,10 +109,7 @@ beforeEach(() => {
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]); onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]); updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]); selectLimit.mockResolvedValue([]);
// Rank rows for the per-id rank guard: every target sits below staff rank 7. selectWhereResolved.mockResolvedValue([]);
selectWhereResolved.mockResolvedValue([{ rank: 1 }]);
// Max slot of existing badges (consumed by the badge loop, not the guard).
selectWhereResolved.mockResolvedValueOnce([{ rank: 1 }]);
}); });
describe("bulkUnban", () => { describe("bulkUnban", () => {
+32 -101
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { and, eq, inArray, max, sql } from "drizzle-orm"; import { and, eq, inArray, max, sql } from "drizzle-orm";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { import {
Ban, Ban,
@@ -12,69 +11,18 @@ import {
UsersCurrency, UsersCurrency,
UsersSettings, UsersSettings,
} from "@/lib/db"; } from "@/lib/db";
import { getHighestRank, PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared"; import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
/**
* 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({ export async function bulkUnban({
userIds, userIds,
}: { }: {
userIds: number[]; userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> { }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds); const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
await guardBulkTargets(staff, ids);
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
const unbanned = Number(result[0]?.affectedRows ?? 0); const unbanned = Number(result[0]?.affectedRows ?? 0);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -82,7 +30,10 @@ export async function bulkUnban({
description: `Unbanned ${unbanned} user(s)`, description: `Unbanned ${unbanned} user(s)`,
targetType: "user", 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({ export async function bulkBan({
@@ -95,14 +46,10 @@ export async function bulkBan({
duration: number; duration: number;
}): Promise<ActionResult<{ banned: number }>> { }): Promise<ActionResult<{ banned: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT); 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); const now = Math.floor(Date.now() / 1000);
let banned = 0; let banned = 0;
for (const userId of ids) { for (const userId of userIds) {
try { try {
await db.insert(Ban).values({ await db.insert(Ban).values({
userId, userId,
@@ -110,8 +57,8 @@ export async function bulkBan({
machineId: "", machineId: "",
userStaffId: staff.id, userStaffId: staff.id,
timestamp: now, timestamp: now,
banExpire: seconds > 0 ? now + seconds : 0, banExpire: duration > 0 ? now + duration : 0,
banReason: reasonText, banReason: reason,
type: "account", type: "account",
}); });
banned++; banned++;
@@ -145,37 +92,33 @@ export async function bulkGiveCurrency({
}> }>
> { > {
const staff = await requirePermission(PERMS.USERS_EDIT); 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; let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of ids) { for (const userId of userIds) {
try { try {
if (type === "credits") { if (type === "credits") {
await db await db
.update(User) .update(User)
.set({ credits: sql`${User.credits} + ${value}` }) .set({ credits: sql`${User.credits} + ${amount}` })
.where(eq(User.id, userId)); .where(eq(User.id, userId));
await rcon.giveCredits(userId, value); await rcon.giveCredits(userId, amount);
} else if (type === "pixels") { } else if (type === "pixels") {
await db await db
.insert(UsersCurrency) .insert(UsersCurrency)
.values({ userId, type: 0, amount: value }) .values({ userId, type: 0, amount })
.onDuplicateKeyUpdate({ .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") { } else if (type === "points") {
await db await db
.insert(UsersCurrency) .insert(UsersCurrency)
.values({ userId, type: 101, amount: value }) .values({ userId, type: 101, amount })
.onDuplicateKeyUpdate({ .onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${value}` }, set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
}); });
await rcon.givePointsGotw(userId, value); await rcon.givePointsGotw(userId, amount);
} }
given++; given++;
} catch { } catch {
@@ -186,7 +129,7 @@ export async function bulkGiveCurrency({
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_give_currency", action: "bulk_give_currency",
description: `Gave ${value} ${type} to ${given} user(s)`, description: `Gave ${amount} ${type} to ${given} user(s)`,
targetType: "user", targetType: "user",
}); });
return { return {
@@ -209,21 +152,19 @@ export async function bulkGiveBadge({
}> }>
> { > {
const staff = await requirePermission(PERMS.USERS_EDIT); 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; let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of ids) { for (const userId of userIds) {
try { try {
const [existing] = await db const [existing] = await db
.select({ id: UsersBadges.id }) .select({ id: UsersBadges.id })
.from(UsersBadges) .from(UsersBadges)
.where( .where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)), and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
) )
.limit(1); .limit(1);
if (!existing) { if (!existing) {
@@ -232,10 +173,8 @@ export async function bulkGiveBadge({
.from(UsersBadges) .from(UsersBadges)
.where(eq(UsersBadges.userId, userId)); .where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1; const slotId = (agg?.maxSlot ?? 0) + 1;
await db await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
.insert(UsersBadges) await rcon.giveBadge(userId, badgeCode);
.values({ userId, slotId, badgeCode: code });
await rcon.giveBadge(userId, code);
} }
given++; given++;
} catch { } catch {
@@ -272,17 +211,12 @@ export async function bulkAdjustCurrency({
}> }>
> { > {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
if (!Number.isFinite(amount) || amount === 0) { if (!Number.isFinite(amount) || amount === 0) {
return { ok: false as const, error: "Amount must be a non-zero number" }; 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) { 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.ok) return given;
if (!given.data) { if (!given.data) {
return { ok: false as const, error: "Currency adjustment failed" }; return { ok: false as const, error: "Currency adjustment failed" };
@@ -301,7 +235,7 @@ export async function bulkAdjustCurrency({
let adjusted = 0; let adjusted = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of ids) { for (const userId of userIds) {
try { try {
if (type === "credits") { if (type === "credits") {
const [user] = await db const [user] = await db
@@ -365,11 +299,8 @@ export async function setTradeLock({
untilUnix: number; untilUnix: number;
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> { }): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const id = toPositiveInt(userId); const until = Math.max(0, Math.trunc(untilUnix));
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 locked = until > 0; const locked = until > 0;
await guardBulkTargets(staff, [id]);
const [user] = await db const [user] = await db
.select({ .select({
@@ -378,7 +309,7 @@ export async function setTradeLock({
online: User.online, online: User.online,
}) })
.from(User) .from(User)
.where(eq(User.id, id)) .where(eq(User.id, userId))
.limit(1); .limit(1);
if (!user) { if (!user) {
return { ok: false as const, error: "User not found" }; return { ok: false as const, error: "User not found" };
@@ -400,7 +331,7 @@ export async function setTradeLock({
.where(eq(Sanctions.id, existing.id)); .where(eq(Sanctions.id, existing.id));
} else { } else {
await tx.insert(Sanctions).values({ await tx.insert(Sanctions).values({
habboId: id, habboId: userId,
tradeLockedUntil: until, tradeLockedUntil: until,
reason: locked ? "Trade lock (CMS)" : "", reason: locked ? "Trade lock (CMS)" : "",
}); });
@@ -438,5 +369,5 @@ export async function setTradeLock({
targetId: userId, 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 mockSetRank = vi.hoisted(() => vi.fn());
const mockExecuteCommand = vi.hoisted(() => vi.fn()); const mockExecuteCommand = vi.hoisted(() => vi.fn());
const mockSendGift = 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", () => ({ vi.mock("@/lib/services/rcon", () => ({
rcon: { rcon: {
send: mockSend, 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 { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum"; const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?"; 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> { async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL); if (!ok) throw new ActionError(RCON_FAIL);
} }
@@ -143,16 +120,9 @@ const giveCreditsSchema = z.object({
export const giveCredits = adminAction( export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema }, { permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => { async (ctx) => {
if (ctx.data.credits > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk( await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits), 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); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
@@ -167,16 +137,9 @@ const giveAmountSchema = z.object({
export const giveDuckets = adminAction( export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema }, { permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => { async (ctx) => {
if (ctx.data.amount > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk( await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount), 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); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
@@ -186,16 +149,9 @@ export const giveDuckets = adminAction(
export const giveDiamonds = adminAction( export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema }, { permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => { async (ctx) => {
if (ctx.data.amount > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk( await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount), 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); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
-17
View File
@@ -10,13 +10,8 @@ const state = vi.hoisted(() => ({
deletes: [] as unknown[], deletes: [] as unknown[],
affectedDelete: 1, affectedDelete: 1,
emptyDeleteResult: false, 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 () => { vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema"); const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db"); const { createFakeDb } = await import("@/test/fake-db");
@@ -320,18 +315,6 @@ describe("sendOfflineMessage", () => {
expect(redirected()).toBe("/messages?send_error=invalid"); 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 () => { it("stores an offline message for a friend", async () => {
state.friendships = [{ id: 1 }]; state.friendships = [{ id: 1 }];
await redirects(() => await redirects(() =>
-3
View File
@@ -12,7 +12,6 @@ import {
User, User,
} from "@/lib/db"; } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
type FriendOutcome = type FriendOutcome =
| "accepted" | "accepted"
@@ -377,8 +376,6 @@ export async function sendOfflineMessage(formData: FormData): Promise<void> {
.limit(1); .limit(1);
if (!recipient) { if (!recipient) {
outcome = "invalid"; outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else { } else {
await db.insert(MessengerOffline).values({ await db.insert(MessengerOffline).values({
userId: friendId, userId: friendId,
+5 -18
View File
@@ -1,14 +1,14 @@
// @ts-nocheck // @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { selectLimit, selectWhere, insertOnDup, mockSendMail, mockRedirect } = const { selectLimit, insertOnDup, mockSendMail, mockRedirect } = vi.hoisted(
vi.hoisted(() => ({ () => ({
selectLimit: vi.fn(), selectLimit: vi.fn(),
insertOnDup: vi.fn().mockResolvedValue({}), insertOnDup: vi.fn().mockResolvedValue({}),
selectWhere: vi.fn(() => Promise.resolve([] as Array<{ id: number }>)),
mockSendMail: vi.fn(), mockSendMail: vi.fn(),
mockRedirect: vi.fn(), mockRedirect: vi.fn(),
})); }),
);
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: (...args: unknown[]) => { redirect: (...args: unknown[]) => {
@@ -24,17 +24,6 @@ vi.mock("@/lib/db", () => {
from: vi.fn(() => ({ from: vi.fn(() => ({
where: vi.fn(() => ({ where: vi.fn(() => ({
limit: selectLimit, 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", () => { describe("requestReset", () => {
it("sends a reset email when the user exists", async () => { it("sends a reset email when the user exists", async () => {
selectLimit.mockResolvedValue([{ id: 1 }]); selectLimit.mockResolvedValue([{ id: 1 }]);
selectWhere.mockResolvedValue([{ id: 1 }]);
const fd = new FormData(); const fd = new FormData();
fd.set("email", "[email protected]"); fd.set("email", "[email protected]");
await expect(requestReset(fd)).rejects.toThrow("redirect"); await expect(requestReset(fd)).rejects.toThrow("redirect");
expect(selectWhere).toHaveBeenCalled(); expect(selectLimit).toHaveBeenCalled();
expect(insertOnDup).toHaveBeenCalled(); expect(insertOnDup).toHaveBeenCalled();
expect(mockSendMail).toHaveBeenCalledWith( expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]", "[email protected]",
@@ -99,7 +87,6 @@ describe("requestReset", () => {
it("does not send email when user is not found", async () => { it("does not send email when user is not found", async () => {
selectLimit.mockResolvedValue([]); selectLimit.mockResolvedValue([]);
selectWhere.mockResolvedValue([]);
const fd = new FormData(); const fd = new FormData();
fd.set("email", "[email protected]"); fd.set("email", "[email protected]");
+20 -50
View File
@@ -1,14 +1,11 @@
"use server"; "use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { asc, eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { env } from "@/env"; import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db"; import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -20,17 +17,6 @@ function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex"); 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> { export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "") const email = String(formData.get("email") ?? "")
.normalize("NFC") .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. // Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try { try {
const matches = await db const [user] = await db
.select({ id: User.id }) .select({ id: User.id })
.from(User) .from(User)
.where(eq(User.mail, email)) .where(eq(User.mail, email))
.orderBy(asc(User.id)); .limit(1);
if (matches.length > 1) {
logger.warn("Password reset address is not unique", {
email,
accountCount: matches.length,
using: matches[0]?.id,
});
}
const user = matches[0];
if (user) { 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 token = randomBytes(32).toString("hex");
const hashed = sha256(token); const hashed = sha256(token);
const createdAt = new Date(); 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. // 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) { 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; let error: string | null = null;
if (password.length < 12) error = "password"; if (password.length < 6) error = "Password must be at least 6 characters";
if (!error) { if (!error) {
try { try {
@@ -130,29 +107,20 @@ export async function resetPassword(formData: FormData): Promise<void> {
row != null && a.length === b.length && timingSafeEqual(a, b); row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) { if (!row || !fresh || !match) {
error = "invalid"; error = "This reset link is invalid or has expired";
} else { } else {
const matches = await db const [user] = await db
.select({ id: User.id }) .select({ id: User.id })
.from(User) .from(User)
.where(eq(User.mail, email)) .where(eq(User.mail, email))
.orderBy(asc(User.id)); .limit(1);
const user = matches[0];
if (!user) { if (!user) {
error = "invalid"; error = "Account not found";
} else { } else {
const newHash = await hashPassword(password); await db
// A password change has to end every existing session: the .update(User)
// popular reason for resetting is a compromised account, and a .set({ password: await hashPassword(password) })
// stolen cookie/API token must not outlive the reset. .where(eq(User.id, user.id));
await Promise.all([
db
.update(User)
.set({ password: newHash })
.where(eq(User.id, user.id)),
revokeUserCredentials(user.id),
]);
await invalidateLoginCache(email);
await db await db
.delete(PasswordReset) .delete(PasswordReset)
.where(eq(PasswordReset.email, email)) .where(eq(PasswordReset.email, email))
@@ -164,12 +132,14 @@ export async function resetPassword(formData: FormData): Promise<void> {
} }
} }
} catch { } catch {
error = "failed"; error = "Could not reset the password — try again";
} }
} }
if (error) { if (error) {
redirect(errorRedirect(email, token, error)); redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
);
} }
redirect("/login?reset=1"); 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: * Re-apply the same grant repair as migration 0018:
* - ranks with admin.dashboard get all admin.*.view (read-only: the sidebar * - ranks with admin.dashboard get all admin.*
* needs to open, nothing more — a blanket `admin.%` grant here is what
* promoted rank 6 to full admin)
* - ranks >= 6 get admin.*.view + dashboard * - ranks >= 6 get admin.*.view + dashboard
* - ranks >= 7 get edit/manage/execute tools used by the sidebar * - 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\`) INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id SELECT 'Role', ar.id, ap.id
FROM \`acl_roles\` ar FROM \`acl_roles\` ar
-- View slugs only: widening this to all admin.* turned "can open the JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
-- 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'
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 SELECT 1
FROM \`acl_model_permissions\` amp FROM \`acl_model_permissions\` amp
+2 -25
View File
@@ -184,7 +184,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "Username must be at least 3 characters", error: "Username must be at least 3 characters",
ok: false, ok: false,
code: "usernameMinLength",
}); });
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -192,7 +191,6 @@ describe("register", () => {
it("rejects usernames containing characters outside the allowed set", async () => { it("rejects usernames containing characters outside the allowed set", async () => {
const result = await register(PREV, buildForm({ username: "bad name!" })); const result = await register(PREV, buildForm({ username: "bad name!" }));
expect(result.error).toContain("letters, numbers, underscore and hyphen"); expect(result.error).toContain("letters, numbers, underscore and hyphen");
expect(result.code).toBe("usernamePattern");
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -201,7 +199,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "Enter a valid email address", error: "Enter a valid email address",
ok: false, ok: false,
code: "emailValid",
}); });
}); });
@@ -210,7 +207,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "Password must be at least 12 characters", error: "Password must be at least 12 characters",
ok: false, ok: false,
code: "passwordMinLength",
}); });
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -224,7 +220,6 @@ describe("register", () => {
}), }),
); );
expect(result.error).toContain("uppercase"); expect(result.error).toContain("uppercase");
expect(result.code).toBe("passwordUpper");
}); });
it("rejects passwords without a digit", async () => { it("rejects passwords without a digit", async () => {
@@ -236,7 +231,6 @@ describe("register", () => {
}), }),
); );
expect(result.error).toContain("digit"); expect(result.error).toContain("digit");
expect(result.code).toBe("passwordDigit");
}); });
it("rejects passwords without a special character", async () => { it("rejects passwords without a special character", async () => {
@@ -248,7 +242,6 @@ describe("register", () => {
}), }),
); );
expect(result.error).toContain("special"); expect(result.error).toContain("special");
expect(result.code).toBe("passwordSpecial");
}); });
it("rejects mismatched password confirmations", async () => { it("rejects mismatched password confirmations", async () => {
@@ -256,18 +249,13 @@ describe("register", () => {
PREV, PREV,
buildForm({ password_confirmation: "Different1" }), buildForm({ password_confirmation: "Different1" }),
); );
expect(result).toEqual({ expect(result).toEqual({ error: "Passwords do not match", ok: false });
error: "Passwords do not match",
ok: false,
code: "passwordsMatch",
});
}); });
it("throttles sign-ups per IP", async () => { it("throttles sign-ups per IP", async () => {
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 }); state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
const result = await runValidRegistration(); const result = await runValidRegistration();
expect(result.error).toContain("Too many sign-up attempts"); expect(result.error).toContain("Too many sign-up attempts");
expect(result.code).toBe("rateLimited");
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -285,7 +273,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "Captcha verification failed. Please try again.", error: "Captcha verification failed. Please try again.",
ok: false, ok: false,
code: "captchaFailed",
}); });
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9"); expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
@@ -303,7 +290,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "You must accept the terms and conditions to register.", error: "You must accept the terms and conditions to register.",
ok: false, ok: false,
code: "termsRequired",
}); });
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -312,11 +298,7 @@ describe("register", () => {
state.checkVpn.mockResolvedValue({ blocked: true }); state.checkVpn.mockResolvedValue({ blocked: true });
state.siteGet.mockResolvedValueOnce("Custom VPN message"); state.siteGet.mockResolvedValueOnce("Custom VPN message");
const result = await runValidRegistration(); const result = await runValidRegistration();
expect(result).toEqual({ expect(result).toEqual({ error: "Custom VPN message", ok: false });
error: "Custom VPN message",
ok: false,
code: "vpnBlocked",
});
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -335,7 +317,6 @@ describe("register", () => {
state.countTotal = 2; state.countTotal = 2;
const result = await runValidRegistration(); const result = await runValidRegistration();
expect(result.error).toContain("maximum number of accounts"); expect(result.error).toContain("maximum number of accounts");
expect(result.code).toBe("maxAccountsPerIp");
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -359,7 +340,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "That username is already taken", error: "That username is already taken",
ok: false, ok: false,
code: "usernameTaken",
}); });
expect(state.insert).not.toHaveBeenCalled(); expect(state.insert).not.toHaveBeenCalled();
}); });
@@ -370,7 +350,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "Registration is temporarily unavailable", error: "Registration is temporarily unavailable",
ok: false, ok: false,
code: "unavailable",
}); });
expect(state.logger.warn).toHaveBeenCalledWith( expect(state.logger.warn).toHaveBeenCalledWith(
"Username uniqueness check failed during registration", "Username uniqueness check failed during registration",
@@ -386,7 +365,6 @@ describe("register", () => {
expect(result).toEqual({ expect(result).toEqual({
error: "That username is already taken", error: "That username is already taken",
ok: false, ok: false,
code: "usernameTaken",
}); });
expect(state.logger.error).not.toHaveBeenCalled(); expect(state.logger.error).not.toHaveBeenCalled();
}); });
@@ -395,7 +373,6 @@ describe("register", () => {
state.insert.mockRejectedValueOnce(new Error("db exploded")); state.insert.mockRejectedValueOnce(new Error("db exploded"));
const result = await runValidRegistration(); const result = await runValidRegistration();
expect(result.error).toContain("Could not create the account"); expect(result.error).toContain("Could not create the account");
expect(result.code).toBe("createFailed");
expect(state.logger.error).toHaveBeenCalledWith( expect(state.logger.error).toHaveBeenCalledWith(
"Account creation failed", "Account creation failed",
expect.objectContaining({ message: "db exploded" }), expect.objectContaining({ message: "db exploded" }),
+7 -72
View File
@@ -121,72 +121,19 @@ const registerSchema = z
path: ["passwordConfirmation"], 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. // 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"; const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export interface RegisterState { export interface RegisterState {
error: string | null; error: string | null;
ok: boolean; ok: boolean;
/** Locale-independent reason, present on every failure. */
code?: RegisterErrorCode;
} }
export async function register( export async function register(
_prevState: RegisterState, _prevState: RegisterState,
formData: FormData, formData: FormData,
): Promise<RegisterState> { ): Promise<RegisterState> {
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({ const fail = (error: string): RegisterState => ({ error, ok: false });
error,
ok: false,
code,
});
const raw = { const raw = {
username: String(formData.get("username") ?? "") username: String(formData.get("username") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -208,8 +155,7 @@ export async function register(
const parsed = registerSchema.safeParse(raw); const parsed = registerSchema.safeParse(raw);
if (!parsed.success) { if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid input"; return fail(parsed.error.issues[0]?.message ?? "Invalid input");
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
} }
const { username, mail, password, look } = parsed.data; 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) { if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
return fail( return fail(
"Too many sign-up attempts. Please wait a few minutes and try again.", "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") { if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) if (!(await verifyCaptcha(token, ip)))
return fail( return fail("Captcha verification failed. Please try again.");
"Captcha verification failed. Please try again.",
"captchaFailed",
);
} }
// Terms acceptance check. // Terms acceptance check.
if (!raw.termsAccepted) if (!raw.termsAccepted)
return fail( return fail("You must accept the terms and conditions to register.");
"You must accept the terms and conditions to register.",
"termsRequired",
);
// VPN/proxy block (only when enabled in /admin/vpn). // VPN/proxy block (only when enabled in /admin/vpn).
if ((await checkVpn(ip)).blocked) { if ((await checkVpn(ip)).blocked) {
return fail( return fail(
(await siteSettings.get("vpn_block_message", "")) || (await siteSettings.get("vpn_block_message", "")) ||
"Registrations from VPN/proxy connections are not allowed.", "Registrations from VPN/proxy connections are not allowed.",
"vpnBlocked",
); );
} }
@@ -262,7 +200,6 @@ export async function register(
if (Number(row?.total ?? 0) >= max) if (Number(row?.total ?? 0) >= max)
return fail( return fail(
"You have reached the maximum number of accounts for your connection.", "You have reached the maximum number of accounts for your connection.",
"maxAccountsPerIp",
); );
} }
@@ -273,11 +210,10 @@ export async function register(
.from(User) .from(User)
.where(eq(User.username, username)) .where(eq(User.username, username))
.limit(1); .limit(1);
if (existing) if (existing) return fail("That username is already taken");
return fail("That username is already taken", "usernameTaken");
} catch { } catch {
logger.warn("Username uniqueness check failed during registration"); 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); const now = Math.floor(Date.now() / 1000);
@@ -297,7 +233,7 @@ export async function register(
} catch (err) { } catch (err) {
const code = (err as { cause?: { code?: string } }).cause?.code; const code = (err as { cause?: { code?: string } }).cause?.code;
if (code === "ER_DUP_ENTRY") { 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", { logger.error("Account creation failed", {
code, code,
@@ -305,7 +241,6 @@ export async function register(
}); });
return fail( return fail(
"Could not create the account. Please try again or contact staff.", "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.del.mockResolvedValue([{ affectedRows: 1 }]);
state.update.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 () => { 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, custom: "x" });
await updateRoomItem({ roomId: 9, itemId: 4, rot: 4, custom: "x" });
expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit"); expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit");
expect(state.update).toHaveBeenCalledWith( expect(state.update).toHaveBeenCalledWith(
Items, Items,
{ rot: 4 }, { custom: "x" },
expect.anything(), expect.anything(),
); );
expect(state.logStaffActivity).toHaveBeenCalledWith( 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 { logStaffActivity } from "@/lib/services/staff-activity";
import { notify } from "@/lib/services/webhook"; 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>) { export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT); const staff = await requirePermission(PERMS.ROOMS_EDIT);
const roomId = toPositiveInt(payload.roomId); const { roomId, itemId, ...data } = payload as {
const itemId = toPositiveInt(payload.itemId); roomId: number;
if (!roomId || !itemId) { itemId: number;
throw new Error("Invalid room or item id"); [key: string]: unknown;
} };
// 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");
await db await db
.update(Items) .update(Items)
.set( .set(data as Partial<typeof Items.$inferInsert>)
pickAllowed(payload, ROOM_ITEM_FIELDS) as Partial<
typeof Items.$inferInsert
>,
)
.where(eq(Items.id, itemId)); .where(eq(Items.id, itemId));
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
@@ -164,20 +117,24 @@ export async function deleteRoom({ id }: { id: number }) {
revalidatePath("/admin/rooms"); 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 staff = await requirePermission(PERMS.ROOMS_EDIT);
const roomId = toPositiveInt(id); await db.update(Rooms).set(data).where(eq(Rooms.id, 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 logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_update", action: "room_update",
description: `Updated room #${roomId}`, description: `Updated room #${id}`,
targetType: "room", 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, selectQueue: [] as Queue,
rows: [] as Array<Record<string, unknown>>, rows: [] as Array<Record<string, unknown>>,
failInsert: false, 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/cache", () => ({ revalidatePath: state.revalidatePath }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: (path: string) => { redirect: (path: string) => {
@@ -215,7 +210,6 @@ describe("postThread", () => {
state.failInsert = false; state.failInsert = false;
state.selectQueue = []; state.selectQueue = [];
state.rows = []; state.rows = [];
state.isAllowed.mockResolvedValue({ ok: true });
state.transaction.mockImplementation( state.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb), async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
); );
@@ -289,18 +283,6 @@ describe("postThread", () => {
expect(state.insert).not.toHaveBeenCalled(); 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 () => { it("reports not_found when the guild does not exist", async () => {
state.selectQueue = [[]]; state.selectQueue = [[]];
await expect(postThread(threadForm())).rejects.toThrow( await expect(postThread(threadForm())).rejects.toThrow(
@@ -342,7 +324,6 @@ describe("replyToThread", () => {
state.failInsert = false; state.failInsert = false;
state.selectQueue = []; state.selectQueue = [];
state.rows = []; state.rows = [];
state.isAllowed.mockResolvedValue({ ok: true });
state.transaction.mockImplementation( state.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb), 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 }); 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 () => { it("rejects missing or non-positive ids by redirecting to /guilds", async () => {
await expect(replyToThread(replyForm({ guildId: "abc" }))).rejects.toThrow( await expect(replyToThread(replyForm({ guildId: "abc" }))).rejects.toThrow(
"/guilds", "/guilds",
-5
View File
@@ -13,7 +13,6 @@ import {
MessengerFriendships, MessengerFriendships,
} from "@/lib/db"; } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; 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 // 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 // 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); .slice(0, MESSAGE_MAX);
if (!subject || !message) { if (!subject || !message) {
outcome = "invalid"; outcome = "invalid";
} else if (!(await isAllowed(`${subject} ${message}`)).ok) {
outcome = "invalid";
} else { } else {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@@ -329,8 +326,6 @@ export async function replyToThread(formData: FormData): Promise<void> {
.slice(0, MESSAGE_MAX); .slice(0, MESSAGE_MAX);
if (!message) { if (!message) {
outcome = "invalid"; outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else { } else {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
+2 -25
View File
@@ -78,22 +78,6 @@ async function verifyTwoFactorCode(
export async function beginTwoFactor(): Promise<void> { export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId(); const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); 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 secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret); const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes(); const codes = generateRecoveryCodes();
@@ -120,19 +104,12 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const { ok, updatedRecoveryCodes } = await verifyTwoFactorCode(id, code); const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode"); 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 await db
.update(User) .update(User)
.set({ .set({ twoFactorConfirmedAt: new Date() })
twoFactorConfirmedAt: new Date(),
...(updatedRecoveryCodes !== undefined
? { twoFactorRecoveryCodes: updatedRecoveryCodes }
: {}),
})
.where(eq(User.id, id)); .where(eq(User.id, id));
redirect("/settings/2fa?enabled=1"); redirect("/settings/2fa?enabled=1");
} }
-18
View File
@@ -8,7 +8,6 @@ const state = vi.hoisted(() => ({
updateCall: undefined as unknown, updateCall: undefined as unknown,
rconSetMotto: vi.fn(), rconSetMotto: vi.fn(),
failDbUpdate: false, failDbUpdate: false,
isAllowed: vi.fn(async () => ({ ok: true })),
})); }));
const databaseErrorClass = vi.hoisted( const databaseErrorClass = vi.hoisted(
@@ -64,10 +63,6 @@ vi.mock("@/lib/services/rcon", () => ({
rcon: { setMotto: state.rconSetMotto }, rcon: { setMotto: state.rconSetMotto },
})); }));
vi.mock("@/lib/services/moderation", () => ({
isAllowed: state.isAllowed,
}));
const mockRevalidatePath = vi.hoisted(() => vi.fn()); const mockRevalidatePath = vi.hoisted(() => vi.fn());
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath })); vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
@@ -103,7 +98,6 @@ beforeEach(() => {
state.updateCall = undefined; state.updateCall = undefined;
state.rconSetMotto.mockResolvedValue(true); state.rconSetMotto.mockResolvedValue(true);
state.failDbUpdate = false; state.failDbUpdate = false;
state.isAllowed.mockResolvedValue({ ok: true });
}); });
describe("updateMotto", () => { 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", () => { describe("updateMottoAction", () => {
it("denies unauthenticated callers", async () => { it("denies unauthenticated callers", async () => {
state.auth.mockResolvedValue(null); state.auth.mockResolvedValue(null);
-8
View File
@@ -6,7 +6,6 @@ import { z } from "zod";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { actionOk, authAction } from "@/lib/foundation/action"; import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors"; import { DatabaseError } from "@/lib/foundation/errors";
import { isAllowed } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
const MOTTO_MAX = 127; const MOTTO_MAX = 127;
@@ -17,14 +16,7 @@ const mottoSchema = z.object({
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`), .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) => { const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
await assertMottoAllowed(ctx.data.motto);
try { try {
await db await db
.update(User) .update(User)
-7
View File
@@ -62,9 +62,6 @@ vi.mock("@/lib/permissions", () => ({
USERS_BAN: "users.ban", USERS_BAN: "users.ban",
USERS_RESET_PASSWORD: "users.reset_password", 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", () => ({ vi.mock("@/lib/safe-action", () => ({
@@ -80,10 +77,6 @@ vi.mock("@/lib/services/audit", () => ({
logAudit: vi.fn(), logAudit: vi.fn(),
})); }));
vi.mock("@/lib/auth/session-revocation", () => ({
revokeUserCredentials: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/services/webhook", () => ({ vi.mock("@/lib/services/webhook", () => ({
notify: vi.fn(), notify: vi.fn(),
})); }));
+5 -36
View File
@@ -3,10 +3,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { invalidateLoginCache } from "@/lib/auth"; import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { import {
Ban, Ban,
db, db,
@@ -15,7 +13,7 @@ import {
UsersCurrency, UsersCurrency,
UsersSettings, UsersSettings,
} from "@/lib/db"; } from "@/lib/db";
import { getHighestRank, PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -56,9 +54,8 @@ export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema }, { permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => { async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data; const { username, mail, password, rank, motto } = ctx.data;
const actorRank = ctx.session.user.rank;
const highestRank = await getHighestRank(); if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
if (rank >= actorRank && !isDynamicSuperAdmin(actorRank, highestRank)) {
throw new ActionError("Cannot assign rank equal or higher than your own"); throw new ActionError("Cannot assign rank equal or higher than your own");
} }
@@ -133,7 +130,7 @@ export const updateUser = adminAction(
if ( if (
userData.rank !== undefined && userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank && 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"); throw new ActionError("Cannot assign rank equal or higher than your own");
} }
@@ -147,15 +144,7 @@ export const updateUser = adminAction(
motto: string; motto: string;
credits: number; credits: number;
pixels: 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) { if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id)); 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)) .where(eq(User.id, targetUserId))
.limit(1); .limit(1);
if (!target) throw new ActionError("User not found"); if (!target) throw new ActionError("User not found");
// The owner is whoever holds the hotel's highest rank *today*. The old if (target.rank >= sessionRank && sessionRank < 7) {
// `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)
) {
throw new ActionError("Cannot modify user with equal or higher rank"); throw new ActionError("Cannot modify user with equal or higher rank");
} }
return target; return target;
@@ -376,9 +358,6 @@ export const resetPassword = adminAction(
.update(User) .update(User)
.set({ password: hashed }) .set({ password: hashed })
.where(eq(User.id, ctx.data.userId)); .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); invalidateLoginCache(target.username);
logAudit({ logAudit({
@@ -440,19 +419,9 @@ const alertUserSchema = z.object({
export const alertUser = adminAction( export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema }, { permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => { async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message); const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success) if (!success)
throw new ActionError("Failed to send alert. Is the emulator running?"); 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(); 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)); const appliedRankIds = new Set(myApps.map((a) => a.rankId));
return ( return (
<section className="page-grid"> <main className="page-grid">
{submitted === "1" ? ( {submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.submitted")} {t("success.submitted")}
@@ -233,6 +233,6 @@ export default async function ApplyStaffPage({
})} })}
</div> </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)); const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
return ( return (
<section className="page-grid"> <main className="page-grid">
{submitted === "1" ? ( {submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.submitted")} {t("success.submitted")}
@@ -199,6 +199,6 @@ export default async function ApplyTeamPage({
})} })}
</div> </div>
)} )}
</section> </main>
); );
} }
+2 -2
View File
@@ -30,7 +30,7 @@ export default async function BadgesPage() {
.catch(() => []); .catch(() => []);
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard icon="🏅" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="🏅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={badges.length === 0}> <ContentCard padded={badges.length === 0}>
@@ -76,6 +76,6 @@ export default async function BadgesPage() {
</div> </div>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+2 -2
View File
@@ -52,7 +52,7 @@ export default async function BannedPage() {
}); });
return ( return (
<section style={{ maxWidth: 560, margin: "2rem auto" }}> <main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}> <ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}>
<p style={{ marginTop: 0 }}>{t("body")}</p> <p style={{ marginTop: 0 }}>{t("body")}</p>
{reason ? ( {reason ? (
@@ -65,6 +65,6 @@ export default async function BannedPage() {
{t("contactStaff")} {t("contactStaff")}
</p> </p>
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+2 -2
View File
@@ -54,7 +54,7 @@ export default function CommunityPage() {
const t = useTranslations("pages.community"); const t = useTranslations("pages.community");
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} />
<div className="card-grid sm-2 lg-3"> <div className="card-grid sm-2 lg-3">
@@ -84,6 +84,6 @@ export default function CommunityPage() {
</Link> </Link>
))} ))}
</div> </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); const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0);
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard <ContentCard
icon="🧩" icon="🧩"
title="Developer API" title="Developer API"
@@ -479,6 +479,6 @@ export default function DevelopersPage() {
</div> </div>
</ContentCard> </ContentCard>
))} ))}
</section> </main>
); );
} }
+2 -2
View File
@@ -102,7 +102,7 @@ export default async function DrawBadgePage({
} }
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard <ContentCard
icon="🎨" icon="🎨"
title="Draw a Badge" title="Draw a Badge"
@@ -231,6 +231,6 @@ export default async function DrawBadgePage({
</div> </div>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
@@ -33,8 +33,6 @@ export function EventRegisterButton({
run(() => registerForEvent({ eventId }), { run(() => registerForEvent({ eventId }), {
successMessage: t("registerSuccess"), successMessage: t("registerSuccess"),
errorMessage: t("registerError"), 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"); else if (isFull) disabledReason = t("eventFull");
return ( return (
<section className="page-grid"> <main className="page-grid">
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
<Link href="/events">{t("back")}</Link> <Link href="/events">{t("back")}</Link>
</p> </p>
@@ -259,6 +259,6 @@ export default async function EventDetailPage({
</ul> </ul>
</ContentCard> </ContentCard>
) : null} ) : null}
</section> </main>
); );
} }
+2 -2
View File
@@ -69,7 +69,7 @@ async function EventsPage({
const href = (page: number) => const href = (page: number) =>
`/events?${new URLSearchParams({ status: result?.status ?? "all", week: result?.week ?? "", mine: params.mine ?? "", page: String(page) })}`; `/events?${new URLSearchParams({ status: result?.status ?? "all", week: result?.week ?? "", mine: params.mine ?? "", page: String(page) })}`;
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard> <ContentCard>
@@ -272,7 +272,7 @@ async function EventsPage({
</nav> </nav>
</ContentCard> </ContentCard>
)} )}
</section> </main>
); );
} }
+5 -42
View File
@@ -1,4 +1,3 @@
import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset"; import { requestReset } from "@/actions/password-reset";
@@ -7,15 +6,6 @@ import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { captchaConfig } from "@/lib/services/captcha"; 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({ export default async function ForgotPage({
searchParams, searchParams,
}: { }: {
@@ -27,39 +17,12 @@ export default async function ForgotPage({
const nonce = (await headers()).get("x-nonce") ?? undefined; const nonce = (await headers()).get("x-nonce") ?? undefined;
return ( return (
<section style={{ maxWidth: 420, margin: "2rem auto" }}> <main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}> <ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}>
{sent ? ( {sent ? (
<> <p className="muted" style={{ textAlign: "center", margin: 0 }}>
<p className="muted" style={{ textAlign: "center", margin: 0 }}> {t("sentNotice")}
{t("sentNotice")} </p>
</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>
</>
) : ( ) : (
<form <form
action={requestReset} action={requestReset}
@@ -103,6 +66,6 @@ export default async function ForgotPage({
<Link href="/login">{t("backToLogin")}</Link> <Link href="/login">{t("backToLogin")}</Link>
</p> </p>
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+2 -2
View File
@@ -102,7 +102,7 @@ export default async function FriendsPage({
: null; : null;
return ( return (
<section className="page-grid"> <main className="page-grid">
{removed === "1" ? ( {removed === "1" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.removed")} {t("success.removed")}
@@ -180,6 +180,6 @@ export default async function FriendsPage({
</div> </div>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
@@ -103,11 +103,11 @@ export default async function GuildForumThreadPage({
} catch (error) { } catch (error) {
publicReadFailure("guild.thread")(error); publicReadFailure("guild.thread")(error);
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard> <ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} /> <PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} />
</ContentCard> </ContentCard>
</section> </main>
); );
} }
@@ -173,7 +173,7 @@ export default async function GuildForumThreadPage({
: null; : null;
return ( return (
<section className="page-grid"> <main className="page-grid">
{replied === "1" ? ( {replied === "1" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.replied")} {t("success.replied")}
@@ -317,6 +317,6 @@ export default async function GuildForumThreadPage({
{t("loginToReply")} <Link href="/login">{t("loginLink")}</Link> {t("loginToReply")} <Link href="/login">{t("loginLink")}</Link>
</p> </p>
)} )}
</section> </main>
); );
} }
@@ -70,7 +70,7 @@ export default async function NewThreadPage({
: null; : null;
return ( return (
<section className="page-grid"> <main className="page-grid">
{errorMessage ? ( {errorMessage ? (
<div role="alert" style={feedbackStyle("error")}> <div role="alert" style={feedbackStyle("error")}>
{errorMessage} {errorMessage}
@@ -132,6 +132,6 @@ export default async function NewThreadPage({
</div> </div>
</form> </form>
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+4 -4
View File
@@ -67,11 +67,11 @@ export default async function GuildForumPage({
} catch (error) { } catch (error) {
publicReadFailure("guild.forum")(error); publicReadFailure("guild.forum")(error);
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard> <ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum`} /> <PublicLoadError href={`/guilds/${guildId}/forum`} />
</ContentCard> </ContentCard>
</section> </main>
); );
} }
@@ -135,7 +135,7 @@ export default async function GuildForumPage({
const usernameById = new Map(users.map((u) => [u.id, u.username])); const usernameById = new Map(users.map((u) => [u.id, u.username]));
return ( return (
<section className="page-grid"> <main className="page-grid">
{posted === "1" ? ( {posted === "1" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.posted")} {t("success.posted")}
@@ -240,6 +240,6 @@ export default async function GuildForumPage({
</table> </table>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+4 -4
View File
@@ -55,11 +55,11 @@ export default async function GuildPage({
} catch (error) { } catch (error) {
publicReadFailure("guild.detail")(error); publicReadFailure("guild.detail")(error);
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard> <ContentCard>
<PublicLoadError href={`/guilds/${guildId}`} /> <PublicLoadError href={`/guilds/${guildId}`} />
</ContentCard> </ContentCard>
</section> </main>
); );
} }
@@ -139,7 +139,7 @@ export default async function GuildPage({
const created = formatDate(new Date(guild.dateCreated * 1000), "date"); const created = formatDate(new Date(guild.dateCreated * 1000), "date");
return ( return (
<section className="page-grid"> <main className="page-grid">
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href="/guilds">{t("allGuilds")}</Link> <Link href="/guilds">{t("allGuilds")}</Link>
</p> </p>
@@ -251,6 +251,6 @@ export default async function GuildPage({
</div> </div>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+2 -2
View File
@@ -50,7 +50,7 @@ export default async function GuildsPage() {
const guilds = await getGuilds(); const guilds = await getGuilds();
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={!guilds?.length}> <ContentCard padded={!guilds?.length}>
@@ -98,6 +98,6 @@ export default async function GuildsPage() {
</div> </div>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+2 -2
View File
@@ -103,7 +103,7 @@ export default async function HelpCategoryPage({
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== ""); const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return ( return (
<section className="page-grid"> <main className="page-grid">
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
<Link href="/help">{t("back")}</Link> <Link href="/help">{t("back")}</Link>
</p> </p>
@@ -162,6 +162,6 @@ export default async function HelpCategoryPage({
</div> </div>
</ContentCard> </ContentCard>
) : null} ) : null}
</section> </main>
); );
} }
+2 -2
View File
@@ -127,7 +127,7 @@ export default async function HelpCenterPage() {
} }
return ( return (
<section className="page-grid"> <main className="page-grid">
<ContentCard <ContentCard
icon="❓" icon="❓"
title={t("title")} title={t("title")}
@@ -278,6 +278,6 @@ export default async function HelpCenterPage() {
</div> </div>
)} )}
</ContentCard> </ContentCard>
</section> </main>
); );
} }
+2 -2
View File
@@ -157,7 +157,7 @@ export default async function HelpTicketDetailPage({
]; ];
return ( return (
<section className="page-grid"> <main className="page-grid">
{replied === "1" ? ( {replied === "1" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.replied")} {t("success.replied")}
@@ -302,6 +302,6 @@ export default async function HelpTicketDetailPage({
{t("closedHint")} {t("closedHint")}
</p> </p>
)} )}
</section> </main>
); );
} }
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more