Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12319db0b | ||
|
|
48291ab641 | ||
|
|
6b34293cd3 | ||
|
|
5cb42c8dfb | ||
|
|
759ae91745 | ||
|
|
179484642f | ||
|
|
6cc45d7413 | ||
|
|
3933214953 | ||
|
|
8561c3f85e | ||
|
|
d2350a6427 | ||
|
|
0845f80768 | ||
|
|
3265c149da | ||
|
|
57710a7fe3 | ||
|
|
5b2eb91c5c | ||
|
|
11ad6d4376 | ||
|
|
6c3d81920e | ||
|
|
108c6ce03d | ||
|
|
6bffc53779 | ||
|
|
3d828a61ab | ||
|
|
7f07c111ac | ||
|
|
8218039c64 | ||
|
|
f705c67fc7 | ||
|
|
c8b3054527 | ||
|
|
8ec3df541e | ||
|
|
8ee144745a | ||
|
|
64ad9baf39 | ||
|
|
704e33638f | ||
|
|
eddb7edea4 | ||
|
|
1c9ddcd48a |
No files matched your search
@@ -33,6 +33,12 @@ jobs:
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
# Port selection decides which blue/green slot stays live. Getting it
|
||||
# wrong starts the candidate on an occupied port, so the regression that
|
||||
# caused a failed deploy is covered here, before any image is built.
|
||||
- name: Deploy port-selection tests
|
||||
run: bash scripts/ci-deploy-ports.test.sh
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
image: node:26
|
||||
|
||||
# Runner containers have no systemd, so scripts/with-memory-cap.sh cannot
|
||||
# enforce an RSS cap there and correctly refuses to run unbounded. These
|
||||
# builds are already isolated inside their own runner container (not the
|
||||
# host) and use the webpack builder; opt out explicitly on purpose.
|
||||
variables:
|
||||
CMS_MEMORY_CAP_BACKEND: "none"
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
+22
-4
@@ -3,10 +3,12 @@ FROM node:26.10.0-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Installeer git en pnpm v12
|
||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||
# Alpine levert geen bash mee.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g pnpm@12.8.1
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g pnpm@12.10.1
|
||||
|
||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||
@@ -26,6 +28,22 @@ FROM migrations AS builder
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
|
||||
# The build runs the webpack builder (see the `build` script in package.json).
|
||||
# Turbopack's compiler is a single native process that grows past 12GB RSS on
|
||||
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
|
||||
# --max-old-space-size cap does NOT help, because that memory is native
|
||||
# Turbopack memory rather than the V8 heap.
|
||||
#
|
||||
# `pnpm run build` goes through scripts/with-memory-cap.sh. BuildKit's build
|
||||
# container has /sys/fs/cgroup mounted read-only (no cgroup MemoryMax) and
|
||||
# `ulimit -v` breaks V8-based builds (see the script header), so this stage
|
||||
# explicitly opts out of the cap. The real bound here is the webpack builder
|
||||
# + the V8 heap cap above, and the build runs isolated in its own container,
|
||||
# not on the host; the host itself is protected by the same wrapper through
|
||||
# systemd.
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
ENV CMS_MEMORY_CAP_BACKEND=none
|
||||
|
||||
# Bouw de Next.js applicatie met caching
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
@@ -57,4 +75,4 @@ EXPOSE 3002
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "docker-start.mjs"]
|
||||
CMD ["node", "docker-start.mjs"]
|
||||
@@ -875,6 +875,24 @@ that the CI deploy path deliberately uses `docker run` rather than compose, so
|
||||
the resource limits are declared in **both** places — limits that only existed
|
||||
in compose would never apply to a real release.
|
||||
|
||||
### Compose on a CI host
|
||||
|
||||
Compose and CI both want port 3002, so only one of them can own a host. A stray
|
||||
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
|
||||
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
|
||||
green slot, and every later release stopped on "Port 3002 is already in use" —
|
||||
after the build, the migrations and the browser gate. Two guards now prevent
|
||||
that:
|
||||
|
||||
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
|
||||
`epicnext-cms-app`, but after a cutover to the green slot that container is
|
||||
stopped and deleted, so the guard stopped firing while the host stayed
|
||||
CI-managed. It now checks both slot containers and the nginx upstream.
|
||||
- `ci-deploy.sh` retires a compose replica of *this* checkout
|
||||
(`com.docker.compose.project.config_files`) from the candidate port before
|
||||
starting the candidate — but never a slot container, and never the port nginx
|
||||
currently serves. Anything else still fails loudly in `assert_port_free`.
|
||||
|
||||
### The nginx upstream is the switch
|
||||
|
||||
nginx does not know about container names; it reads a plain list of backends from
|
||||
@@ -951,7 +969,7 @@ branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
|
||||
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
||||
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm analyze` | Build + report per-route bundle sizes |
|
||||
| `pnpm jobs:worker` | Start background task worker |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
@@ -1080,7 +1098,7 @@ The CMS automatically translates furniture names and descriptions to **13 langua
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm typecheck # Type check all files
|
||||
pnpm test # Run test suite
|
||||
pnpm analyze # Build and analyze bundle sizes
|
||||
pnpm analyze # Build and report per-route bundle sizes
|
||||
pnpm biome:check # Lint and format
|
||||
```
|
||||
|
||||
|
||||
+24
@@ -38,6 +38,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
|
||||
@@ -162,6 +162,22 @@ server {
|
||||
ssl_early_data on;
|
||||
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
|
||||
|
||||
# Resuming a session skips the full handshake, which is most of the cost of
|
||||
# a TLS connection. Without this nginx performs no session resumption at all:
|
||||
# every visitor paid a full handshake on every request. 50M shared sessions
|
||||
# is roughly 1GB at the default 20-byte key id plus overhead.
|
||||
ssl_session_cache shared:CMS_TLS:50m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# `index index.html` without a `root` left nginx resolving every
|
||||
# try_files/$uri against the compiled-in default /etc/nginx/html. The
|
||||
# /robots.txt and /favicon.ico probes then stat() a path the worker cannot
|
||||
# traverse, and because a failed stat is logged at crit the error log filled
|
||||
# with 149 crit lines per scan. Pointing root at the CMS document root makes
|
||||
# the same probe a plain 404, which log_not_found already suppresses.
|
||||
root /var/www/html;
|
||||
|
||||
index index.html;
|
||||
|
||||
# ─── Security Headers ───
|
||||
@@ -366,8 +382,21 @@ server {
|
||||
add_header Cache-Tag "cms-camera";
|
||||
}
|
||||
|
||||
# robots.txt is generated by the CMS (src/app/robots.ts, force-dynamic
|
||||
# because it needs APP_URL) and sitemap.xml points crawlers at it. This
|
||||
# location used to answer from disk with try_files, which made it a
|
||||
# guaranteed 404: the file does not exist in public/, so crawlers were told
|
||||
# to obey a robots.txt they could never read. Proxy it like the route it
|
||||
# actually is. favicon.ico below stays on disk — log_not_found already
|
||||
# keeps its miss quiet.
|
||||
location = /robots.txt {
|
||||
access_log off;
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
||||
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
|
||||
|
||||
# ─── Static Next.js Assets ───
|
||||
location /_next/static/ {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
|
||||
# and health probes). This is NOT optional: a web process alone does not
|
||||
# establish that scheduled work runs. The CMS reports it as a failed
|
||||
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
|
||||
# missing, which is exactly what happened while nothing supervised this.
|
||||
#
|
||||
# It runs on the host rather than in a container on purpose: the schedule
|
||||
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
|
||||
# and it must survive CMS deploys (a container is replaced on every release).
|
||||
Description=AtomNext CMS scheduled-job worker
|
||||
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
|
||||
After=network-online.target docker.service mariadb.service
|
||||
Wants=network-online.target
|
||||
# Start ordering only; the worker tolerates the database being briefly absent
|
||||
# and retries, so do not make it hard-fail when mariadb is slow to boot.
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/var/www/atom-nexst
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
|
||||
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
|
||||
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
|
||||
# bad DATABASE_URL) from spinning.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Give a crashed job time to finish its DB transaction before the next start,
|
||||
# otherwise a mid-transaction kill can loop on the same failure.
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=cms-jobs-worker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Service]
|
||||
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
|
||||
# inherits that soft limit, so worker_connections 2048 could not actually be
|
||||
# reached and every start logged:
|
||||
# "2048 worker_connections exceed open file resource limit: 1024"
|
||||
# Raise both soft and hard to 65536 so the master's rlimit covers
|
||||
# worker_connections before nginx is even started.
|
||||
LimitNOFILE=65536
|
||||
|
||||
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
|
||||
# down until someone noticed. nginx is the only thing serving the site, so it
|
||||
# must come back on its own. `on-failure` restarts only abnormal exits, which
|
||||
# keeps an operator-initiated `systemctl stop` from being undone.
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Give in-flight requests time to drain on stop/reload instead of severing
|
||||
# keepalive connections and long-polling SSE streams mid-response.
|
||||
TimeoutStopSec=30
|
||||
+3
-40
@@ -1,18 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
#
|
||||
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
|
||||
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
|
||||
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
|
||||
#
|
||||
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
|
||||
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
|
||||
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
|
||||
# nieuwe replica, dan blijft de oude gewoon draaien.
|
||||
#
|
||||
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
|
||||
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
|
||||
# afwijkende velden opnieuw in.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -23,8 +10,6 @@ x-cms: &cms
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
network: host
|
||||
network_mode: host
|
||||
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
|
||||
# volgt. Met 10s werden streams en imports afgekapt.
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -36,20 +21,11 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
# De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
|
||||
# gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
|
||||
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
|
||||
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
|
||||
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
|
||||
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
|
||||
# volledig door twee replicas worden opgeëist.
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
|
||||
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
|
||||
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
@@ -58,7 +34,6 @@ x-cms: &cms
|
||||
start_period: 40s
|
||||
|
||||
services:
|
||||
# Blauwe replica: host-poort 3002.
|
||||
cms:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms
|
||||
@@ -66,8 +41,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
|
||||
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
@@ -76,7 +49,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
# ── Byparr (Cloudflare bypass for clone sources) ──
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
@@ -84,19 +56,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
|
||||
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
||||
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
||||
pids_limit: 256
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# 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.
|
||||
start_period: 30s
|
||||
@@ -12,17 +12,25 @@ The command writes `report.json` and `report.md` and prints the Markdown report.
|
||||
|
||||
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
|
||||
|
||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
||||
|
||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||
|
||||
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||
|
||||
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||
|
||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
|
||||
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from the manifest's chunk lists are excluded.
|
||||
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
|
||||
|
||||
## Initial limits
|
||||
|
||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
|
||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
|
||||
|
||||
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
|
||||
|
||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Repair the escalation introduced by 0018's rule 1 ("has admin.dashboard gets
|
||||
-- ALL admin.*"). Migrating 0011 grants admin.dashboard to every rank >= 6 so
|
||||
-- that the sidebar opens, which meant rank 6 silently acquired
|
||||
-- admin.permissions.manage, admin.rcon.execute, admin.settings.edit,
|
||||
-- admin.users.edit, admin.users.reset_password, admin.room.delete, ...
|
||||
--
|
||||
-- Rule 1 is narrowed to `admin.%.view` (read-only, all the sidebar needs) in
|
||||
-- both the migration set and the runtime repair action. This migration undoes
|
||||
-- the over-grant on databases that already ran 0018: every role below the top
|
||||
-- rank keeps dashboard + *.view and loses every other admin.* grant. Ranks
|
||||
-- that legitimately hold tools keep them, because rule 3 only targets
|
||||
-- rank >= 7 and those roles are not touched here.
|
||||
--
|
||||
-- Note on the rank extraction: `acl_roles.slug` looks like `rank_7`, and
|
||||
-- MySQL's SUBSTRING is 1-based, so the digits start at position 6 — right
|
||||
-- after the 5-character `rank_`. Reading from position 7 truncates the first
|
||||
-- digit, which turns rank_10 into 0 and rank_7 into an empty string, i.e. both
|
||||
-- would compare as < 7 and lose grants this migration is supposed to preserve.
|
||||
-- The REGEXP guard below guarantees the remainder really is all digits.
|
||||
|
||||
DELETE `amp`
|
||||
FROM `acl_model_permissions` `amp`
|
||||
JOIN `acl_roles` `ar`
|
||||
ON `ar`.`id` = `amp`.`model_id`
|
||||
AND `amp`.`model_type` = 'Role'
|
||||
JOIN `acl_permissions` `ap`
|
||||
ON `ap`.`id` = `amp`.`permission_id`
|
||||
WHERE `ap`.`slug` LIKE 'admin.%'
|
||||
AND `ap`.`slug` NOT LIKE '%.view'
|
||||
AND `ar`.`slug` REGEXP '^rank_[0-9]+$'
|
||||
AND CAST(SUBSTRING(`ar`.`slug`, 6) AS UNSIGNED) < 7;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 0035_users_mail_index.sql
|
||||
-- Index on users.mail.
|
||||
--
|
||||
-- The authentication paths all look an account up by mail: password reset,
|
||||
-- e-mail verification, duplicate-address detection and the verify/resend
|
||||
-- cooldown all resolve a single user from a submitted address. Without an index
|
||||
-- each of those is a full table scan of `users`, which grows with every
|
||||
-- registration.
|
||||
--
|
||||
-- Deliberately NOT unique. Legacy rows predate the duplicate-address handling
|
||||
-- and can legitimately contain the same address more than once, so a unique
|
||||
-- index would fail to apply on an existing database. The lookup is made
|
||||
-- deterministic by ordering on `id` (see requestReset / the verify page), which
|
||||
-- is stable without the index and correct with it.
|
||||
--
|
||||
-- The column is VARCHAR(500), which exceeds the 767-byte InnoDB prefix limit on
|
||||
-- older row formats, hence an explicit 191-character prefix: enough to make the
|
||||
-- lookup selective and still indexable everywhere.
|
||||
CREATE INDEX IF NOT EXISTS `users_mail_index` ON `users` (`mail`(191));
|
||||
@@ -88,7 +88,10 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.press("Enter");
|
||||
await expect(page).toHaveURL(/\/me(?:\?|$)/);
|
||||
// The login page honours `?from=`, so an admin bounced off /admin lands
|
||||
// back where they were heading instead of on /me. The step below
|
||||
// navigates there explicitly anyway; this asserts the redirect target.
|
||||
await expect(page).toHaveURL(/\/admin\/articles\/new(?:\?|$)/);
|
||||
const session = await context.request
|
||||
.get("/api/auth/session")
|
||||
.then((response) => response.json());
|
||||
|
||||
@@ -156,7 +156,14 @@ beforeAll(async () => {
|
||||
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
|
||||
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
|
||||
// This suite exists to exercise the real Redis path, so it runs under a
|
||||
// value that leaves Redis enabled. "development" is used because it is the
|
||||
// only non-production value src/env.ts accepts. Vitest's own environment is
|
||||
// still configured via vitest.integration.config.ts. Object.assign is used
|
||||
// because process.env.NODE_ENV is typed read-only.
|
||||
Object.assign(process.env, { NODE_ENV: "development" });
|
||||
process.env.HOTEL_NAME = "Integration";
|
||||
|
||||
await connection.query(
|
||||
@@ -380,9 +387,8 @@ describe("Redis application cache", () => {
|
||||
let fetches = 0;
|
||||
const fetch = async () => ({ revision: ++fetches });
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(
|
||||
await cache.cached(key, 60000, async () => ({ revision: 1 })),
|
||||
).resolves.toMatchObject({ revision: 1 });
|
||||
// Second read is served from cache, so the origin is not consulted again.
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||
cache.invalidateMemory(key);
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
@@ -403,6 +409,9 @@ describe("Redis application cache", () => {
|
||||
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
||||
"updated",
|
||||
);
|
||||
// `second`'s memory copy was dropped too, but its Redis entry survives, so
|
||||
// the read is served from the shared cache and never recomputes. This is
|
||||
// what makes the two entries independent.
|
||||
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
||||
"second",
|
||||
);
|
||||
@@ -620,9 +629,12 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(existing.status).toBe("draft");
|
||||
expect(existing.publishedAt).toBeNull();
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
// A draft has no public article, so this read is a negative result that
|
||||
// gets cached. Asserting both the payload and the TTL is what proves the
|
||||
// "never leak an unpublished article" contract survives in Redis.
|
||||
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
expect(await appRedis?.get(negativeKey)).toBeNull();
|
||||
expect(await appRedis?.get(negativeKey)).toBe("null");
|
||||
expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0);
|
||||
|
||||
const body = `<p>${"Contenuto completo è 📰 ".repeat(4000)}</p>`;
|
||||
@@ -839,7 +851,8 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
expect(await redis.get(negativeKey)).toBeNull();
|
||||
// Cached negative results are stored as the JSON encoding of null.
|
||||
expect(await redis.get(negativeKey)).toBe("null");
|
||||
const publish = articleForm({
|
||||
id: String(existing.id),
|
||||
baseToken: articleEditToken(existing),
|
||||
|
||||
+25
-25
@@ -5,10 +5,10 @@
|
||||
"engines": {
|
||||
"node": ">=26.10.0 <27"
|
||||
},
|
||||
"packageManager": "pnpm@12.8.1",
|
||||
"packageManager": "pnpm@12.10.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm assets:editor && next dev",
|
||||
"build": "pnpm assets:editor && next build",
|
||||
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev",
|
||||
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
|
||||
"start": "next start",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
@@ -16,9 +16,9 @@
|
||||
"format": "biome format --write .",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run --coverage.enabled=false",
|
||||
"test:coverage": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
||||
@@ -30,25 +30,25 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "next experimental-analyze",
|
||||
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:news:real": "node --import tsx e2e/news-real/run.ts",
|
||||
"test:ui": "playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test",
|
||||
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
|
||||
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"performance:report": "node scripts/performance-report.mjs",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts"
|
||||
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.20",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.104.1",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
@@ -59,16 +59,16 @@
|
||||
"drizzle-orm": "0.45.3",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"isomorphic-dompurify": "^4.4.0",
|
||||
"isomorphic-dompurify": "^4.5.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.50.0",
|
||||
"lucide-react": "1.52.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "14.0.0",
|
||||
"music-metadata": "11.16.1",
|
||||
"music-metadata": "12.0.0",
|
||||
"mysql2": "3.24.5",
|
||||
"next": "16.3.8",
|
||||
"next": "16.4.0",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
@@ -76,17 +76,17 @@
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.89.0",
|
||||
"resend": "6.32.0",
|
||||
"resend": "6.32.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"tinymce": "8.9.2",
|
||||
"tinymce": "8.9.3",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/parser": "7.29.9",
|
||||
"@babel/parser": "8.0.7",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
@@ -98,14 +98,14 @@
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "3.0.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"msw": "^2.15.0",
|
||||
"pino-pretty": "13.2.0",
|
||||
"postcss": "8.5.29",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.2.0",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.2",
|
||||
"vite": "8.3.3",
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
Generated
+360
-371
File diff suppressed because it is too large.
Load diff
@@ -16,3 +16,4 @@ overrides:
|
||||
glob: '^11.0.0'
|
||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
||||
source-map-js: '1.2.2'
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -45,11 +45,28 @@ for (const image of nodeImages) {
|
||||
);
|
||||
}
|
||||
|
||||
const cmpVersion = (a, b) => {
|
||||
const pa = a.split(".").map((part) => Number.parseInt(part, 10));
|
||||
const pb = b.split(".").map((part) => Number.parseInt(part, 10));
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// `.nvmrc` is the recommended version for reproducible local development and
|
||||
// the exact Docker base image (both asserted above), but the *runtime* we run
|
||||
// on may be any version the package.json engines range accepts. Requiring an
|
||||
// exact patch match here would fail on every Node.js patch release, even when
|
||||
// the version is explicitly supported.
|
||||
if (!process.argv.includes("--static")) {
|
||||
assert.equal(
|
||||
process.versions.node,
|
||||
pinnedVersion,
|
||||
"the active Node.js runtime must match .nvmrc",
|
||||
const active = process.versions.node;
|
||||
const upperBound = `${major + 1}.0.0`;
|
||||
assert.ok(
|
||||
cmpVersion(active, pinnedVersion) >= 0 &&
|
||||
cmpVersion(active, upperBound) < 0,
|
||||
`the active Node.js runtime (${active}) must satisfy package.json engines.node (${packageJson.engines.node}); .nvmrc pins ${pinnedVersion} as the recommended version`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
|
||||
#
|
||||
# Background: on a host where both blue/green slots answer /api/health, the
|
||||
# original read_active_port() counted healthy slots and only consulted the nginx
|
||||
# upstream when the count was not exactly 1. With two healthy slots it fell back
|
||||
# to the upstream file, but an operator `docker compose up` can leave an extra
|
||||
# replica behind, after which the fallback picked slot A regardless of which slot
|
||||
# was really live. The candidate then tried to start on an occupied port, and the
|
||||
# health probe answered from the pre-existing container on that port instead of
|
||||
# the candidate — producing 30 failed "expected release never became healthy"
|
||||
# attempts against a release that was never serving.
|
||||
#
|
||||
# The functions are extracted from ci-deploy.sh rather than copied so this test
|
||||
# cannot drift from the script it protects.
|
||||
set -Eeuo pipefail
|
||||
|
||||
deploy_script="$(dirname "$0")/ci-deploy.sh"
|
||||
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
|
||||
|
||||
# Pull the two functions out of the real script.
|
||||
extract() {
|
||||
sed -n "/^$1() {/,/^}/p" "$deploy_script"
|
||||
}
|
||||
|
||||
read_active_port_fn="$(extract read_active_port)"
|
||||
assert_port_free_fn="$(extract assert_port_free)"
|
||||
answers_health_fn="$(extract answers_health)"
|
||||
|
||||
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
|
||||
echo "could not extract functions from $deploy_script" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
slot_a_port=3002
|
||||
slot_b_port=3003
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# ── read_active_port ──────────────────────────────────────────────────────────
|
||||
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
|
||||
run_read_active_port() {
|
||||
local body="$1" a="$2" b="$3" tmp
|
||||
tmp="$(mktemp)"
|
||||
if [ "$body" = "none" ]; then
|
||||
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
|
||||
rm -f "$tmp"
|
||||
else
|
||||
printf '%s\n' "$body" >"$tmp"
|
||||
fi
|
||||
CMS_UPSTREAM_FILE="$tmp" \
|
||||
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
|
||||
bash -c "
|
||||
slot_a_port=$slot_a_port
|
||||
slot_b_port=$slot_b_port
|
||||
upstream_file=\"\$CMS_UPSTREAM_FILE\"
|
||||
$read_active_port_fn
|
||||
# Defined after the extracted function on purpose: answers_health is a
|
||||
# collaborator here, and the test substitutes a deterministic stub for it.
|
||||
answers_health() {
|
||||
local p=\$1 want
|
||||
case \$p in
|
||||
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
|
||||
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
|
||||
*) want='' ;;
|
||||
esac
|
||||
[ \"\$want\" = yes ]
|
||||
}
|
||||
read_active_port
|
||||
echo
|
||||
" 2>/dev/null
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# nginx points at slot B and both answer -> trust the upstream file.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
|
||||
|
||||
# The regression: both healthy, nginx points at B, but slot A is an unrelated
|
||||
# leftover replica. The upstream file is the only thing that knows which slot is
|
||||
# live, so it must win.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
|
||||
|
||||
# Upstream names a dead slot: fall back to a slot that actually answers, never to
|
||||
# the dead port itself.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
|
||||
|
||||
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
|
||||
# rollback path has no target.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
|
||||
|
||||
# No upstream file at all: pick a slot that answers.
|
||||
got="$(run_read_active_port none no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port none yes no)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
|
||||
|
||||
# ── assert_port_free ─────────────────────────────────────────────────────────
|
||||
# Runs against real loopback ports: 3999 is intentionally unused, so the check
|
||||
# must report it free.
|
||||
bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3999 candidate >/dev/null 2>&1
|
||||
" || fail "a port with no listener must be reported as free"
|
||||
|
||||
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
|
||||
# it genuinely is free, otherwise the assertion would be meaningless.
|
||||
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
|
||||
if bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3002 candidate >/dev/null 2>&1
|
||||
"; then
|
||||
fail "an occupied port must be rejected, but assert_port_free returned success"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo 'Deploy port-selection tests passed'
|
||||
+183
-30
@@ -76,6 +76,13 @@ healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
|
||||
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
|
||||
# een al draaiend proces dat nu of nooit antwoordt.
|
||||
answers_health() {
|
||||
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
|
||||
}
|
||||
|
||||
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
|
||||
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
|
||||
# niet stilvalt op een upgrade.
|
||||
@@ -85,29 +92,156 @@ detect_blue_green() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
|
||||
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
|
||||
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
|
||||
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
|
||||
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
|
||||
# Welke poort is op dit moment ÉCHT live?
|
||||
#
|
||||
# Volgorde van vertrouwen:
|
||||
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
|
||||
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
|
||||
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
|
||||
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
|
||||
#
|
||||
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
|
||||
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
|
||||
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
|
||||
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
|
||||
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
|
||||
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
|
||||
# container die toevallig een andere release draaide.
|
||||
read_active_port() {
|
||||
local live="" port="" result=""
|
||||
local count=0
|
||||
for port in "$slot_a_port" "$slot_b_port"; do
|
||||
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
|
||||
live="$live $port"
|
||||
fi
|
||||
done
|
||||
for port in $live; do count=$((count + 1)); result="$port"; done
|
||||
if [ "$count" -eq 1 ]; then
|
||||
printf '%s' "$result"
|
||||
local port="" pointed=""
|
||||
|
||||
if [ -r "$upstream_file" ]; then
|
||||
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$port" ] && answers_health "$port"; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
case "$port" in
|
||||
"$slot_b_port") printf '%s' "$slot_b_port" ;;
|
||||
*) printf '%s' "$slot_a_port" ;;
|
||||
|
||||
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
|
||||
# enige andere gezonde slot, anders is er niets om op te bouwen.
|
||||
for candidate in "$slot_a_port" "$slot_b_port"; do
|
||||
[ "$candidate" = "$port" ] && continue
|
||||
if answers_health "$candidate"; then
|
||||
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
|
||||
# rollback-poging toch het vorige slot kan starten.
|
||||
if [ -n "$port" ]; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$slot_a_port"
|
||||
}
|
||||
|
||||
# Poort-bezetting controleren vóór het starten van de kandidaat.
|
||||
#
|
||||
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
|
||||
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
|
||||
# container op diezelfde poort beantwoordt. Dat levert een misleidende
|
||||
# "expected release never became healthy" op in plaats van de echte oorzaak.
|
||||
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
|
||||
# hem vasthoudt.
|
||||
assert_port_free() {
|
||||
local port="$1" name="$2"
|
||||
local holders=""
|
||||
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
|
||||
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
|
||||
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
|
||||
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
|
||||
# hoeft te zien.
|
||||
if type ss >/dev/null 2>&1; then
|
||||
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
|
||||
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
|
||||
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
|
||||
fi
|
||||
[ -z "$holders" ] && return 0
|
||||
echo "Port $port is already in use, cannot start candidate $name" >&2
|
||||
printf '%s\n' "$holders" >&2
|
||||
|
||||
# Noem exact het container dat de poort vasthoudt.
|
||||
#
|
||||
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
|
||||
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
|
||||
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
|
||||
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
|
||||
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
|
||||
# onschuldige containers begraven.
|
||||
local squatter="squatter_pids"
|
||||
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
|
||||
if [ -n "$squatter_pids" ]; then
|
||||
local pid cid owner=""
|
||||
for pid in $squatter_pids; do
|
||||
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
|
||||
[ -n "$cid" ] || continue
|
||||
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
|
||||
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
|
||||
done
|
||||
fi
|
||||
echo "" >&2
|
||||
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
|
||||
# naam die toevallig op een van deze poorten draait — meestal een
|
||||
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
|
||||
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
|
||||
# dus niet geraakt.
|
||||
case "$owner" in
|
||||
*"/$name"*|*"/$slot_b_container"*)
|
||||
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
|
||||
*)
|
||||
cat >&2 <<EOF
|
||||
This port is held by a container that is not a blue/green slot, so the deploy
|
||||
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
|
||||
the other slot until cutover.
|
||||
|
||||
Remove the stray container and re-run the deploy:
|
||||
|
||||
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
|
||||
|
||||
If it comes back after a reboot, it is started by docker-compose.yml rather
|
||||
than by this script; delete or disable that service.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Ruim een compose-replica op die een blauwe/groene slot bezet.
|
||||
#
|
||||
# Een `docker compose up` — of de dagelijkse `scripts/docker-update.sh`, waarvan
|
||||
# de CI-eigendomscontrole per slot wankelde — laat een replica met container_name
|
||||
# `epicnext-cms` achter op poort 3002. Die draait nooit live: nginx wijst naar de
|
||||
# poort van een slot-container die dit script zelf heeft gestart, en die staat per
|
||||
# definitie aan de andere kant dan de kandidaat. Zonder deze opruimstap loopt elke
|
||||
# release vast op een bezette poort totdat iemand de container met de hand
|
||||
# verwijdert.
|
||||
#
|
||||
# Bewust smal, want een container van een ander deployment is niet van ons:
|
||||
# - alleen een replica die uit precies deze checkout komt
|
||||
# (com.docker.compose.project.config_files), niet een losse compose-project;
|
||||
# - nooit een slot-container, want die beheert dit script zelf;
|
||||
# - nooit de poort waar nginx naar wijst.
|
||||
# Wat daarnaast nog op de doel-poort zit, laat assert_port_free() met zijn eigen
|
||||
# foutmelding staan in plaats van stilzwijgend verdwijnen.
|
||||
retire_compose_replicas() {
|
||||
local live_port="$1" cid name="" config_files="" port=""
|
||||
while read -r cid; do
|
||||
[ -n "$cid" ] || continue
|
||||
name="$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null | sed -n 's#^/##p' || true)"
|
||||
case "$name" in ''|"$slot_a_container"|"$slot_b_container") continue ;; esac
|
||||
config_files="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$cid" 2>/dev/null || true)"
|
||||
[ "$config_files" = "$deploy_dir/docker-compose.yml" ] || continue
|
||||
port="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$cid" 2>/dev/null | sed -n 's#^PORT=##p' | head -1 || true)"
|
||||
[ -n "$port" ] && [ "$port" != "$live_port" ] || continue
|
||||
echo "Removing compose replica $name on port $port: it squats a blue/green slot and is not the live release (nginx serves $live_port)"
|
||||
docker rm -f "$name" || return 1
|
||||
done < <(docker ps --filter "label=com.docker.compose.project.config_files=$deploy_dir/docker-compose.yml" --format '{{.ID}}')
|
||||
return 0
|
||||
}
|
||||
|
||||
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
|
||||
@@ -153,6 +287,7 @@ switch_upstream() {
|
||||
# gelden.
|
||||
start_candidate() {
|
||||
local port="$1" name="$2"
|
||||
assert_port_free "$port" "$name"
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
@@ -183,7 +318,7 @@ finish() {
|
||||
if [ "$cutover_started" -eq 0 ]; then
|
||||
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
|
||||
echo "Deployment failed before cutover; the live release was never stopped" >&2
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
|
||||
docker logs "$new_container" --tail 50 >&2 || true
|
||||
docker rm -f "$new_container" || true
|
||||
fi
|
||||
@@ -191,9 +326,9 @@ finish() {
|
||||
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
|
||||
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
|
||||
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
|
||||
if [ -n "$new_container" ]; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
|
||||
if [ -n "$new_container" ]; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
|
||||
if healthy "$old_port"; then
|
||||
echo "Rollback verified on port $old_port"
|
||||
@@ -338,15 +473,28 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The avatar/badge disk cache lives on the host bind and is written by uid 33
|
||||
# inside the container. Root-owned directories make every cache write fail
|
||||
# silently, which turns each avatar into a fresh live render.
|
||||
# The application writes everything under storage/ as uid 33, but storage is a
|
||||
# host bind so the image's own ownership is irrelevant. Any path that is not
|
||||
# uid 33 makes the write fail with EACCES, and because most of these writes are
|
||||
# inside a try/catch the failure is silent: the avatar cache just never fills
|
||||
# (each avatar becomes a fresh live render) and the catalog export reports
|
||||
# "delivery failed" while the emulator never receives the update. The old code
|
||||
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
|
||||
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
|
||||
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
|
||||
nitro-cleanup config-backups nitro-scale32-backups; do
|
||||
target="$deploy_dir/storage/$owned_dir"
|
||||
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
|
||||
[ -d "$target" ] || continue
|
||||
chown -R 33:33 "$target" 2>/dev/null || true
|
||||
done
|
||||
# The avatar/badge cache needs its leaf directories to exist before first use;
|
||||
# the cache misses (and re-renders live) rather than erroring when they do not.
|
||||
for cache_dir in avatars badges; do
|
||||
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
|
||||
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
|
||||
|
||||
if [ "$blue_green" -eq 1 ]; then
|
||||
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
|
||||
@@ -356,23 +504,28 @@ if [ "$blue_green" -eq 1 ]; then
|
||||
docker rm -f "$new_container"
|
||||
fi
|
||||
|
||||
# 2. Start de kandidaat ernaast. De live release draait ononderbroken door.
|
||||
# 2. Een compose-replica die ooit is achtergebleven zit hier nog op de
|
||||
# doel-poort. Hij draait niet live en wordt dus opgeruimd, zodat de release
|
||||
# niet op een bezette poort stukloopt.
|
||||
retire_compose_replicas "$old_port"
|
||||
|
||||
# 3. Start de kandidaat ernaast. De live release draait ononderbroken door.
|
||||
candidate_attempted=1
|
||||
start_candidate "$new_port" "$new_container"
|
||||
|
||||
# 3. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
|
||||
# 4. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
|
||||
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
|
||||
# cutover de productie breken in plaats van ervoor.
|
||||
healthy "$new_port"
|
||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||
|
||||
# 4. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||
cutover_started=1
|
||||
switch_upstream "$new_port"
|
||||
echo "Cut over to port $new_port; retiring port $old_port"
|
||||
|
||||
# 5. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
|
||||
# 6. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
|
||||
# waarop er geen enkele container draait.
|
||||
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
|
||||
docker stop "$old_container"
|
||||
|
||||
+94
-2
@@ -3,15 +3,21 @@
|
||||
#
|
||||
# Modes:
|
||||
# (default) — post-deploy cleanup (safe, fast):
|
||||
# - Build cache older than 72h, capped at 4 GB max used space.
|
||||
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
|
||||
# least-recently-used entries. This cap is the actual bound.
|
||||
# - Unreferenced images older than 7 days (keeps rollback images around).
|
||||
# - Stopped containers older than 24h.
|
||||
# - Dangling images, which are always unreferenced.
|
||||
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
|
||||
# --force — emergency mode ("never let the disk max out"): drops everything
|
||||
# with no age windows:
|
||||
# - ALL unreferenced build cache,
|
||||
# - ALL unreferenced images (no age grace),
|
||||
# - ALL stopped containers.
|
||||
#
|
||||
# The default mode escalates to --force on its own when / drops below 8 GB free,
|
||||
# so the bound holds even if this stops running on schedule.
|
||||
#
|
||||
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
||||
# Idempotent; exits 0 when Docker is unavailable.
|
||||
set -Eeuo pipefail
|
||||
@@ -34,15 +40,101 @@ command -v docker >/dev/null 2>&1 || {
|
||||
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# A hard ceiling on the root filesystem is what actually bounds the growth, so
|
||||
# the emergency path is reached on disk pressure rather than only on a timer.
|
||||
# The image/container passes stay age-gated: a rollback image and a stopped
|
||||
# container are cheap to keep for a week and expensive to lose.
|
||||
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
|
||||
# 8 GB free is comfortable for a database plus a release swap.
|
||||
if (( FREE_KB < 8 * 1024 * 1024 )); then
|
||||
FORCE=1
|
||||
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
|
||||
"$(now)" "$FREE_KB" >>"$LOG_FILE"
|
||||
fi
|
||||
|
||||
if (( FORCE )); then
|
||||
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
||||
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
||||
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
|
||||
# --max-used-space and --filter are mutually exclusive in buildx: passing
|
||||
# both makes the cap a no-op and the cache grows without bound. The cap alone
|
||||
# is the bound, and it evicts least-recently-used entries to get there.
|
||||
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
|
||||
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
||||
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
# Dangling images have no tag and no container, so nothing can reference them.
|
||||
# They are what repeated local builds leave behind.
|
||||
docker image prune -f >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# ── Interrupted git gc leftovers ─────────────────────────────────
|
||||
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
|
||||
# nothing reclaims it: git only clears those on the next successful gc. One such
|
||||
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
|
||||
# than a day are considered, so a gc running right now is never touched.
|
||||
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
|
||||
while IFS= read -r -d '' tmp; do
|
||||
size=$(du -h "$tmp" | cut -f1)
|
||||
rm -f "$tmp"
|
||||
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
|
||||
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
|
||||
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── Orphaned browser profiles ─────────────────────────────────────
|
||||
# byparr launches a real Firefox per request, and each launch leaves a
|
||||
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
|
||||
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
|
||||
# days on this host, ~1.7 GB/day.
|
||||
#
|
||||
# Deleting a profile out from under a running browser kills that job, so live
|
||||
# ones are identified the only way that is reliable rather than by age: a
|
||||
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
|
||||
# pointing into the directory. Anything not referenced that way, and untouched
|
||||
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
|
||||
#
|
||||
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
|
||||
# age window that is safe for the leak is far too wide for the disk.
|
||||
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||
for container in ${BYPARR_CONTAINERS:-byparr}; do
|
||||
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
|
||||
|
||||
removed=$(
|
||||
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
|
||||
set -u
|
||||
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||
base="${1:-/tmp}"
|
||||
live_file=$(mktemp)
|
||||
# Live profiles are the ones a running process still holds open.
|
||||
for p in $(ps -eo pid= 2>/dev/null); do
|
||||
ls -l "/proc/$p/fd" 2>/dev/null
|
||||
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
|
||||
|
||||
count=0
|
||||
for dir in "$base"/playwright_firefoxdev_profile-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
# Never touch something a process is still using.
|
||||
grep -Fxq "$dir" "$live_file" && continue
|
||||
# A profile a browser is still writing to is not an orphan
|
||||
# yet, even if the directory itself looks old.
|
||||
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
|
||||
done
|
||||
rm -f "$live_file"
|
||||
printf "%s" "$count"
|
||||
' sh /tmp 2>/dev/null || printf '0'
|
||||
)
|
||||
if [[ "${removed:-0}" -gt 0 ]]; then
|
||||
printf '[%s] removed %s orphaned browser profiles from %s\n' \
|
||||
"$(now)" "$removed" "$container" >>"$LOG_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
detectMemoryLimitMb,
|
||||
heapLimitMb,
|
||||
runtimeNodeOptions,
|
||||
} from "./docker-start.mjs";
|
||||
|
||||
it("imports runtime validation without starting the CMS", () => {
|
||||
const result = spawnSync(
|
||||
@@ -15,3 +21,89 @@ it("imports runtime validation without starting the CMS", () => {
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
describe("heap limit", () => {
|
||||
it("leaves headroom for the memory V8 does not account for", () => {
|
||||
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
|
||||
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
|
||||
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
|
||||
});
|
||||
|
||||
it("clamps to a floor and a ceiling", () => {
|
||||
// Too small to run a Next.js server at all: floor wins.
|
||||
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
|
||||
// A huge or absent limit must not turn into a 100 GB heap.
|
||||
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
|
||||
expect(heapLimitMb(Number.NaN)).toBe(8192);
|
||||
expect(heapLimitMb(0)).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cgroup detection", () => {
|
||||
const asReader = (contents) => (path) => {
|
||||
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
|
||||
return contents[path];
|
||||
};
|
||||
|
||||
it("reads the cgroup v2 limit", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
|
||||
),
|
||||
).toBe(2867);
|
||||
});
|
||||
|
||||
it("falls back to cgroup v1 when v2 is absent", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({
|
||||
"/sys/fs/cgroup/memory.max": "",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
|
||||
}),
|
||||
),
|
||||
).toBe(4300);
|
||||
});
|
||||
|
||||
it("treats an unlimited cgroup as no limit at all", () => {
|
||||
// cgroup v1 reports "max"; a bare sentinel means the same thing.
|
||||
expect(
|
||||
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
|
||||
).toBe(8192);
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
|
||||
}),
|
||||
),
|
||||
).toBe(8192);
|
||||
});
|
||||
|
||||
it("falls back when neither cgroup file is readable", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(() => {
|
||||
throw new Error("ENOENT");
|
||||
}),
|
||||
).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NODE_OPTIONS", () => {
|
||||
it("adds the cap when none is set", () => {
|
||||
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
|
||||
expect(runtimeNodeOptions(undefined, 2867)).toBe(
|
||||
"--max-old-space-size=2867",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unrelated options already present", () => {
|
||||
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
|
||||
"--no-warnings --max-old-space-size=2867",
|
||||
);
|
||||
});
|
||||
|
||||
it("never overrides an explicit operator choice", () => {
|
||||
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
|
||||
"--max-old-space-size=8192",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,70 @@
|
||||
// Fail before listening if an installation has no valid runtime configuration.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/** Fraction of the container memory limit V8 is allowed to use for its heap.
|
||||
* The rest has to cover native allocations the JS heap cannot account for:
|
||||
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
|
||||
* RSC rendering. */
|
||||
const HEAP_FRACTION = 0.7;
|
||||
const MIN_HEAP_MB = 512;
|
||||
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
|
||||
* for 4300 MB, and a backstop at or below that would silently turn the fraction
|
||||
* into a fixed number and make the two limits disagree. This exists purely so a
|
||||
* nonsensical cgroup reading cannot ask for an unbounded heap. */
|
||||
const MAX_HEAP_MB = 8192;
|
||||
|
||||
export function heapLimitMb(cgroupLimitBytes) {
|
||||
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
|
||||
return MAX_HEAP_MB;
|
||||
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
|
||||
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this container's memory ceiling from cgroup v2, falling back to v1.
|
||||
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
|
||||
* container on a 24 GB host lets the heap grow past the limit and the kernel
|
||||
* OOM-kills the process mid-request — which is what produced the
|
||||
* `next-build (v16)` kills in the host logs. A container that GCs before it
|
||||
* reaches the ceiling degrades to a slower page instead of a killed process.
|
||||
*/
|
||||
export function detectMemoryLimitMb(readFile = readFileSync) {
|
||||
const candidates = [
|
||||
"/sys/fs/cgroup/memory.max",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
];
|
||||
for (const path of candidates) {
|
||||
let raw;
|
||||
try {
|
||||
raw = readFile(path, "utf8").trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
|
||||
// sentinel of a very large number on some kernels.
|
||||
if (raw === "max" || raw === "") continue;
|
||||
const bytes = Number(raw);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) continue;
|
||||
// A host-sized "limit" means no cgroup ceiling was applied.
|
||||
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
|
||||
return heapLimitMb(bytes);
|
||||
}
|
||||
return heapLimitMb(Number.NaN);
|
||||
}
|
||||
|
||||
export function runtimeNodeOptions(
|
||||
existing = "",
|
||||
heapMb = detectMemoryLimitMb(),
|
||||
) {
|
||||
const flag = `--max-old-space-size=${heapMb}`;
|
||||
if (!existing.trim()) return flag;
|
||||
// Respect an explicit operator override; only add the cap when absent.
|
||||
if (existing.includes("--max-old-space-size")) return existing;
|
||||
return `${existing} ${flag}`;
|
||||
}
|
||||
|
||||
export function validateRuntime(settings) {
|
||||
const invalid = [];
|
||||
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
|
||||
@@ -33,7 +96,13 @@ if (
|
||||
) {
|
||||
try {
|
||||
validateRuntime(process.env);
|
||||
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
||||
const heapMb = detectMemoryLimitMb();
|
||||
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
|
||||
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
|
||||
const child = spawn(process.execPath, ["server.js"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_OPTIONS: nodeOptions },
|
||||
});
|
||||
for (const signal of ["SIGTERM", "SIGINT"])
|
||||
process.on(signal, () => child.kill(signal));
|
||||
child.on("error", () => {
|
||||
|
||||
@@ -58,10 +58,27 @@ trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
|
||||
|
||||
# An existing CI deployment is a different owner of the same host port.
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
|
||||
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
|
||||
# This host belongs to CI: the blue/green deploy owns both host ports (3002 and
|
||||
# 3003) and one of the two slot containers is always the live release. Compose
|
||||
# may only run where CI does not.
|
||||
#
|
||||
# Checking epicnext-cms-app alone was not enough. After a cutover to the green
|
||||
# slot the blue container is stopped, renamed and deleted, so the guard stopped
|
||||
# firing while the host stayed CI-managed. `docker compose up` then recreated a
|
||||
# replica named epicnext-cms on port 3002 — the blue slot, exactly where the next
|
||||
# candidate has to start — and every later release failed on a busy port until
|
||||
# someone removed that container by hand (see logs/docker-update.cron.log).
|
||||
# Therefore: both slot containers count, and so does the nginx upstream, which is
|
||||
# the only thing that still marks the host as blue/green when a slot is idle.
|
||||
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
||||
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
||||
fi
|
||||
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
||||
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
||||
fi
|
||||
done
|
||||
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
|
||||
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
|
||||
|
||||
+71
-5
@@ -1,9 +1,17 @@
|
||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
// Must stay the first import. ESM evaluates a module's imports in source
|
||||
// order, and `../src/features/operations/worker` reaches `@/env`, which parses
|
||||
// process.env at import time. With this import further down the tree, load-env
|
||||
// ran *after* the schema validation had already thrown on a missing
|
||||
// DATABASE_URL, so the worker could only ever start from an environment that
|
||||
// already exported the config — which is why `pnpm jobs:worker` died
|
||||
// immediately and nothing supervised it.
|
||||
import "./load-env";
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodePath from "node:path";
|
||||
import { Cron } from "croner";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { redis } from "../src/lib/redis";
|
||||
@@ -18,6 +26,7 @@ import {
|
||||
diskLevel,
|
||||
parseDfOutput,
|
||||
} from "../src/lib/services/disk-usage";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
||||
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
@@ -161,13 +170,69 @@ async function checkDiskUsage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the JAR to back up. `EMULATOR_JAR_PATH` may point at the file itself
|
||||
* or at a directory of release JARs, because the emulator's own unit file
|
||||
* launches `ls -t Polaris-*-jar-with-dependencies.jar` — a path pinned to one
|
||||
* release filename goes stale on the next emulator upgrade, and a stale path
|
||||
* fails as a bare ENOENT from copyFile that gives no hint what is wrong. A
|
||||
* directory (or a path with a `*`) resolves to the most recently modified JAR,
|
||||
* matching how the emulator actually picks its build.
|
||||
*/
|
||||
export function resolveEmulatorJar(
|
||||
configuredPath: string,
|
||||
fs: typeof import("node:fs") = nodeFs,
|
||||
{ resolve }: typeof import("node:path") = nodePath,
|
||||
): string | null {
|
||||
const { existsSync, readdirSync, statSync } = fs;
|
||||
if (configuredPath.includes("*")) {
|
||||
const dir = configuredPath.slice(0, configuredPath.lastIndexOf("/") + 1);
|
||||
const pattern = configuredPath.slice(dir.length);
|
||||
if (!existsSync(dir)) return null;
|
||||
return (
|
||||
readdirSync(dir)
|
||||
.filter((name: string) => name.startsWith(pattern.split("*")[0] ?? ""))
|
||||
.map((name: string) => resolve(dir, name))
|
||||
.filter((path: string) => existsSync(path))
|
||||
.sort(
|
||||
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
if (existsSync(configuredPath) && statSync(configuredPath).isFile())
|
||||
return configuredPath;
|
||||
// A directory: take the newest JAR in it.
|
||||
if (existsSync(configuredPath) && statSync(configuredPath).isDirectory()) {
|
||||
return (
|
||||
readdirSync(configuredPath)
|
||||
.filter((name: string) => name.endsWith(".jar"))
|
||||
.map((name: string) => resolve(configuredPath, name))
|
||||
.sort(
|
||||
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function backupEmulatorJar(): Promise<void> {
|
||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||
|
||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
|
||||
await import("node:fs");
|
||||
const fs = await import("node:fs");
|
||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs;
|
||||
const { resolve } = await import("node:path");
|
||||
|
||||
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
|
||||
if (!jarPath) {
|
||||
// Configured but unusable: say so once, loudly, instead of every night
|
||||
// logging an opaque copyFile ENOENT that reads like a permissions bug.
|
||||
logger.error(
|
||||
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
|
||||
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||
const backupFile = resolve(
|
||||
env.EMULATOR_BACKUP_DIR,
|
||||
@@ -179,10 +244,11 @@ async function backupEmulatorJar(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
|
||||
copyFileSync(jarPath, backupFile);
|
||||
logger.info("Backed up emulator JAR", {
|
||||
module: "jobs",
|
||||
backupFile,
|
||||
source: jarPath,
|
||||
});
|
||||
|
||||
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
||||
|
||||
@@ -101,6 +101,15 @@ fi
|
||||
if [[ ! -d /var/log/nginx ]]; then
|
||||
install -d -o root -g adm -m 750 /var/log/nginx
|
||||
fi
|
||||
# nginx-cms.conf sets `root /var/www/html` so that disk-backed locations
|
||||
# (favicon.ico) resolve somewhere the www-data worker can actually traverse.
|
||||
# The previous implicit root was /etc/nginx/html, which sits behind /etc/nginx
|
||||
# (0750 root:root): the worker got EACCES on every stat, and nginx logs a
|
||||
# failed stat at crit, so each crawler probe wrote a crit line.
|
||||
if [[ ! -d /var/www/html ]]; then
|
||||
install -d -o root -g root -m 755 /var/www/html
|
||||
echo "+ created /var/www/html (document root)"
|
||||
fi
|
||||
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||
[[ -f "$f" ]] || touch "$f"
|
||||
done
|
||||
|
||||
@@ -79,6 +79,27 @@ function filesFrom(values) {
|
||||
return values.map(normalizeAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
|
||||
* a real file has to be separated from its id. An id is rejected by
|
||||
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
|
||||
* suffix), which makes it a usable filter — but only for ids. A malformed
|
||||
* *path* must still fail loudly rather than be silently dropped, or a broken
|
||||
* manifest would quietly under-report a route's real weight.
|
||||
*/
|
||||
function assetFilesFromChunkList(values) {
|
||||
if (!Array.isArray(values))
|
||||
throw new Error("Unsupported JavaScript chunk list.");
|
||||
const files = [];
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string")
|
||||
throw new Error("Non-string JavaScript asset.");
|
||||
if (/^\d+$/.test(value)) continue;
|
||||
files.push(normalizeAsset(value));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
@@ -86,18 +107,44 @@ export function measureRoute({
|
||||
clientManifest,
|
||||
readAsset,
|
||||
}) {
|
||||
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
|
||||
// not — it only records chunks per client module — so after the build moved
|
||||
// to webpack (3d828a61) every route reported "unavailable" and the report
|
||||
// silently stopped measuring anything. Fall back to the same source Next's
|
||||
// own `static-routes-info` uses for webpack builds.
|
||||
const entries = clientManifest?.entryJSFiles;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||
const webpackModules = clientManifest?.clientModules;
|
||||
let filesBySource;
|
||||
let webpackLayout = false;
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
const sourceEntries = Object.keys(entries);
|
||||
if (
|
||||
!sourceEntries.some((key) =>
|
||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||
)
|
||||
)
|
||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||
filesBySource = Object.entries(entries);
|
||||
} else if (webpackModules && typeof webpackModules === "object") {
|
||||
webpackLayout = true;
|
||||
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
|
||||
filesBySource = [];
|
||||
for (const node of Object.values(webpackModules)) {
|
||||
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
|
||||
// One shared origin label instead of the module path: the per-chunk
|
||||
// `sources` list is written into report.json, and webpack records
|
||||
// absolute node_modules paths for every client module on the route.
|
||||
filesBySource.push(["client-module", node.chunks]);
|
||||
}
|
||||
if (filesBySource.length === 0)
|
||||
throw new Error(
|
||||
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
||||
);
|
||||
const sourceEntries = Object.keys(entries);
|
||||
if (
|
||||
!sourceEntries.some((key) =>
|
||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||
)
|
||||
)
|
||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||
}
|
||||
const bootstrap = filesFrom(
|
||||
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
||||
);
|
||||
@@ -110,8 +157,9 @@ export function measureRoute({
|
||||
origins.set(file, sources);
|
||||
};
|
||||
for (const file of bootstrap) add(file, "bootstrap");
|
||||
for (const [entry, values] of Object.entries(entries))
|
||||
for (const file of filesFrom(values)) add(file, entry);
|
||||
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom;
|
||||
for (const [entry, values] of filesBySource)
|
||||
for (const file of readChunkList(values)) add(file, entry);
|
||||
const size = (file, sources) => {
|
||||
const bytes = readAsset(file);
|
||||
return {
|
||||
@@ -248,12 +296,13 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
||||
nodeVersion: process.version,
|
||||
zlibVersion: process.versions.zlib,
|
||||
manifestFormat: "Next App Router client-reference entryJSFiles",
|
||||
manifestFormat:
|
||||
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
|
||||
definition:
|
||||
"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.",
|
||||
"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.",
|
||||
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
||||
excluded:
|
||||
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
|
||||
"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.",
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +216,135 @@ describe("route JS measurement", () => {
|
||||
);
|
||||
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
|
||||
});
|
||||
// The regression: after the build moved to webpack (3d828a61) the manifest
|
||||
// has no entryJSFiles, only clientModules[*].chunks. Every route then
|
||||
// reported "unavailable" and the report measured nothing at all while still
|
||||
// exiting zero, so the budgets silently stopped being enforced.
|
||||
describe("webpack manifests without entryJSFiles", () => {
|
||||
const webpackManifest = {
|
||||
clientModules: {
|
||||
"[project]/src/components/header.tsx": {
|
||||
chunks: [
|
||||
"4269",
|
||||
"static/chunks/4269-shared.js?dpl=abc",
|
||||
"6726",
|
||||
"static/chunks/header-entry.js?dpl=abc",
|
||||
],
|
||||
},
|
||||
"[project]/src/app/(site)/news/page.tsx": {
|
||||
chunks: [
|
||||
"4269",
|
||||
"static/chunks/4269-shared.js?dpl=abc",
|
||||
"7777",
|
||||
"/_next/static/chunks/page.js?dpl=abc",
|
||||
],
|
||||
},
|
||||
// Async-only modules are recorded with an empty chunk list.
|
||||
"[project]/src/components/lazy.tsx": { chunks: [] },
|
||||
},
|
||||
};
|
||||
const webpackFiles = {
|
||||
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
|
||||
// bootstrap assets must exist or the read fails.
|
||||
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
|
||||
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
|
||||
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
|
||||
"static/chunks/header-entry.js": Buffer.from("header"),
|
||||
"static/chunks/page.js": Buffer.from("page"),
|
||||
"static/chunks/polyfill.js": Buffer.from("legacy"),
|
||||
};
|
||||
const measure = () =>
|
||||
measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: webpackManifest,
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
});
|
||||
|
||||
it("derives the envelope from clientModules and ignores chunk ids", () => {
|
||||
const row = measure();
|
||||
expect(row.status).toBe("measured");
|
||||
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
|
||||
// are not assets and must not throw or be counted.
|
||||
expect(row.initial.chunkCount).toBe(5);
|
||||
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
|
||||
"static/chunks/4269-shared.js",
|
||||
"static/chunks/header-entry.js",
|
||||
"static/chunks/page.js",
|
||||
"static/chunks/runtime.js",
|
||||
"static/chunks/shared.js",
|
||||
]);
|
||||
// Same bytes as the Turbopack fixture would produce for these files.
|
||||
expect(row.initial.rawBytes).toBe(
|
||||
Object.values(webpackFiles)
|
||||
.filter((b) => !b.includes("legacy"))
|
||||
.reduce((n, b) => n + b.length, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a chunk reached by several client modules only once", () => {
|
||||
const shared = measure().initial.chunks.find(
|
||||
(f) => f.path === "static/chunks/4269-shared.js",
|
||||
);
|
||||
expect(shared).toBeDefined();
|
||||
expect(measure().initial.chunkCount).toBe(5);
|
||||
});
|
||||
|
||||
it("does not leak absolute module paths into the report", () => {
|
||||
const sources = new Set(
|
||||
measure().initial.chunks.flatMap((chunk) => chunk.sources),
|
||||
);
|
||||
// Only the two known origin labels; no node_modules path may appear.
|
||||
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
|
||||
});
|
||||
|
||||
it("still fails rather than under-reporting a malformed chunk path", () => {
|
||||
expect(() =>
|
||||
measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: {
|
||||
clientModules: {
|
||||
x: { chunks: ["static/chunks/../../etc/passwd"] },
|
||||
},
|
||||
},
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
}),
|
||||
).toThrow("Unsupported JavaScript asset");
|
||||
});
|
||||
|
||||
it("reports unavailable when webpack recorded no chunks at all", () => {
|
||||
expect(() =>
|
||||
measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: { clientModules: { x: { chunks: [] } } },
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
}),
|
||||
).toThrow("Neither entryJSFiles nor clientModules");
|
||||
});
|
||||
|
||||
it("prefers entryJSFiles when a manifest carries both", () => {
|
||||
// A future Next version could emit both; the explicit list wins
|
||||
// because it is per-segment and therefore the tighter envelope.
|
||||
const row = measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: {
|
||||
...webpackManifest,
|
||||
entryJSFiles: {
|
||||
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
|
||||
},
|
||||
},
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
});
|
||||
expect(row.initial.chunkCount).toBe(3);
|
||||
});
|
||||
});
|
||||
it("does not deduplicate shared files across independent cold route totals", () => {
|
||||
const row = measureRoute({
|
||||
budget,
|
||||
|
||||
+28
-3
@@ -1,10 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup cron jobs for maintenance
|
||||
#
|
||||
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
|
||||
# script that pipes one job at a time silently drops every other scheduled job.
|
||||
# Entries are matched by their command, so re-running this is idempotent.
|
||||
set -Eeuo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Adds one schedule line to the crontab unless its command is already present.
|
||||
add_job() {
|
||||
local schedule="$1" command
|
||||
command="${schedule##* }"
|
||||
local current
|
||||
current="$(crontab -l 2>/dev/null || true)"
|
||||
if grep -Fq "$command" <<<"$current"; then
|
||||
echo "Already scheduled: $command"
|
||||
return
|
||||
fi
|
||||
if [[ -z "$current" ]]; then
|
||||
printf '%s\n' "$schedule" | crontab -
|
||||
else
|
||||
printf '%s\n%s\n' "$current" "$schedule" | crontab -
|
||||
fi
|
||||
echo "Scheduled: $schedule"
|
||||
}
|
||||
|
||||
# Run backup every day at 03:00
|
||||
echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
|
||||
# Run prune every Sunday at 04:00
|
||||
echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
|
||||
add_job "0 3 * * * $SCRIPT_DIR/backup.sh"
|
||||
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly
|
||||
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly
|
||||
# run would let ~12 GB accumulate before anything reclaimed it.
|
||||
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
|
||||
|
||||
echo "Cron jobs configured."
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/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.
|
||||
#
|
||||
# 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=()
|
||||
|
||||
# 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
|
||||
+30
-34
@@ -4,11 +4,32 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
|
||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
/**
|
||||
* Store an uploaded media file under MEDIA_ROOT.
|
||||
*
|
||||
* The extension always comes from the *detected* format (magic bytes + a full
|
||||
* sharp decode), never from `file.name` or the browser-supplied MIME type:
|
||||
* trusting either lets arbitrary bytes land on disk with an attacker-chosen name
|
||||
* that the media route would then serve.
|
||||
*/
|
||||
async function storeUploadedMedia(
|
||||
file: File,
|
||||
): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
|
||||
const validated = await validateSiteImageUpload(file);
|
||||
if (!validated.success) return { ok: false, error: validated.error };
|
||||
const baseDir = MEDIA_ROOT;
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${validated.extension}`;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep))
|
||||
return { ok: false, error: "Invalid path" };
|
||||
await writeFile(filePath, validated.bytes);
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
export async function uploadMedia(
|
||||
formData: FormData,
|
||||
@@ -16,25 +37,9 @@ export async function uploadMedia(
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE)
|
||||
return { ok: false, error: "File too large (max 5MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
|
||||
};
|
||||
|
||||
const baseDir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
const stored = await storeUploadedMedia(file);
|
||||
if (!stored.ok) return { ok: false, error: stored.error };
|
||||
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
@@ -45,6 +50,8 @@ export async function deleteMedia(name: string): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
const baseDir = MEDIA_ROOT;
|
||||
// A name that is not a bare file name never reaches the unlink.
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("..")) return;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||
try {
|
||||
@@ -62,22 +69,11 @@ export async function uploadMediaAndReturn(
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return "";
|
||||
if (file.size > MAX_SIZE) return "";
|
||||
if (!ALLOWED.includes(file.type)) return "";
|
||||
|
||||
const baseDir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
const stored = await storeUploadedMedia(file);
|
||||
if (!stored.ok) return "";
|
||||
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return `/api/media/${name}`;
|
||||
return `/api/media/${stored.name}`;
|
||||
}
|
||||
@@ -14,10 +14,19 @@ import {
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import {
|
||||
isSecretSettingKey,
|
||||
SECRET_PLACEHOLDER,
|
||||
} from "@/lib/services/setting-secrets";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||
|
||||
// Raw keys only the CMS core is allowed to own. Writing an arbitrary key from
|
||||
// the generic "advanced key/value" form previously meant a staff member could
|
||||
// overwrite `turnstile_secret`, `force_staff_2fa` or `min_staff_rank`.
|
||||
const RAW_SETTING_KEY_RE = /^[a-z0-9][a-z0-9_.-]{0,127}$/;
|
||||
|
||||
function normalizeSettingValue(key: string, value: string): string {
|
||||
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
||||
return normalizeHabboGamedataHotel(value);
|
||||
@@ -71,11 +80,12 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const value = normalizeSettingValue(
|
||||
key,
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
);
|
||||
if (!key) return;
|
||||
const raw = String(formData.get("value") ?? "").normalize("NFC");
|
||||
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
|
||||
// Blank on a secret means "keep what is stored", so the UI can render a
|
||||
// placeholder without the risk of wiping the credential.
|
||||
if (isSecretSettingKey(key) && raw === SECRET_PLACEHOLDER) return;
|
||||
const value = isSecretSettingKey(key) ? raw : normalizeSettingValue(key, raw);
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value })
|
||||
@@ -90,7 +100,7 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
.slice(0, 128);
|
||||
const value = normalizeSettingValue(
|
||||
key,
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
@@ -99,7 +109,17 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
// Managed keys go through `saveManagedSettings`; anything else must be a
|
||||
// clearly namespaced custom key, and lockout/security settings are never
|
||||
// writable through the free-form form.
|
||||
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
|
||||
if (
|
||||
key === "force_staff_2fa" ||
|
||||
key === "min_staff_rank" ||
|
||||
key === "maintenance_enabled"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
|
||||
@@ -15,6 +15,9 @@ const core = vi.hoisted(() => ({
|
||||
.trim(),
|
||||
password: String(password ?? "").normalize("NFC"),
|
||||
}),
|
||||
isLoginLocked: vi.fn(async () => false),
|
||||
recordLoginFailure: vi.fn(async () => false),
|
||||
clearLoginLockout: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({ env: {} }));
|
||||
@@ -27,8 +30,14 @@ vi.mock("@/lib/services/captcha", () => ({
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { getBool: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-lockout", () => ({
|
||||
isLoginLocked: core.isLoginLocked,
|
||||
recordLoginFailure: core.recordLoginFailure,
|
||||
clearLoginLockout: core.clearLoginLockout,
|
||||
}));
|
||||
|
||||
const user = (overrides = {}) => ({
|
||||
id: 42,
|
||||
password: "hash",
|
||||
twoFactorConfirmedAt: null,
|
||||
mail: null,
|
||||
@@ -45,6 +54,9 @@ beforeEach(() => {
|
||||
core.verifyLoginPassword.mockResolvedValue({ valid: true });
|
||||
core.isEmailUnverified.mockResolvedValue(false);
|
||||
core.runDummyHashCheck.mockResolvedValue(undefined);
|
||||
core.isLoginLocked.mockResolvedValue(false);
|
||||
core.recordLoginFailure.mockResolvedValue(false);
|
||||
core.clearLoginLockout.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("precheckLogin", () => {
|
||||
@@ -85,4 +97,57 @@ describe("precheckLogin", () => {
|
||||
core.isEmailUnverified.mockResolvedValue(true);
|
||||
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
||||
});
|
||||
|
||||
it("returns locked for an account that is already locked out", async () => {
|
||||
core.getLoginUser.mockResolvedValue(user());
|
||||
core.isLoginLocked.mockResolvedValue(true);
|
||||
expect(await precheckLogin("user", "pass")).toBe("locked");
|
||||
// The password is never verified while locked, so a correct password
|
||||
// cannot walk a locked account back in.
|
||||
expect(core.verifyLoginPassword).not.toHaveBeenCalled();
|
||||
expect(core.clearLoginLockout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the lockout before verifying the password", async () => {
|
||||
core.getLoginUser.mockResolvedValue(user());
|
||||
const order: string[] = [];
|
||||
core.getLoginUser.mockImplementation(async () => {
|
||||
order.push("lookup");
|
||||
return user();
|
||||
});
|
||||
core.isLoginLocked.mockImplementation(async () => {
|
||||
order.push("lock");
|
||||
return false;
|
||||
});
|
||||
core.verifyLoginPassword.mockImplementation(async () => {
|
||||
order.push("verify");
|
||||
return { valid: true };
|
||||
});
|
||||
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||
expect(order).toEqual(["lookup", "lock", "verify"]);
|
||||
});
|
||||
|
||||
it("records a failure and skips the clear when the password is wrong", async () => {
|
||||
core.getLoginUser.mockResolvedValue(user());
|
||||
core.verifyLoginPassword.mockResolvedValue({ valid: false });
|
||||
core.recordLoginFailure.mockResolvedValue(false);
|
||||
expect(await precheckLogin("user", "pass")).toBe("invalid");
|
||||
expect(core.recordLoginFailure).toHaveBeenCalledWith(42);
|
||||
expect(core.clearLoginLockout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the lockout after a successful authentication", async () => {
|
||||
core.getLoginUser.mockResolvedValue(user());
|
||||
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||
expect(core.clearLoginLockout).toHaveBeenCalledWith(42);
|
||||
expect(core.recordLoginFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not lock or clear a bucket for an unknown account", async () => {
|
||||
core.getLoginUser.mockResolvedValue(null);
|
||||
expect(await precheckLogin("nonexistent", "pass")).toBe("invalid");
|
||||
expect(core.isLoginLocked).not.toHaveBeenCalled();
|
||||
expect(core.recordLoginFailure).not.toHaveBeenCalled();
|
||||
expect(core.clearLoginLockout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
runDummyHashCheck,
|
||||
verifyLoginPassword,
|
||||
} from "@/lib/auth/login-core";
|
||||
import {
|
||||
clearLoginLockout,
|
||||
isLoginLocked,
|
||||
recordLoginFailure,
|
||||
} from "@/lib/auth/login-lockout";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
|
||||
@@ -15,7 +20,8 @@ export type PrecheckResult =
|
||||
| "invalid"
|
||||
| "twofactor"
|
||||
| "unverified"
|
||||
| "captcha";
|
||||
| "captcha"
|
||||
| "locked";
|
||||
|
||||
/**
|
||||
* Validates username+password WITHOUT creating a session, and reports whether a
|
||||
@@ -38,6 +44,9 @@ export async function precheckLogin(
|
||||
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
|
||||
}
|
||||
|
||||
// A lockout must be checked BEFORE the password is verified: the success
|
||||
// path clears the counter, which would otherwise let an already-locked
|
||||
// account straight back in with the correct credentials.
|
||||
const user = await getLoginUser(u);
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
@@ -45,8 +54,15 @@ export async function precheckLogin(
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
if (await isLoginLocked(user.id)) return "locked";
|
||||
|
||||
const res = await verifyLoginPassword(user, p);
|
||||
if (!res.valid) return "invalid";
|
||||
if (!res.valid) {
|
||||
await recordLoginFailure(user.id);
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
await clearLoginLockout(user.id);
|
||||
|
||||
if (await isEmailUnverified(user)) {
|
||||
return "unverified";
|
||||
|
||||
@@ -41,7 +41,11 @@ const {
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { USERS_EDIT: "users.edit" },
|
||||
// Staff (rank 7) may act on anyone below the hotel's top rank.
|
||||
getHighestRank: vi.fn(() => Promise.resolve(10)),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
@@ -109,7 +113,10 @@ beforeEach(() => {
|
||||
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
selectLimit.mockResolvedValue([]);
|
||||
selectWhereResolved.mockResolvedValue([]);
|
||||
// Rank rows for the per-id rank guard: every target sits below staff rank 7.
|
||||
selectWhereResolved.mockResolvedValue([{ rank: 1 }]);
|
||||
// Max slot of existing badges (consumed by the badge loop, not the guard).
|
||||
selectWhereResolved.mockResolvedValueOnce([{ rank: 1 }]);
|
||||
});
|
||||
|
||||
describe("bulkUnban", () => {
|
||||
|
||||
+101
-32
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
Ban,
|
||||
@@ -11,18 +12,69 @@ import {
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { getHighestRank, PERMS } from "@/lib/permissions";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/**
|
||||
* Bulk actions are plain server actions whose arguments come from the client,
|
||||
* so every one of them validates the payload and the target ranks first. The
|
||||
* helpers below are the whole "is this allowed" contract.
|
||||
*/
|
||||
const MAX_BULK_USERS = 200;
|
||||
|
||||
function parseUserIds(raw: unknown): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const ids = raw
|
||||
.map((v) => (typeof v === "number" ? v : Number(v)))
|
||||
.filter((v) => Number.isInteger(v) && v > 0);
|
||||
return [...new Set(ids)].slice(0, MAX_BULK_USERS);
|
||||
}
|
||||
|
||||
function toPositiveInt(raw: unknown): number | null {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function parseAmount(raw: unknown, max = 1_000_000): number | null {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
return Number.isInteger(n) && n > 0 && n <= max ? n : null;
|
||||
}
|
||||
|
||||
function parseDuration(raw: unknown): number {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? Math.min(n, 60 * 60 * 24 * 365) : 0;
|
||||
}
|
||||
|
||||
async function guardBulkTargets(
|
||||
staff: { id: number; rank: number },
|
||||
userIds: number[],
|
||||
): Promise<void> {
|
||||
const highestRank = await getHighestRank();
|
||||
const superAdmin = isDynamicSuperAdmin(staff.rank, highestRank);
|
||||
if (superAdmin || userIds.length === 0) return;
|
||||
const rows = await db
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(inArray(User.id, userIds));
|
||||
const blocked = rows.filter((r) => r.rank >= staff.rank);
|
||||
if (blocked.length > 0) {
|
||||
throw new Error(
|
||||
"Cannot act on a user at or above your rank — those ids were skipped",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUnban({
|
||||
userIds,
|
||||
}: {
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
|
||||
const ids = parseUserIds(userIds);
|
||||
await guardBulkTargets(staff, ids);
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
|
||||
const unbanned = Number(result[0]?.affectedRows ?? 0);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -30,10 +82,7 @@ export async function bulkUnban({
|
||||
description: `Unbanned ${unbanned} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { unbanned, total: userIds.length },
|
||||
};
|
||||
return { ok: true as const, data: { unbanned, total: ids.length } };
|
||||
}
|
||||
|
||||
export async function bulkBan({
|
||||
@@ -46,10 +95,14 @@ export async function bulkBan({
|
||||
duration: number;
|
||||
}): Promise<ActionResult<{ banned: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const ids = parseUserIds(userIds);
|
||||
const seconds = parseDuration(duration);
|
||||
const reasonText = typeof reason === "string" ? reason.slice(0, 255) : "";
|
||||
await guardBulkTargets(staff, ids);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let banned = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
@@ -57,8 +110,8 @@ export async function bulkBan({
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire: duration > 0 ? now + duration : 0,
|
||||
banReason: reason,
|
||||
banExpire: seconds > 0 ? now + seconds : 0,
|
||||
banReason: reasonText,
|
||||
type: "account",
|
||||
});
|
||||
banned++;
|
||||
@@ -92,33 +145,37 @@ export async function bulkGiveCurrency({
|
||||
}>
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const ids = parseUserIds(userIds);
|
||||
const value = parseAmount(amount);
|
||||
if (!value) throw new Error("Invalid amount");
|
||||
await guardBulkTargets(staff, ids);
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.set({ credits: sql`${User.credits} + ${value}` })
|
||||
.where(eq(User.id, userId));
|
||||
await rcon.giveCredits(userId, amount);
|
||||
await rcon.giveCredits(userId, value);
|
||||
} else if (type === "pixels") {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: 0, amount })
|
||||
.values({ userId, type: 0, amount: value })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${value}` },
|
||||
});
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
await rcon.giveDuckets(userId, value);
|
||||
} else if (type === "points") {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: 101, amount })
|
||||
.values({ userId, type: 101, amount: value })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${value}` },
|
||||
});
|
||||
await rcon.givePointsGotw(userId, amount);
|
||||
await rcon.givePointsGotw(userId, value);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
@@ -129,7 +186,7 @@ export async function bulkGiveCurrency({
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_currency",
|
||||
description: `Gave ${amount} ${type} to ${given} user(s)`,
|
||||
description: `Gave ${value} ${type} to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
@@ -152,19 +209,21 @@ export async function bulkGiveBadge({
|
||||
}>
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const ids = parseUserIds(userIds);
|
||||
const code =
|
||||
typeof badgeCode === "string" ? badgeCode.trim().slice(0, 64) : "";
|
||||
if (!code) throw new Error("Invalid badge code");
|
||||
await guardBulkTargets(staff, ids);
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersBadges.userId, userId),
|
||||
eq(UsersBadges.badgeCode, badgeCode),
|
||||
),
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
@@ -173,8 +232,10 @@ export async function bulkGiveBadge({
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
await db
|
||||
.insert(UsersBadges)
|
||||
.values({ userId, slotId, badgeCode: code });
|
||||
await rcon.giveBadge(userId, code);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
@@ -211,12 +272,17 @@ export async function bulkAdjustCurrency({
|
||||
}>
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const ids = parseUserIds(userIds);
|
||||
if (!Number.isFinite(amount) || amount === 0) {
|
||||
return { ok: false as const, error: "Amount must be a non-zero number" };
|
||||
}
|
||||
if (Math.abs(Math.trunc(amount)) > 1_000_000) {
|
||||
return { ok: false as const, error: "Amount is too large" };
|
||||
}
|
||||
await guardBulkTargets(staff, ids);
|
||||
|
||||
if (amount > 0) {
|
||||
const given = await bulkGiveCurrency({ userIds, amount, type });
|
||||
const given = await bulkGiveCurrency({ userIds: ids, amount, type });
|
||||
if (!given.ok) return given;
|
||||
if (!given.data) {
|
||||
return { ok: false as const, error: "Currency adjustment failed" };
|
||||
@@ -235,7 +301,7 @@ export async function bulkAdjustCurrency({
|
||||
let adjusted = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
const [user] = await db
|
||||
@@ -299,8 +365,11 @@ export async function setTradeLock({
|
||||
untilUnix: number;
|
||||
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const until = Math.max(0, Math.trunc(untilUnix));
|
||||
const id = toPositiveInt(userId);
|
||||
if (!id) return { ok: false as const, error: "Invalid user" };
|
||||
const until = Math.max(0, Math.min(Math.trunc(untilUnix), 2_000_000_000));
|
||||
const locked = until > 0;
|
||||
await guardBulkTargets(staff, [id]);
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
@@ -309,7 +378,7 @@ export async function setTradeLock({
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.where(eq(User.id, id))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
return { ok: false as const, error: "User not found" };
|
||||
@@ -331,7 +400,7 @@ export async function setTradeLock({
|
||||
.where(eq(Sanctions.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(Sanctions).values({
|
||||
habboId: userId,
|
||||
habboId: id,
|
||||
tradeLockedUntil: until,
|
||||
reason: locked ? "Trade lock (CMS)" : "",
|
||||
});
|
||||
@@ -369,5 +438,5 @@ export async function setTradeLock({
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return { ok: true as const, data: { userId, untilUnix: until } };
|
||||
return { ok: true as const, data: { userId: id, untilUnix: until } };
|
||||
}
|
||||
@@ -52,6 +52,13 @@ const mockSetMotto = vi.hoisted(() => vi.fn());
|
||||
const mockSetRank = vi.hoisted(() => vi.fn());
|
||||
const mockExecuteCommand = vi.hoisted(() => vi.fn());
|
||||
const mockSendGift = vi.hoisted(() => vi.fn());
|
||||
// The audit service is exercised separately; here it only has to be harmless.
|
||||
// Mocked explicitly because the fake db has no `insert`, which used to leak an
|
||||
// unhandled rejection out of the fire-and-forget audit call.
|
||||
vi.mock("@/lib/services/audit", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: {
|
||||
send: mockSend,
|
||||
@@ -455,3 +462,25 @@ 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));
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,35 @@ import { db, queryRows, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
|
||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||
|
||||
/** Currency amounts are capped: unbounded values break the hotel economy. */
|
||||
const MAX_CURRENCY = 1_000_000;
|
||||
|
||||
/** Every mutation here gets an audit entry; rank changes and RCON most of all. */
|
||||
function auditAction(
|
||||
userId: number,
|
||||
action: string,
|
||||
targetId: number,
|
||||
after: Record<string, unknown>,
|
||||
): void {
|
||||
// logAudit is async, so a surrounding try/catch cannot see its rejection —
|
||||
// it would surface as an unhandled rejection and, in production, take the
|
||||
// request down over a failing audit insert. Swallow it on the promise
|
||||
// instead, which is what "auditing must never fail the command it
|
||||
// describes" actually requires.
|
||||
void Promise.resolve()
|
||||
.then(() => logAudit({ userId, action, target: "User", targetId, after }))
|
||||
.catch(() => {
|
||||
/* auditing must never fail the command it describes */
|
||||
});
|
||||
}
|
||||
|
||||
async function requireRconOk(ok: boolean): Promise<void> {
|
||||
if (!ok) throw new ActionError(RCON_FAIL);
|
||||
}
|
||||
@@ -120,9 +143,16 @@ const giveCreditsSchema = z.object({
|
||||
export const giveCredits = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.credits > MAX_CURRENCY) {
|
||||
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
|
||||
}
|
||||
await requireRconOk(
|
||||
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
|
||||
);
|
||||
auditAction(Number(ctx.session.user.id), "give_credits", ctx.data.userId, {
|
||||
userId: ctx.data.userId,
|
||||
amount: ctx.data.credits,
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
@@ -137,9 +167,16 @@ const giveAmountSchema = z.object({
|
||||
export const giveDuckets = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.amount > MAX_CURRENCY) {
|
||||
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
|
||||
}
|
||||
await requireRconOk(
|
||||
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
|
||||
);
|
||||
auditAction(Number(ctx.session.user.id), "give_duckets", ctx.data.userId, {
|
||||
userId: ctx.data.userId,
|
||||
amount: ctx.data.amount,
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
@@ -149,9 +186,16 @@ export const giveDuckets = adminAction(
|
||||
export const giveDiamonds = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.amount > MAX_CURRENCY) {
|
||||
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
|
||||
}
|
||||
await requireRconOk(
|
||||
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
|
||||
);
|
||||
auditAction(Number(ctx.session.user.id), "give_diamonds", ctx.data.userId, {
|
||||
userId: ctx.data.userId,
|
||||
amount: ctx.data.amount,
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
},
|
||||
|
||||
@@ -10,8 +10,13 @@ const state = vi.hoisted(() => ({
|
||||
deletes: [] as unknown[],
|
||||
affectedDelete: 1,
|
||||
emptyDeleteResult: false,
|
||||
isAllowed: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
// The real moderation module loads the word filter through the (mocked) db,
|
||||
// which would silently change the rows the offline-message assertions read.
|
||||
vi.mock("@/lib/services/moderation", () => ({ isAllowed: state.isAllowed }));
|
||||
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { createFakeDb } = await import("@/test/fake-db");
|
||||
@@ -315,6 +320,18 @@ describe("sendOfflineMessage", () => {
|
||||
expect(redirected()).toBe("/messages?send_error=invalid");
|
||||
});
|
||||
|
||||
it("rejects content blocked by the word filter before storing it", async () => {
|
||||
state.friendships = [{ id: 1 }];
|
||||
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
|
||||
await redirects(() =>
|
||||
sendOfflineMessage(fakeForm({ friendId: "2", message: "rude words" })),
|
||||
);
|
||||
expect(state.isAllowed).toHaveBeenCalledWith("rude words");
|
||||
expect(state.inserts).toHaveLength(0);
|
||||
expect(redirected()).toBe("/messages?send_error=invalid");
|
||||
state.isAllowed.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("stores an offline message for a friend", async () => {
|
||||
state.friendships = [{ id: 1 }];
|
||||
await redirects(() =>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
User,
|
||||
} from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
type FriendOutcome =
|
||||
| "accepted"
|
||||
@@ -376,6 +377,8 @@ export async function sendOfflineMessage(formData: FormData): Promise<void> {
|
||||
.limit(1);
|
||||
if (!recipient) {
|
||||
outcome = "invalid";
|
||||
} else if (!(await isAllowed(message)).ok) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await db.insert(MessengerOffline).values({
|
||||
userId: friendId,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// @ts-nocheck
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { selectLimit, insertOnDup, mockSendMail, mockRedirect } = vi.hoisted(
|
||||
() => ({
|
||||
const { selectLimit, selectWhere, insertOnDup, mockSendMail, mockRedirect } =
|
||||
vi.hoisted(() => ({
|
||||
selectLimit: vi.fn(),
|
||||
insertOnDup: vi.fn().mockResolvedValue({}),
|
||||
selectWhere: vi.fn(() => Promise.resolve([] as Array<{ id: number }>)),
|
||||
mockSendMail: vi.fn(),
|
||||
mockRedirect: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (...args: unknown[]) => {
|
||||
@@ -24,6 +24,17 @@ vi.mock("@/lib/db", () => {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
// Matches the deterministic `.orderBy(asc(User.id))` list
|
||||
// reads used to resolve duplicate addresses.
|
||||
orderBy: vi.fn(() => ({
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||
then(
|
||||
resolve: (v: unknown) => void,
|
||||
reject: (e: unknown) => void,
|
||||
) {
|
||||
return Promise.resolve(selectWhere()).then(resolve, reject);
|
||||
},
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
@@ -70,13 +81,14 @@ beforeEach(() => {
|
||||
describe("requestReset", () => {
|
||||
it("sends a reset email when the user exists", async () => {
|
||||
selectLimit.mockResolvedValue([{ id: 1 }]);
|
||||
selectWhere.mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
|
||||
expect(selectLimit).toHaveBeenCalled();
|
||||
expect(selectWhere).toHaveBeenCalled();
|
||||
expect(insertOnDup).toHaveBeenCalled();
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
"[email protected]",
|
||||
@@ -87,6 +99,7 @@ describe("requestReset", () => {
|
||||
|
||||
it("does not send email when user is not found", async () => {
|
||||
selectLimit.mockResolvedValue([]);
|
||||
selectWhere.mockResolvedValue([]);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import { invalidateLoginCache } from "@/lib/auth/login-core";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import { db, PasswordReset, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
@@ -17,6 +20,17 @@ function sha256(s: string): string {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to the reset form with a *code*, never with the human-readable message:
|
||||
* a raw `?error=` value would be rendered on our own domain, which is a
|
||||
* perfect phishing skeleton. The page maps each code to a translation.
|
||||
*/
|
||||
function errorRedirect(email: string, token: string, code: string): never {
|
||||
return redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${code}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestReset(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -40,12 +54,23 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
// Always respond the same way so we don't reveal which emails exist.
|
||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
try {
|
||||
const [user] = await db
|
||||
const matches = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.limit(1);
|
||||
.orderBy(asc(User.id));
|
||||
if (matches.length > 1) {
|
||||
logger.warn("Password reset address is not unique", {
|
||||
email,
|
||||
accountCount: matches.length,
|
||||
using: matches[0]?.id,
|
||||
});
|
||||
}
|
||||
const user = matches[0];
|
||||
if (user) {
|
||||
// Duplicate addresses exist on legacy databases; resetting the
|
||||
// *oldest* account keeps the choice deterministic instead of
|
||||
// "whatever row the engine returns first".
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const hashed = sha256(token);
|
||||
const createdAt = new Date();
|
||||
@@ -80,13 +105,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
|
||||
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
|
||||
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
|
||||
);
|
||||
redirect(errorRedirect(email, token, "ratelimit"));
|
||||
}
|
||||
|
||||
let error: string | null = null;
|
||||
if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
let error: "password" | "invalid" | "failed" | null = null;
|
||||
if (password.length < 12) error = "password";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
@@ -107,20 +130,29 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
row != null && a.length === b.length && timingSafeEqual(a, b);
|
||||
|
||||
if (!row || !fresh || !match) {
|
||||
error = "This reset link is invalid or has expired";
|
||||
error = "invalid";
|
||||
} else {
|
||||
const [user] = await db
|
||||
const matches = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.limit(1);
|
||||
.orderBy(asc(User.id));
|
||||
const user = matches[0];
|
||||
if (!user) {
|
||||
error = "Account not found";
|
||||
error = "invalid";
|
||||
} else {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ password: await hashPassword(password) })
|
||||
.where(eq(User.id, user.id));
|
||||
const newHash = await hashPassword(password);
|
||||
// A password change has to end every existing session: the
|
||||
// popular reason for resetting is a compromised account, and a
|
||||
// stolen cookie/API token must not outlive the reset.
|
||||
await Promise.all([
|
||||
db
|
||||
.update(User)
|
||||
.set({ password: newHash })
|
||||
.where(eq(User.id, user.id)),
|
||||
revokeUserCredentials(user.id),
|
||||
]);
|
||||
await invalidateLoginCache(email);
|
||||
await db
|
||||
.delete(PasswordReset)
|
||||
.where(eq(PasswordReset.email, email))
|
||||
@@ -132,14 +164,12 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error = "Could not reset the password — try again";
|
||||
error = "failed";
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
redirect(errorRedirect(email, token, error));
|
||||
}
|
||||
redirect("/login?reset=1");
|
||||
}
|
||||
@@ -175,8 +175,10 @@ export const setCmsPermissions = adminAction(
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-apply the same grant repair as migration 0018:
|
||||
* - ranks with admin.dashboard get all admin.*
|
||||
* Re-apply the grant repair from migration 0018/0034:
|
||||
* - ranks with admin.dashboard get all admin.*.view (read-only: the sidebar
|
||||
* needs to open, nothing more — a blanket `admin.%` grant here is what
|
||||
* promoted rank 6 to full admin)
|
||||
* - ranks >= 6 get admin.*.view + dashboard
|
||||
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
|
||||
*/
|
||||
@@ -187,7 +189,9 @@ export const repairAdminNavAclGrants = adminAction(
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`acl_roles\` ar
|
||||
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
||||
-- View slugs only: widening this to all admin.* turned "can open the
|
||||
-- panel" into "is a full admin" for every mid rank (see 0034).
|
||||
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view'
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
|
||||
@@ -184,6 +184,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Username must be at least 3 characters",
|
||||
ok: false,
|
||||
code: "usernameMinLength",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -191,6 +192,7 @@ describe("register", () => {
|
||||
it("rejects usernames containing characters outside the allowed set", async () => {
|
||||
const result = await register(PREV, buildForm({ username: "bad name!" }));
|
||||
expect(result.error).toContain("letters, numbers, underscore and hyphen");
|
||||
expect(result.code).toBe("usernamePattern");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -199,6 +201,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Enter a valid email address",
|
||||
ok: false,
|
||||
code: "emailValid",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +210,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Password must be at least 12 characters",
|
||||
ok: false,
|
||||
code: "passwordMinLength",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -220,6 +224,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("uppercase");
|
||||
expect(result.code).toBe("passwordUpper");
|
||||
});
|
||||
|
||||
it("rejects passwords without a digit", async () => {
|
||||
@@ -231,6 +236,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("digit");
|
||||
expect(result.code).toBe("passwordDigit");
|
||||
});
|
||||
|
||||
it("rejects passwords without a special character", async () => {
|
||||
@@ -242,6 +248,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("special");
|
||||
expect(result.code).toBe("passwordSpecial");
|
||||
});
|
||||
|
||||
it("rejects mismatched password confirmations", async () => {
|
||||
@@ -249,13 +256,18 @@ describe("register", () => {
|
||||
PREV,
|
||||
buildForm({ password_confirmation: "Different1" }),
|
||||
);
|
||||
expect(result).toEqual({ error: "Passwords do not match", ok: false });
|
||||
expect(result).toEqual({
|
||||
error: "Passwords do not match",
|
||||
ok: false,
|
||||
code: "passwordsMatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("throttles sign-ups per IP", async () => {
|
||||
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("Too many sign-up attempts");
|
||||
expect(result.code).toBe("rateLimited");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -273,6 +285,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Captcha verification failed. Please try again.",
|
||||
ok: false,
|
||||
code: "captchaFailed",
|
||||
});
|
||||
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
@@ -290,6 +303,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "You must accept the terms and conditions to register.",
|
||||
ok: false,
|
||||
code: "termsRequired",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -298,7 +312,11 @@ describe("register", () => {
|
||||
state.checkVpn.mockResolvedValue({ blocked: true });
|
||||
state.siteGet.mockResolvedValueOnce("Custom VPN message");
|
||||
const result = await runValidRegistration();
|
||||
expect(result).toEqual({ error: "Custom VPN message", ok: false });
|
||||
expect(result).toEqual({
|
||||
error: "Custom VPN message",
|
||||
ok: false,
|
||||
code: "vpnBlocked",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -317,6 +335,7 @@ describe("register", () => {
|
||||
state.countTotal = 2;
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("maximum number of accounts");
|
||||
expect(result.code).toBe("maxAccountsPerIp");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -340,6 +359,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "That username is already taken",
|
||||
ok: false,
|
||||
code: "usernameTaken",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -350,6 +370,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Registration is temporarily unavailable",
|
||||
ok: false,
|
||||
code: "unavailable",
|
||||
});
|
||||
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||
"Username uniqueness check failed during registration",
|
||||
@@ -365,6 +386,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "That username is already taken",
|
||||
ok: false,
|
||||
code: "usernameTaken",
|
||||
});
|
||||
expect(state.logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -373,6 +395,7 @@ describe("register", () => {
|
||||
state.insert.mockRejectedValueOnce(new Error("db exploded"));
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("Could not create the account");
|
||||
expect(result.code).toBe("createFailed");
|
||||
expect(state.logger.error).toHaveBeenCalledWith(
|
||||
"Account creation failed",
|
||||
expect.objectContaining({ message: "db exploded" }),
|
||||
|
||||
+72
-7
@@ -121,19 +121,72 @@ const registerSchema = z
|
||||
path: ["passwordConfirmation"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable, locale-independent reason for a failed sign-up. The client maps these
|
||||
* onto `pages.register.<code>` so the form speaks the visitor's language; the
|
||||
* English `error` string stays as a fallback and for API/log consumers.
|
||||
*/
|
||||
export type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Maps the schema's English messages onto locale-independent codes. */
|
||||
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"Username must be at least 3 characters": "usernameMinLength",
|
||||
"Username must be at most 25 characters": "usernameMaxLength",
|
||||
"Username may only contain letters, numbers, underscore and hyphen":
|
||||
"usernamePattern",
|
||||
"This username is reserved": "usernameReserved",
|
||||
"Enter a valid email address": "emailValid",
|
||||
"Temporary email domains are not allowed": "emailDisposable",
|
||||
"Password must be at least 12 characters": "passwordMinLength",
|
||||
"Password is too long": "passwordMaxLength",
|
||||
"Password must contain at least one uppercase letter": "passwordUpper",
|
||||
"Password must contain at least one lowercase letter": "passwordLower",
|
||||
"Password must contain at least one digit": "passwordDigit",
|
||||
"Password must contain at least one special character": "passwordSpecial",
|
||||
"Passwords do not match": "passwordsMatch",
|
||||
};
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
ok: boolean;
|
||||
/** Locale-independent reason, present on every failure. */
|
||||
code?: RegisterErrorCode;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
_prevState: RegisterState,
|
||||
formData: FormData,
|
||||
): Promise<RegisterState> {
|
||||
const fail = (error: string): RegisterState => ({ error, ok: false });
|
||||
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
|
||||
error,
|
||||
ok: false,
|
||||
code,
|
||||
});
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -155,7 +208,8 @@ export async function register(
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
@@ -166,6 +220,7 @@ export async function register(
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return fail(
|
||||
"Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
"rateLimited",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,18 +229,25 @@ export async function register(
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip)))
|
||||
return fail("Captcha verification failed. Please try again.");
|
||||
return fail(
|
||||
"Captcha verification failed. Please try again.",
|
||||
"captchaFailed",
|
||||
);
|
||||
}
|
||||
|
||||
// Terms acceptance check.
|
||||
if (!raw.termsAccepted)
|
||||
return fail("You must accept the terms and conditions to register.");
|
||||
return fail(
|
||||
"You must accept the terms and conditions to register.",
|
||||
"termsRequired",
|
||||
);
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return fail(
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed.",
|
||||
"vpnBlocked",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +262,7 @@ export async function register(
|
||||
if (Number(row?.total ?? 0) >= max)
|
||||
return fail(
|
||||
"You have reached the maximum number of accounts for your connection.",
|
||||
"maxAccountsPerIp",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,10 +273,11 @@ export async function register(
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (existing) return fail("That username is already taken");
|
||||
if (existing)
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
} catch {
|
||||
logger.warn("Username uniqueness check failed during registration");
|
||||
return fail("Registration is temporarily unavailable");
|
||||
return fail("Registration is temporarily unavailable", "unavailable");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -233,7 +297,7 @@ export async function register(
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
return fail("That username is already taken");
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
}
|
||||
logger.error("Account creation failed", {
|
||||
code,
|
||||
@@ -241,6 +305,7 @@ export async function register(
|
||||
});
|
||||
return fail(
|
||||
"Could not create the account. Please try again or contact staff.",
|
||||
"createFailed",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,14 +76,17 @@ describe("rooms actions", () => {
|
||||
});
|
||||
state.del.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
// The item/room ownership lookups must find their row.
|
||||
state.roomRows = [{ name: "Lobby" }, { id: 4 }];
|
||||
});
|
||||
|
||||
it("requires the ROOMS_EDIT permission for updateRoomItem", async () => {
|
||||
await updateRoomItem({ roomId: 9, itemId: 4, custom: "x" });
|
||||
// `custom` is not an allow-listed column, so it must never reach `.set()`.
|
||||
await updateRoomItem({ roomId: 9, itemId: 4, rot: 4, custom: "x" });
|
||||
expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit");
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
Items,
|
||||
{ custom: "x" },
|
||||
{ rot: 4 },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||
|
||||
+63
-20
@@ -9,16 +9,63 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
// Only these columns may be patched from the client. Spreading the whole payload
|
||||
// into `.set()` let a caller rewrite roomId/userId/extraData of any row, which
|
||||
// is mass assignment and IDOR in one.
|
||||
const ROOM_ITEM_FIELDS = [
|
||||
"wallPos",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"rot",
|
||||
"extraData",
|
||||
"wiredData",
|
||||
"limitedData",
|
||||
"guildId",
|
||||
] as const;
|
||||
|
||||
const ROOM_FIELDS = ["name", "description", "state", "usersMax"] as const;
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number | null {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
const { roomId, itemId, ...data } = payload as {
|
||||
roomId: number;
|
||||
itemId: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
const roomId = toPositiveInt(payload.roomId);
|
||||
const itemId = toPositiveInt(payload.itemId);
|
||||
if (!roomId || !itemId) {
|
||||
throw new Error("Invalid room or item id");
|
||||
}
|
||||
// The item must belong to the room the staff member is editing.
|
||||
const [item] = await db
|
||||
.select({ id: Items.id })
|
||||
.from(Items)
|
||||
.where(and(eq(Items.id, itemId), eq(Items.roomId, roomId)))
|
||||
.limit(1);
|
||||
if (!item) throw new Error("Item not found in this room");
|
||||
|
||||
await db
|
||||
.update(Items)
|
||||
.set(data as Partial<typeof Items.$inferInsert>)
|
||||
.set(
|
||||
pickAllowed(payload, ROOM_ITEM_FIELDS) as Partial<
|
||||
typeof Items.$inferInsert
|
||||
>,
|
||||
)
|
||||
.where(eq(Items.id, itemId));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -117,24 +164,20 @@ export async function deleteRoom({ id }: { id: number }) {
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom({
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
usersMax?: number;
|
||||
}) {
|
||||
export async function updateRoom({ id, ...data }: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
|
||||
const roomId = toPositiveInt(id);
|
||||
if (!roomId) throw new Error("Invalid room id");
|
||||
await db
|
||||
.update(Rooms)
|
||||
.set(pickAllowed(data, ROOM_FIELDS) as Partial<typeof Rooms.$inferInsert>)
|
||||
.where(eq(Rooms.id, roomId));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_update",
|
||||
description: `Updated room #${id}`,
|
||||
description: `Updated room #${roomId}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
targetId: roomId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${id}`);
|
||||
revalidatePath(`/admin/rooms/${roomId}`);
|
||||
}
|
||||
@@ -14,8 +14,13 @@ const state = vi.hoisted(() => ({
|
||||
selectQueue: [] as Queue,
|
||||
rows: [] as Array<Record<string, unknown>>,
|
||||
failInsert: false,
|
||||
isAllowed: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
// The real moderation module loads the word filter through the (mocked) db
|
||||
// select queue, which would shift the rows the forum assertions rely on.
|
||||
vi.mock("@/lib/services/moderation", () => ({ isAllowed: state.isAllowed }));
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
@@ -210,6 +215,7 @@ describe("postThread", () => {
|
||||
state.failInsert = false;
|
||||
state.selectQueue = [];
|
||||
state.rows = [];
|
||||
state.isAllowed.mockResolvedValue({ ok: true });
|
||||
state.transaction.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
|
||||
);
|
||||
@@ -283,6 +289,18 @@ describe("postThread", () => {
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects content blocked by the word filter before hitting the db", async () => {
|
||||
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
|
||||
state.selectQueue = [[{ id: 10 }]];
|
||||
await expect(postThread(threadForm())).rejects.toThrow(
|
||||
"/guilds/10/forum/new?error=invalid",
|
||||
);
|
||||
expect(state.isAllowed).toHaveBeenCalledWith(
|
||||
"Welcome thread Hello from the community",
|
||||
);
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports not_found when the guild does not exist", async () => {
|
||||
state.selectQueue = [[]];
|
||||
await expect(postThread(threadForm())).rejects.toThrow(
|
||||
@@ -324,6 +342,7 @@ describe("replyToThread", () => {
|
||||
state.failInsert = false;
|
||||
state.selectQueue = [];
|
||||
state.rows = [];
|
||||
state.isAllowed.mockResolvedValue({ ok: true });
|
||||
state.transaction.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
|
||||
);
|
||||
@@ -361,6 +380,16 @@ describe("replyToThread", () => {
|
||||
expect(state.update.mock.calls[0][1]).toMatchObject({ postsCount: 1 });
|
||||
});
|
||||
|
||||
it("rejects a reply blocked by the word filter before hitting the db", async () => {
|
||||
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
|
||||
state.selectQueue = [[{ id: 20, locked: 0, postsCount: 3 }]];
|
||||
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||
"/guilds/10/forum/20?error=invalid",
|
||||
);
|
||||
expect(state.isAllowed).toHaveBeenCalledWith("A thoughtful reply");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing or non-positive ids by redirecting to /guilds", async () => {
|
||||
await expect(replyToThread(replyForm({ guildId: "abc" }))).rejects.toThrow(
|
||||
"/guilds",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MessengerFriendships,
|
||||
} from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
|
||||
// guilds_forums_comments.message which is TEXT. Keep the first post's message
|
||||
@@ -235,6 +236,8 @@ export async function postThread(formData: FormData): Promise<void> {
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) {
|
||||
outcome = "invalid";
|
||||
} else if (!(await isAllowed(`${subject} ${message}`)).ok) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -326,6 +329,8 @@ export async function replyToThread(formData: FormData): Promise<void> {
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) {
|
||||
outcome = "invalid";
|
||||
} else if (!(await isAllowed(message)).ok) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
||||
@@ -78,6 +78,22 @@ async function verifyTwoFactorCode(
|
||||
export async function beginTwoFactor(): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
|
||||
if (!(await rateLimit(`2fa-begin:${id}`, 5, 30_000)).ok)
|
||||
redirect("/settings/2fa?error=ratelimit");
|
||||
|
||||
// Re-running this action while 2FA is confirmed would be a silent *downgrade*
|
||||
// (the new secret is stored unconfirmed, and unconfirmed means "login gate
|
||||
// off"), so the existing setup has to be disabled through the proper flow
|
||||
// first: a valid code, not just an authenticated session.
|
||||
const [current] = await db
|
||||
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
||||
.from(User)
|
||||
.where(eq(User.id, id))
|
||||
.limit(1);
|
||||
if (current?.twoFactorConfirmedAt)
|
||||
redirect("/settings/2fa?error=alreadyenabled");
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
|
||||
const codes = generateRecoveryCodes();
|
||||
@@ -104,12 +120,19 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
const { ok, updatedRecoveryCodes } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
// A recovery code spends itself on use, so persist the remainder together
|
||||
// with the confirmation instead of dropping the caller's own update.
|
||||
await db
|
||||
.update(User)
|
||||
.set({ twoFactorConfirmedAt: new Date() })
|
||||
.set({
|
||||
twoFactorConfirmedAt: new Date(),
|
||||
...(updatedRecoveryCodes !== undefined
|
||||
? { twoFactorRecoveryCodes: updatedRecoveryCodes }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(User.id, id));
|
||||
redirect("/settings/2fa?enabled=1");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
|
||||
updateCall: undefined as unknown,
|
||||
rconSetMotto: vi.fn(),
|
||||
failDbUpdate: false,
|
||||
isAllowed: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
const databaseErrorClass = vi.hoisted(
|
||||
@@ -63,6 +64,10 @@ vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { setMotto: state.rconSetMotto },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/moderation", () => ({
|
||||
isAllowed: state.isAllowed,
|
||||
}));
|
||||
|
||||
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
|
||||
|
||||
@@ -98,6 +103,7 @@ beforeEach(() => {
|
||||
state.updateCall = undefined;
|
||||
state.rconSetMotto.mockResolvedValue(true);
|
||||
state.failDbUpdate = false;
|
||||
state.isAllowed.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("updateMotto", () => {
|
||||
@@ -141,6 +147,18 @@ describe("updateMotto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMotto word filter", () => {
|
||||
it("rejects a motto blocked by the word filter before persisting", async () => {
|
||||
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
|
||||
await expect(updateMotto(mockingForm("bad motto"))).rejects.toThrow(
|
||||
databaseErrorClass,
|
||||
);
|
||||
expect(state.isAllowed).toHaveBeenCalledWith("bad motto");
|
||||
expect(state.updateCall).toBeUndefined();
|
||||
expect(state.rconSetMotto).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMottoAction", () => {
|
||||
it("denies unauthenticated callers", async () => {
|
||||
state.auth.mockResolvedValue(null);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { actionOk, authAction } from "@/lib/foundation/action";
|
||||
import { DatabaseError } from "@/lib/foundation/errors";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const MOTTO_MAX = 127;
|
||||
@@ -16,7 +17,14 @@ const mottoSchema = z.object({
|
||||
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
|
||||
});
|
||||
|
||||
async function assertMottoAllowed(motto: string): Promise<void> {
|
||||
if (!(await isAllowed(motto)).ok) {
|
||||
throw new DatabaseError("Motto not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
|
||||
await assertMottoAllowed(ctx.data.motto);
|
||||
try {
|
||||
await db
|
||||
.update(User)
|
||||
|
||||
@@ -62,6 +62,9 @@ vi.mock("@/lib/permissions", () => ({
|
||||
USERS_BAN: "users.ban",
|
||||
USERS_RESET_PASSWORD: "users.reset_password",
|
||||
},
|
||||
// Staff in the fixtures is rank 7 and the hotel's top rank is 10, so rank
|
||||
// guards act as "below-your-own-rank only".
|
||||
getHighestRank: vi.fn(() => Promise.resolve(10)),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
@@ -77,6 +80,10 @@ vi.mock("@/lib/services/audit", () => ({
|
||||
logAudit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session-revocation", () => ({
|
||||
revokeUserCredentials: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/webhook", () => ({
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
+36
-5
@@ -3,8 +3,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { getHighestRank, PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
@@ -54,8 +56,9 @@ export const createUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||
async (ctx) => {
|
||||
const { username, mail, password, rank, motto } = ctx.data;
|
||||
|
||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
const actorRank = ctx.session.user.rank;
|
||||
const highestRank = await getHighestRank();
|
||||
if (rank >= actorRank && !isDynamicSuperAdmin(actorRank, highestRank)) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
@@ -130,7 +133,7 @@ export const updateUser = adminAction(
|
||||
if (
|
||||
userData.rank !== undefined &&
|
||||
userData.rank >= ctx.session.user.rank &&
|
||||
ctx.session.user.rank < 7
|
||||
!isDynamicSuperAdmin(ctx.session.user.rank, await getHighestRank())
|
||||
) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
@@ -144,7 +147,15 @@ export const updateUser = adminAction(
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
mailVerified?: string;
|
||||
}>;
|
||||
// A changed address has to prove itself again: leaving mail_verified
|
||||
// set would keep every mail send (resets, notifications) pointed at an
|
||||
// inbox nobody confirmed, and would silently bypass the "verified
|
||||
// accounts only" gate.
|
||||
if (patch.mail !== undefined && patch.mail !== targetUser.mail) {
|
||||
patch.mailVerified = "0";
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await db.update(User).set(patch).where(eq(User.id, id));
|
||||
}
|
||||
@@ -331,7 +342,14 @@ async function guardRank(targetUserId: number, sessionRank: number) {
|
||||
.where(eq(User.id, targetUserId))
|
||||
.limit(1);
|
||||
if (!target) throw new ActionError("User not found");
|
||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||
// The owner is whoever holds the hotel's highest rank *today*. The old
|
||||
// `sessionRank < 7` shortcut handed every rank-7 account owner powers on
|
||||
// any hotel whose top rank is 8+, which makes it a plain escalation.
|
||||
const highestRank = await getHighestRank();
|
||||
if (
|
||||
target.rank >= sessionRank &&
|
||||
!isDynamicSuperAdmin(sessionRank, highestRank)
|
||||
) {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
}
|
||||
return target;
|
||||
@@ -358,6 +376,9 @@ export const resetPassword = adminAction(
|
||||
.update(User)
|
||||
.set({ password: hashed })
|
||||
.where(eq(User.id, ctx.data.userId));
|
||||
// A staff-issued password must also end the user's live sessions: this
|
||||
// action exists precisely for "account compromised" situations.
|
||||
await revokeUserCredentials(ctx.data.userId);
|
||||
invalidateLoginCache(target.username);
|
||||
|
||||
logAudit({
|
||||
@@ -419,9 +440,19 @@ const alertUserSchema = z.object({
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to send alert. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_alert",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { message: ctx.data.message, username: target.username },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export default async function ApplyStaffPage({
|
||||
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{submitted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.submitted")}
|
||||
@@ -233,6 +233,6 @@ export default async function ApplyStaffPage({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export default async function ApplyTeamPage({
|
||||
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{submitted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.submitted")}
|
||||
@@ -199,6 +199,6 @@ export default async function ApplyTeamPage({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default async function BadgesPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🏅" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={badges.length === 0}>
|
||||
@@ -76,6 +76,6 @@ export default async function BadgesPage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default async function BannedPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||
<section style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||
<ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}>
|
||||
<p style={{ marginTop: 0 }}>{t("body")}</p>
|
||||
{reason ? (
|
||||
@@ -65,6 +65,6 @@ export default async function BannedPage() {
|
||||
{t("contactStaff")}
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function CommunityPage() {
|
||||
const t = useTranslations("pages.community");
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
@@ -84,6 +84,6 @@ export default function CommunityPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -398,7 +398,7 @@ export default function DevelopersPage() {
|
||||
const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard
|
||||
icon="🧩"
|
||||
title="Developer API"
|
||||
@@ -479,6 +479,6 @@ export default function DevelopersPage() {
|
||||
</div>
|
||||
</ContentCard>
|
||||
))}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default async function DrawBadgePage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard
|
||||
icon="🎨"
|
||||
title="Draw a Badge"
|
||||
@@ -231,6 +231,6 @@ export default async function DrawBadgePage({
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export function EventRegisterButton({
|
||||
run(() => registerForEvent({ eventId }), {
|
||||
successMessage: t("registerSuccess"),
|
||||
errorMessage: t("registerError"),
|
||||
// registerForEvent revalidates /events and /events/<id>.
|
||||
revalidated: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -142,7 +142,7 @@ export default async function EventDetailPage({
|
||||
else if (isFull) disabledReason = t("eventFull");
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/events">{t("back")}</Link>
|
||||
</p>
|
||||
@@ -259,6 +259,6 @@ export default async function EventDetailPage({
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ async function EventsPage({
|
||||
const href = (page: number) =>
|
||||
`/events?${new URLSearchParams({ status: result?.status ?? "all", week: result?.week ?? "", mine: params.mine ?? "", page: String(page) })}`;
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard>
|
||||
@@ -272,7 +272,7 @@ async function EventsPage({
|
||||
</nav>
|
||||
</ContentCard>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requestReset } from "@/actions/password-reset";
|
||||
@@ -6,6 +7,15 @@ import Link from "@/components/link";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.forgot");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ForgotPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -17,12 +27,39 @@ export default async function ForgotPage({
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<section style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}>
|
||||
{sent ? (
|
||||
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
|
||||
{t("sentNotice")}
|
||||
</p>
|
||||
<>
|
||||
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
|
||||
{t("sentNotice")}
|
||||
</p>
|
||||
{/* A mail that never arrived must be retryable from here,
|
||||
otherwise the visitor is stuck on a dead end. */}
|
||||
<form
|
||||
action={requestReset}
|
||||
style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}
|
||||
>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<CaptchaWidget
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("sendAnotherLink")}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
action={requestReset}
|
||||
@@ -66,6 +103,6 @@ export default async function ForgotPage({
|
||||
<Link href="/login">{t("backToLogin")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default async function FriendsPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{removed === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.removed")}
|
||||
@@ -180,6 +180,6 @@ export default async function FriendsPage({
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -103,11 +103,11 @@ export default async function GuildForumThreadPage({
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.thread")(error);
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard>
|
||||
<PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} />
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export default async function GuildForumThreadPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{replied === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.replied")}
|
||||
@@ -317,6 +317,6 @@ export default async function GuildForumThreadPage({
|
||||
{t("loginToReply")} <Link href="/login">{t("loginLink")}</Link>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export default async function NewThreadPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
@@ -132,6 +132,6 @@ export default async function NewThreadPage({
|
||||
</div>
|
||||
</form>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -67,11 +67,11 @@ export default async function GuildForumPage({
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.forum")(error);
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard>
|
||||
<PublicLoadError href={`/guilds/${guildId}/forum`} />
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export default async function GuildForumPage({
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{posted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
@@ -240,6 +240,6 @@ export default async function GuildForumPage({
|
||||
</table>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -55,11 +55,11 @@ export default async function GuildPage({
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.detail")(error);
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard>
|
||||
<PublicLoadError href={`/guilds/${guildId}`} />
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export default async function GuildPage({
|
||||
const created = formatDate(new Date(guild.dateCreated * 1000), "date");
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href="/guilds">{t("allGuilds")}</Link>
|
||||
</p>
|
||||
@@ -251,6 +251,6 @@ export default async function GuildPage({
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export default async function GuildsPage() {
|
||||
const guilds = await getGuilds();
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={!guilds?.length}>
|
||||
@@ -98,6 +98,6 @@ export default async function GuildsPage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export default async function HelpCategoryPage({
|
||||
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href="/help">{t("back")}</Link>
|
||||
</p>
|
||||
@@ -162,6 +162,6 @@ export default async function HelpCategoryPage({
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default async function HelpCenterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard
|
||||
icon="❓"
|
||||
title={t("title")}
|
||||
@@ -278,6 +278,6 @@ export default async function HelpCenterPage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export default async function HelpTicketDetailPage({
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{replied === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.replied")}
|
||||
@@ -302,6 +302,6 @@ export default async function HelpTicketDetailPage({
|
||||
{t("closedHint")}
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export default async function HelpTicketsPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{created === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.created")}
|
||||
@@ -167,6 +167,6 @@ export default async function HelpTicketsPage({
|
||||
</table>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+28
-16
@@ -1,4 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { CloudsField } from "@/components/clouds-field";
|
||||
import MotionPageWrapper from "@/components/motion-page-wrapper";
|
||||
@@ -7,6 +9,7 @@ import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { TopHeader } from "@/components/top-header";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { publicClientMessages } from "@/lib/i18n-client-messages";
|
||||
|
||||
const RadioPlayerGate = dynamic(
|
||||
() => import("@/components/public/radio-player-gate"),
|
||||
@@ -20,30 +23,39 @@ const RadioPlayerGate = dynamic(
|
||||
/**
|
||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||
*
|
||||
* The message provider lives here rather than only in the root layout: nested
|
||||
* NextIntlClientProviders replace the parent set instead of merging, so this is
|
||||
* where the public catalogue is installed — without the ~177 KB of staff-tool
|
||||
* namespaces that no page in this group can reach.
|
||||
*/
|
||||
export default async function SiteLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const [session, locale, messages] = await Promise.all([
|
||||
auth(),
|
||||
getLocale(),
|
||||
getMessages(),
|
||||
]);
|
||||
const clientMessages = publicClientMessages(messages);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
||||
<CloudsField />
|
||||
{session?.user?.id ? (
|
||||
<>
|
||||
<div data-theme-block="top_header">
|
||||
<TopHeader session={session} />
|
||||
</div>
|
||||
<div data-theme-block="site_header">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
<div data-theme-block="navigation">
|
||||
<Navigation session={session} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{/* Site chrome is public: hiding it all for anonymous visitors used to
|
||||
strand them — from /news, /leaderboard or /shop there was no way to
|
||||
reach any other page at all. */}
|
||||
<div data-theme-block="top_header">
|
||||
<TopHeader session={session} />
|
||||
</div>
|
||||
<div data-theme-block="site_header">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
<div data-theme-block="navigation">
|
||||
<Navigation session={session} />
|
||||
</div>
|
||||
<main>
|
||||
<div
|
||||
data-theme-block="content_grid"
|
||||
@@ -62,6 +74,6 @@ export default async function SiteLayout({
|
||||
<div data-theme-block="radio_player">
|
||||
<RadioPlayerGate />
|
||||
</div>
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.leaderboard");
|
||||
@@ -52,7 +53,17 @@ function formatValue(key: TabKey, value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
type Row = { username: string; look: string; value: number };
|
||||
type Row = { userId: number; username: string; look: string; value: number };
|
||||
|
||||
/**
|
||||
* Users who hid their wallet must not appear in the currency tabs: the profile
|
||||
* page already honours that, and a leaderboard that ignores it makes the
|
||||
* setting meaningless.
|
||||
*/
|
||||
async function withoutHiddenWallets(rows: Row[]): Promise<Row[]> {
|
||||
const privacy = await loadProfilePrivacyMap(rows.map((r) => r.userId));
|
||||
return rows.filter((r) => privacy.get(r.userId)?.wallet !== false);
|
||||
}
|
||||
|
||||
async function loadCreditsRows(): Promise<Row[]> {
|
||||
try {
|
||||
@@ -62,6 +73,7 @@ async function loadCreditsRows(): Promise<Row[]> {
|
||||
() =>
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
credits: User.credits,
|
||||
@@ -71,11 +83,14 @@ async function loadCreditsRows(): Promise<Row[]> {
|
||||
.limit(20),
|
||||
{ staleMs: 120000 },
|
||||
);
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
look: u.look,
|
||||
value: u.credits,
|
||||
}));
|
||||
return await withoutHiddenWallets(
|
||||
users.map((u) => ({
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
look: u.look,
|
||||
value: u.credits,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -89,6 +104,7 @@ async function loadCurrencyRows(type: number): Promise<Row[]> {
|
||||
() =>
|
||||
db
|
||||
.select({
|
||||
userId: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
value: UsersCurrency.amount,
|
||||
@@ -99,7 +115,7 @@ async function loadCurrencyRows(type: number): Promise<Row[]> {
|
||||
.orderBy(desc(UsersCurrency.amount))
|
||||
.limit(20),
|
||||
{ staleMs: 120000 },
|
||||
);
|
||||
).then(withoutHiddenWallets);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -116,6 +132,7 @@ async function loadSettingsRows(
|
||||
() =>
|
||||
db
|
||||
.select({
|
||||
userId: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
value: column,
|
||||
@@ -166,7 +183,7 @@ export default async function LeaderboardPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard
|
||||
icon="📊"
|
||||
title={t("title")}
|
||||
@@ -245,6 +262,6 @@ export default async function LeaderboardPage({
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+196
-198
@@ -1,20 +1,47 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
|
||||
import { cached } from "@/lib/cache";
|
||||
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
||||
|
||||
export default async function LoginPage() {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.login");
|
||||
const title = t("title");
|
||||
const description = t("subtitle");
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
// Sign-in is a dead end for crawlers and duplicates the homepage copy.
|
||||
robots: { index: false, follow: false },
|
||||
openGraph: { title, description, type: "website" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
from?: string;
|
||||
registered?: string;
|
||||
reset?: string;
|
||||
}>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.login");
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
@@ -23,202 +50,173 @@ export default async function LoginPage() {
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
const sp = await searchParams;
|
||||
const redirectTo = safeRedirectPath(sp.from);
|
||||
|
||||
// Already signed in: the form has nothing to do here. Honour `from` first so
|
||||
// an admin bounced off /admin lands back where they were heading.
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect(redirectTo);
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cached("online_count", 10_000, () =>
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
cachedOnlineCount().catch(() => 0),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8),
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
// `/login?registered=1` is where the sign-up form lands when it could not
|
||||
// auto sign-in (e-mail verification still pending). Without this notice the
|
||||
// visitor would only see an empty login form and no sign their account
|
||||
// exists.
|
||||
const notice =
|
||||
sp.registered === "1" ? (
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
||||
>
|
||||
{t("registeredSuccess")}
|
||||
</p>
|
||||
) : sp.reset === "1" ? (
|
||||
// `?reset=1` comes from a successful password reset; saying so matters
|
||||
// because the visitor just changed their password and a silent form
|
||||
// reads like the reset failed.
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
||||
>
|
||||
{t("passwordChanged")}
|
||||
</p>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||
|
||||
<Reveal>
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
||||
{/* Left panel */}
|
||||
<div className="lg:w-96 shrink-0 space-y-4">
|
||||
<SurfaceCard
|
||||
className="card-glow relative overflow-hidden p-6 text-center"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt="Frank"
|
||||
width={130}
|
||||
height={170}
|
||||
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
color:
|
||||
"var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{t("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
className="text-xl font-black"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
{t("welcomeBack")}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("welcomeBackSub", { hotelName })}
|
||||
</p>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
{recentUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={t("whoIsOnline")}
|
||||
icon={ICON_NAV_GOODY}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{latestUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={t("newestCitizens")}
|
||||
icon={ICON_FRIENDS}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{latestUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="flex-1 lg:sticky lg:top-6">
|
||||
<SurfaceCard
|
||||
title={t("title")}
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-6 sm:p-7"
|
||||
>
|
||||
<p
|
||||
className="text-sm mb-6"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<LoginForm
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
notice={notice}
|
||||
form={
|
||||
<LoginForm
|
||||
redirectTo={redirectTo}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SurfaceCard
|
||||
className="card-glow relative overflow-hidden p-6 text-center"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||
<div
|
||||
className="aurora-blob -left-16 top-10 h-56 w-56"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-16 bottom-0 h-56 w-56"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
|
||||
animationDelay: "-8s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt="Frank"
|
||||
width={130}
|
||||
height={170}
|
||||
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
boxShadow:
|
||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex rounded-full h-2 w-2"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
</span>
|
||||
{t("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
className="text-xl font-black"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
{t("welcomeBack")}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("welcomeBackSub", { hotelName })}
|
||||
</p>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<AuthUsersCards
|
||||
recentUsers={recentUsers}
|
||||
latestUsers={latestUsers}
|
||||
recentTitle={t("whoIsOnline")}
|
||||
latestTitle={t("newestCitizens")}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default async function LogoPage() {
|
||||
),
|
||||
);
|
||||
return (
|
||||
<main
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "1.5rem",
|
||||
@@ -45,6 +45,6 @@ export default async function LogoPage() {
|
||||
/>
|
||||
|
||||
<LogoGenerator initialText={initialText} canSaveToSite={canSaveToSite} />
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default async function MaintenancePage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||
<section style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon="🛠️"
|
||||
title={t("title", { hotel })}
|
||||
@@ -25,6 +25,6 @@ export default async function MaintenancePage() {
|
||||
{t("staffCanLogIn")}
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export default async function MarketplacePage() {
|
||||
const total = offers.reduce((sum, o) => sum + o.price, 0);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🛍️" title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="stat-grid">
|
||||
<StatBlock
|
||||
@@ -148,6 +148,6 @@ export default async function MarketplacePage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -47,14 +47,14 @@ async function MePage({
|
||||
data = await loadUserDashboard(userId);
|
||||
} catch {
|
||||
return (
|
||||
<main>
|
||||
<section>
|
||||
<SurfaceCard className="p-6">
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<Link href="/me" className="btn btn-outline">
|
||||
{t("retry")}
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
// Daily reward state (settings + schedule + the user's last claim). Never
|
||||
@@ -63,14 +63,14 @@ async function MePage({
|
||||
const user = data.userRows[0];
|
||||
if (!user)
|
||||
return (
|
||||
<main>
|
||||
<section>
|
||||
<SurfaceCard className="p-6">
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<Link href="/login" className="btn btn-outline">
|
||||
{t("login")}
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
const {
|
||||
hotelName,
|
||||
@@ -145,7 +145,7 @@ async function MePage({
|
||||
? error
|
||||
: "error";
|
||||
return (
|
||||
<main className={styles.dashboard}>
|
||||
<section className={styles.dashboard}>
|
||||
{claimed && (
|
||||
<p role="status" className={styles.feedback}>
|
||||
{t("claimed")}
|
||||
@@ -181,6 +181,8 @@ async function MePage({
|
||||
width={100}
|
||||
height={140}
|
||||
className={styles.avatar}
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
<div className={styles.identity}>
|
||||
<p className="muted">{t("welcome", { hotel: hotelName })}</p>
|
||||
@@ -469,7 +471,7 @@ async function MePage({
|
||||
</SurfaceCard>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ export default async function MessagesPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{accepted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.accepted")}
|
||||
@@ -391,6 +391,6 @@ export default async function MessagesPage({
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ async function ArticlePage({
|
||||
} catch {
|
||||
logger.error("Public article lookup failed", { module: "news" });
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="📰" title={t("loadError")}>
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<a
|
||||
@@ -104,7 +104,7 @@ async function ArticlePage({
|
||||
{t("retry")}
|
||||
</a>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!article) notFound();
|
||||
@@ -159,7 +159,7 @@ async function ArticlePage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{comment === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
@@ -196,7 +196,16 @@ async function ArticlePage({
|
||||
src={article.image}
|
||||
alt=""
|
||||
decoding="async"
|
||||
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
||||
loading="eager"
|
||||
// Reserve the box: an unbounded hero image shifts the whole
|
||||
// article down once the bitmap decodes.
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16 / 9",
|
||||
objectFit: "cover",
|
||||
borderRadius: 10,
|
||||
margin: "0 0 1rem",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
|
||||
@@ -414,7 +423,7 @@ async function ArticlePage({
|
||||
)}
|
||||
</ContentCard>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ async function NewsPage({
|
||||
`/news?${new URLSearchParams({ q: result?.search ?? params.q ?? "", order: result?.order ?? "newest", page: String(page) })}`;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="📰" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard>
|
||||
@@ -186,7 +186,7 @@ async function NewsPage({
|
||||
</nav>
|
||||
</ContentCard>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+237
-158
@@ -1,4 +1,5 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
@@ -6,7 +7,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { AnimatedCounter } from "@/components/animated-counter";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { Clock } from "@/components/clock";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
@@ -14,6 +15,7 @@ import { LiveOnlineCounter } from "@/components/live-online-counter";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SpotlightCard } from "@/components/spotlight-card";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { TypewriterText } from "@/components/typewriter-text";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -23,7 +25,10 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
|
||||
import {
|
||||
cachedOnlineCount,
|
||||
countArticles,
|
||||
countPhotos,
|
||||
countRooms,
|
||||
countUsers,
|
||||
@@ -40,17 +45,17 @@ import {
|
||||
ICON_NAV_ME,
|
||||
} from "@/lib/site-icons";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
openGraph: {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const tn = await getTranslations("nav");
|
||||
const tp = await getTranslations("pages.home");
|
||||
const title = tn("home");
|
||||
const description = tp("welcomeBody");
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description, type: "website" },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Local visual language (theme-aware, no big white blocks) ──────────
|
||||
const PANEL_BG =
|
||||
@@ -175,17 +180,12 @@ async function getHotelData() {
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers,
|
||||
recentPhotos,
|
||||
] = await Promise.all([
|
||||
cached("online_count", 10_000, () =>
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(publicReadFailure("home.online")),
|
||||
cachedOnlineCount().catch(publicReadFailure("home.online")),
|
||||
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
|
||||
publicReadFailure("home.users"),
|
||||
),
|
||||
@@ -195,6 +195,9 @@ async function getHotelData() {
|
||||
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
|
||||
publicReadFailure("home.photos-count"),
|
||||
),
|
||||
cached("total_articles", 300_000, countArticles, {
|
||||
staleMs: 300000,
|
||||
}).catch(publicReadFailure("home.articles-count")),
|
||||
getNewsList(4, { throwOnError: true }).catch(
|
||||
publicReadFailure("home.news"),
|
||||
),
|
||||
@@ -203,7 +206,7 @@ async function getHotelData() {
|
||||
15_000,
|
||||
() =>
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(12),
|
||||
@@ -228,6 +231,7 @@ async function getHotelData() {
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers,
|
||||
recentPhotos,
|
||||
@@ -249,12 +253,24 @@ export default async function Home() {
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers,
|
||||
recentUsers: rawRecentUsers,
|
||||
recentPhotos,
|
||||
logo,
|
||||
} = await getHotelData();
|
||||
|
||||
// Users who hide their online state must not surface in the "who is online"
|
||||
// rails on the homepage either.
|
||||
const recentUserPrivacy = await loadProfilePrivacyMap(
|
||||
(rawRecentUsers ?? []).map((u) => u.id),
|
||||
);
|
||||
const recentUsers =
|
||||
rawRecentUsers === null
|
||||
? null
|
||||
: rawRecentUsers.filter(
|
||||
(u) => recentUserPrivacy.get(u.id)?.online !== false,
|
||||
);
|
||||
const captcha = await captchaConfig();
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
@@ -310,7 +326,7 @@ export default async function Home() {
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
||||
>
|
||||
→
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -342,12 +358,6 @@ export default async function Home() {
|
||||
>
|
||||
{a.title}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
|
||||
style={{ color: "#cbd5e1" }}
|
||||
>
|
||||
{formatDate(a.createdAt, "date", "")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -371,7 +381,7 @@ export default async function Home() {
|
||||
color: "var(--color-primary)",
|
||||
},
|
||||
{
|
||||
value: articles?.length ?? null,
|
||||
value: articleCount,
|
||||
label: tp("statsArticles"),
|
||||
color: "var(--color-accent)",
|
||||
},
|
||||
@@ -484,6 +494,29 @@ export default async function Home() {
|
||||
"linear-gradient(180deg, rgba(8,11,24,0.74) 0%, rgba(8,11,24,0.55) 38%, rgba(8,11,24,0.38) 62%, rgba(8,11,24,0.55) 85%, color-mix(in srgb, var(--color-background) 94%, transparent) 100%)",
|
||||
}}
|
||||
/>
|
||||
{/* Drifting brand-tinted aurora behind the copy */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
|
||||
animationDelay: "-7s",
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
|
||||
@@ -553,7 +586,16 @@ export default async function Home() {
|
||||
</p>
|
||||
|
||||
{recentUsers !== null && recentUsers.length > 0 && (
|
||||
<div className="mt-7 flex items-center justify-center gap-3">
|
||||
<div
|
||||
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
borderColor: "rgba(255,255,255,0.22)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
animationDelay: "0.08s",
|
||||
}}
|
||||
>
|
||||
<div className="flex -space-x-2.5">
|
||||
{recentUsers.slice(0, 4).map((u) => (
|
||||
<UserAvatarThumbnail
|
||||
@@ -566,25 +608,21 @@ export default async function Home() {
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-extrabold uppercase tracking-[0.22em]"
|
||||
style={{ color: "rgba(255,255,255,0.72)" }}
|
||||
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
|
||||
style={{ color: "rgba(255,255,255,0.75)" }}
|
||||
>
|
||||
{tp("recentUsers")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
|
||||
<div
|
||||
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn-shine inline-flex items-center gap-2.5 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wider transition-all duration-300 ease-out hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 55%, transparent), 0 0 0 1px color-mix(in srgb, var(--color-primary) 55%, transparent)",
|
||||
}}
|
||||
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/EnterHubbly.png"
|
||||
@@ -597,21 +635,23 @@ export default async function Home() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 ease-out hover:-translate-y-1 hover:brightness-110 active:scale-95"
|
||||
style={{
|
||||
color: "#fff",
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
className="btn-glass-dark px-8 py-4 text-sm font-bold"
|
||||
>
|
||||
{th("login")} →
|
||||
{th("login")}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Floating stat chips */}
|
||||
<div className="mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3">
|
||||
<div
|
||||
className="animate-fade-in-up mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
||||
style={{ animationDelay: "0.32s" }}
|
||||
>
|
||||
{statChips.map((s) => (
|
||||
<span key={s.label} className="glass-chip">
|
||||
<span
|
||||
@@ -633,6 +673,28 @@ export default async function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll cue */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div
|
||||
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.09)",
|
||||
border: "1px solid rgba(255,255,255,0.3)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgba(255,255,255,0.88)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating mascot — decorative, hidden on smaller screens. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -645,7 +707,6 @@ export default async function Home() {
|
||||
alt=""
|
||||
width={96}
|
||||
height={128}
|
||||
priority
|
||||
unoptimized
|
||||
className="relative animate-float object-contain drop-shadow-2xl"
|
||||
/>
|
||||
@@ -661,32 +722,29 @@ export default async function Home() {
|
||||
aria-label={tp("exploreLabel")}
|
||||
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
||||
>
|
||||
{exploreLinks.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="group inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-xs font-bold transition-all duration-300 hover:-translate-y-0.5 sm:text-sm"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-surface) 55%, transparent)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
color: "var(--color-text-readable)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
boxShadow:
|
||||
"0 2px 8px -2px color-mix(in srgb, #000 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
{exploreLinks.map((l, i) => (
|
||||
<Link key={l.href} href={l.href} className="explore-pill group">
|
||||
<span
|
||||
className="-translate-x-1 text-[10px] opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
i % 2 === 0
|
||||
? "var(--color-primary)"
|
||||
: "var(--color-accent)",
|
||||
boxShadow: `0 0 8px color-mix(in srgb, ${
|
||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"
|
||||
} 70%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
{l.label}
|
||||
<ArrowRight
|
||||
aria-hidden="true"
|
||||
className="pill-arrow h-3.5 w-3.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
@@ -711,9 +769,9 @@ export default async function Home() {
|
||||
const accentVar =
|
||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
|
||||
return (
|
||||
<div
|
||||
<SpotlightCard
|
||||
key={f.title}
|
||||
className="card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
|
||||
className="spotlight card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
|
||||
style={{
|
||||
background: GLASS_TILE_BG,
|
||||
backdropFilter: "blur(16px)",
|
||||
@@ -738,8 +796,8 @@ export default async function Home() {
|
||||
<div
|
||||
className="relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${accentVar} 16%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 22%, transparent)`,
|
||||
background: `linear-gradient(135deg, color-mix(in srgb, ${accentVar} 30%, transparent), color-mix(in srgb, ${accentVar} 12%, transparent))`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 30%, transparent), 0 8px 18px -10px color-mix(in srgb, ${accentVar} 70%, transparent)`,
|
||||
borderRadius: 14,
|
||||
}}
|
||||
>
|
||||
@@ -766,7 +824,7 @@ export default async function Home() {
|
||||
>
|
||||
{f.desc}
|
||||
</p>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -787,64 +845,73 @@ export default async function Home() {
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8 lg:grid-cols-4">
|
||||
{[
|
||||
{ value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS },
|
||||
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
|
||||
{
|
||||
value: totalPhotos,
|
||||
label: tp("statsPhotos"),
|
||||
icon: ICON_CAMERA,
|
||||
},
|
||||
{
|
||||
value: articles?.length ?? null,
|
||||
label: tp("statsArticles"),
|
||||
icon: ICON_ARTICLE,
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="group relative flex flex-col items-center gap-2.5 text-center"
|
||||
>
|
||||
<div
|
||||
className="card-hairline overflow-hidden rounded-2xl border"
|
||||
style={PANEL_STYLE}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8 p-6 sm:p-8 lg:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
value: users,
|
||||
label: tp("statsCitizens"),
|
||||
icon: ICON_FRIENDS,
|
||||
},
|
||||
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
|
||||
{
|
||||
value: totalPhotos,
|
||||
label: tp("statsPhotos"),
|
||||
icon: ICON_CAMERA,
|
||||
},
|
||||
{
|
||||
value: articleCount,
|
||||
label: tp("statsArticles"),
|
||||
icon: ICON_ARTICLE,
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
}}
|
||||
key={s.label}
|
||||
className="stat-cell group relative flex flex-col items-center gap-2.5 text-center"
|
||||
>
|
||||
<Image
|
||||
src={s.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={s.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{s.value === null ? (
|
||||
<span className="text-base">{te("unavailable")}</span>
|
||||
) : (
|
||||
<AnimatedCounter value={s.value} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{s.value === null ? (
|
||||
<span className="text-base">{te("unavailable")}</span>
|
||||
) : (
|
||||
<AnimatedCounter value={s.value} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -870,7 +937,9 @@ export default async function Home() {
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-3.5 sm:p-4"
|
||||
>
|
||||
<HomeLoginForm
|
||||
<LoginForm
|
||||
variant="compact"
|
||||
redirectTo="/"
|
||||
captcha={{
|
||||
provider: captcha.provider,
|
||||
siteKey: captcha.siteKey || undefined,
|
||||
@@ -963,12 +1032,10 @@ export default async function Home() {
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{recentPhotos.slice(0, 4).map((p) => (
|
||||
{recentPhotos.slice(0, 4).map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/photos"
|
||||
title=""
|
||||
aria-label={tp("recentPhotos")}
|
||||
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
|
||||
style={{
|
||||
borderColor:
|
||||
@@ -977,7 +1044,7 @@ export default async function Home() {
|
||||
>
|
||||
<Image
|
||||
src={p.url}
|
||||
alt={tp("recentPhotos")}
|
||||
alt={`${tp("recentPhotos")} ${i + 1}`}
|
||||
width={280}
|
||||
height={210}
|
||||
className="h-full w-full object-cover"
|
||||
@@ -1009,6 +1076,28 @@ export default async function Home() {
|
||||
className="brand-halo left-1/2 top-0 h-72 w-[620px] max-w-full -translate-x-1/2 -translate-y-1/3"
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-blob -left-20 bottom-0 h-72 w-72"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 45%, transparent)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-16 top-4 h-64 w-64"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
animationDelay: "-6s",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
id="join-cta-title"
|
||||
className="relative text-3xl font-black tracking-tight text-balance sm:text-4xl"
|
||||
@@ -1025,34 +1114,24 @@ export default async function Home() {
|
||||
<div className="relative mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn-shine group inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wide transition-all duration-300 hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 50%, transparent)",
|
||||
}}
|
||||
className="btn-brand btn-shine group px-8 py-4 text-sm font-black uppercase tracking-wide"
|
||||
>
|
||||
{tp("ctaJoin")}
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/community"
|
||||
className="inline-flex items-center rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 hover:-translate-y-1 hover:brightness-110 active:scale-95"
|
||||
style={{
|
||||
color: "#fff",
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
border: "1px solid rgba(255,255,255,0.22)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
className="btn-glass-dark px-8 py-4 text-sm font-bold"
|
||||
>
|
||||
{tp("browseCommunity")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hero-ring" aria-hidden="true" />
|
||||
</section>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
@@ -53,9 +54,16 @@ export default async function PhotosPage() {
|
||||
photos = publicReadFailure("photos")(error);
|
||||
}
|
||||
|
||||
// Users can hide their photos everywhere, not only on their profile.
|
||||
const photoOwners = [...new Set((photos ?? []).map((p) => p.userId))];
|
||||
const photoPrivacy = await loadProfilePrivacyMap(photoOwners);
|
||||
const visiblePhotos = (photos ?? []).filter(
|
||||
(p) => photoPrivacy.get(p.userId)?.photos !== false,
|
||||
);
|
||||
|
||||
// Pre-shape for the client lightbox: translate captions server-side so the
|
||||
// client component stays free of i18n/db dependencies.
|
||||
const items: LightboxPhoto[] = (photos ?? []).map((p) => ({
|
||||
const items: LightboxPhoto[] = visiblePhotos.map((p) => ({
|
||||
id: String(p.id),
|
||||
url: p.url,
|
||||
alt: t("photoAlt", { id: p.userId }),
|
||||
@@ -64,7 +72,7 @@ export default async function PhotosPage() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="📸" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={items.length === 0}>
|
||||
@@ -76,6 +84,6 @@ export default async function PhotosPage() {
|
||||
<PhotoLightbox photos={items} />
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export default async function PollDetailPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/polls">{t("back")}</Link>
|
||||
</p>
|
||||
@@ -248,6 +248,6 @@ export default async function PollDetailPage({
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,8 @@ export function PollVoteForm({
|
||||
run(() => voteOnPoll({ pollId, votes }), {
|
||||
successMessage: t("voteSuccess"),
|
||||
errorMessage: t("voteError"),
|
||||
// voteOnPoll revalidates /polls and /polls/<id>.
|
||||
revalidated: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default async function PollsPage() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="📊" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={polls.length === 0}>
|
||||
@@ -122,6 +122,6 @@ export default async function PollsPage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default async function RadioApplyPage({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
{submitted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.submitted")}
|
||||
@@ -199,6 +199,6 @@ export default async function RadioApplyPage({
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export default async function RadioContestDetailPage({
|
||||
const active = contest.isActive ? "Active" : "Ended";
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/radio/contests">← Back to contests</Link>
|
||||
</p>
|
||||
@@ -90,6 +90,6 @@ export default async function RadioContestDetailPage({
|
||||
</tbody>
|
||||
</table>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default async function RadioContestsPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🎉" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={contests.length === 0}>
|
||||
@@ -71,6 +71,6 @@ export default async function RadioContestsPage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default async function RadioGiveawayDetailPage({
|
||||
: null);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/radio/giveaways">← Back to giveaways</Link>
|
||||
</p>
|
||||
@@ -95,6 +95,6 @@ export default async function RadioGiveawayDetailPage({
|
||||
</tbody>
|
||||
</table>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default async function RadioGiveawaysPage() {
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🎁" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={giveaways.length === 0}>
|
||||
@@ -78,6 +78,6 @@ export default async function RadioGiveawaysPage() {
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default async function RadioLeaderboardPage() {
|
||||
const rows = await loadRows();
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<section className="page-grid">
|
||||
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={rows.length === 0}>
|
||||
@@ -85,6 +85,6 @@ export default async function RadioLeaderboardPage() {
|
||||
</table>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user