Compare commits
58
Commits
fb470123c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9560ddc212 | ||
|
|
c0a5079ede | ||
|
|
a8172ce88e | ||
|
|
9416317c5b | ||
|
|
e17576856f | ||
|
|
201296e837 | ||
|
|
2ae8127476 | ||
|
|
95bc7da83e | ||
|
|
9fd675a6a0 | ||
|
|
5080ca586d | ||
|
|
76493a9df8 | ||
|
|
9cff32a04b | ||
|
|
79b0b8894d | ||
|
|
6b9ed8d15e | ||
|
|
6c89a7ed46 | ||
|
|
fbf98cd2b5 | ||
|
|
cf448d6ce9 | ||
|
|
4768b2b406 | ||
|
|
af67a28639 | ||
|
|
a4bc25b6d6 | ||
|
|
fb724a35cc | ||
|
|
a878d831aa | ||
|
|
1ad4f9ea28 | ||
|
|
4f6b352691 | ||
|
|
42fa988d9c | ||
|
|
be4dc08c31 | ||
|
|
6ef92f57fc | ||
|
|
a16e9da018 | ||
|
|
3cb5baabfb | ||
|
|
e96c408ac1 | ||
|
|
e4564abc5e | ||
|
|
30583b8e2e | ||
|
|
97454f361b | ||
|
|
ecd8e5852c | ||
|
|
8ca538f99f | ||
|
|
1e81cac504 | ||
|
|
3bc218ba16 | ||
|
|
61f4db2a00 | ||
|
|
597287fce5 | ||
|
|
5c428c4526 | ||
|
|
e7a6b2b602 | ||
|
|
4b25292ecc | ||
|
|
b40293bd06 | ||
|
|
9cf1eb615c | ||
|
|
aef0939ee9 | ||
|
|
c6e5e0b1a9 | ||
|
|
2249303dbd | ||
|
|
07c5904879 | ||
|
|
4776a41c01 | ||
|
|
a32cf6119b | ||
|
|
bb3799f705 | ||
|
|
d28dde459a | ||
|
|
095353012a | ||
|
|
ef0c425598 | ||
|
|
8d3263a8fe | ||
|
|
050fcdf25b | ||
|
|
a40669b130 | ||
|
|
c15177a52e |
No files matched your search
@@ -26,6 +26,25 @@ bun.lockb
|
|||||||
public/nitro-assets
|
public/nitro-assets
|
||||||
public/swf
|
public/swf
|
||||||
|
|
||||||
|
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
|
||||||
|
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
|
||||||
|
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
|
||||||
|
# testcontainers en drizzle-kit afhankelijk maken.
|
||||||
|
e2e/
|
||||||
|
integration/
|
||||||
|
|
||||||
|
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
||||||
|
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
||||||
|
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Wordt tijdens de build opnieuw gegenereerd door `pnpm assets:editor`
|
||||||
|
# (bron: node_modules/tinymce) en staat niet in git.
|
||||||
|
public/vendor/
|
||||||
|
|
||||||
|
# Lokale buildcache voor `docker compose build` (cache_from/cache_to).
|
||||||
|
.docker-cache/
|
||||||
|
|
||||||
.deploy.lock
|
.deploy.lock
|
||||||
logs
|
logs
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -3,8 +3,14 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
|
||||||
DATABASE_POOL_SIZE=150
|
# `connection_limit` in the URL is only honoured by tools that read the URL
|
||||||
|
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||||
|
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||||
|
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||||
|
# DATABASE_URL supports both MySQL/MariaDB ("mysql://...") and PostgreSQL ("postgres://...").
|
||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||||
|
DATABASE_POOL_SIZE=25
|
||||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
@@ -59,6 +65,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
|||||||
APP_KEY=base64:your-app-key-here=
|
APP_KEY=base64:your-app-key-here=
|
||||||
# Bcrypt cost factor for new password hashes.
|
# Bcrypt cost factor for new password hashes.
|
||||||
BCRYPT_COST=12
|
BCRYPT_COST=12
|
||||||
|
# Password-length policy for every new password (register, password reset and
|
||||||
|
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
|
||||||
|
PASSWORD_MIN_LENGTH=12
|
||||||
|
PASSWORD_MAX_LENGTH=128
|
||||||
|
|
||||||
# --- ANTI-DDOS (app-layer gate, production only) ---
|
# --- ANTI-DDOS (app-layer gate, production only) ---
|
||||||
# On by default in production. Set to "false" to disable (not recommended).
|
# On by default in production. Set to "false" to disable (not recommended).
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,9 @@ blob-report/
|
|||||||
build-reports/
|
build-reports/
|
||||||
!/docs/performance-budgets.md
|
!/docs/performance-budgets.md
|
||||||
|
|
||||||
|
# Local buildcache (docker compose cache_from/cache_to)
|
||||||
|
.docker-cache/
|
||||||
|
|
||||||
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
||||||
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
||||||
backups/
|
backups/
|
||||||
+1
-1
@@ -27,7 +27,7 @@ This source change alone does not configure or deploy the production service.
|
|||||||
## Exported content
|
## Exported content
|
||||||
|
|
||||||
| Source | Repository destination |
|
| Source | Repository destination |
|
||||||
| --- | --- |
|
| ------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||||
| Configured furni icons | `Gamedata/icons` |
|
| Configured furni icons | `Gamedata/icons` |
|
||||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||||
|
|||||||
+29
-7
@@ -1,23 +1,45 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:26.10.0-alpine AS migrations
|
FROM node:26.12.1-alpine AS migrations
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
# Installeer git, bash en corepack. bash is nodig voor
|
||||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||||
# Alpine levert geen bash mee.
|
# Alpine levert geen bash mee.
|
||||||
|
#
|
||||||
|
# pnpm komt niet meer uit een losgepind npm-pakket, maar uit corepack: die
|
||||||
|
# leest de versie én de sha512-hash uit de `packageManager`-pin van
|
||||||
|
# package.json. Daarmee kan de image nooit uit sync raken met het lockfile
|
||||||
|
# (vroeger stond hier [email protected] terwijl package.json 12.11.2 voorschreef)
|
||||||
|
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
apk add --no-cache git bash \
|
--mount=type=cache,target=/root/.npm \
|
||||||
&& npm install -g [email protected]
|
apk add --no-cache git bash util-linux \
|
||||||
|
&& npm install -g [email protected] \
|
||||||
|
&& corepack enable pnpm
|
||||||
|
|
||||||
|
# corepack bewaart de gedownloade pnpm buiten de cache-mount, zodat de runtime
|
||||||
|
# (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm ook
|
||||||
|
# zonder netwerk kan gebruiken.
|
||||||
|
ENV COREPACK_HOME="/usr/local/share/corepack"
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
|
||||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
# Alleen package.json: de `packageManager`-pin bepaalt welke pnpm corepack
|
||||||
|
# installeert. Deze laag blijft gecachet wanneer alleen de lockfile verandert.
|
||||||
|
COPY package.json ./
|
||||||
|
RUN corepack install
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||||
|
|
||||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
# Voer de installatie uit met de pnpm v12 store cache-mount. De mount moet op
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
# de store staan die pnpm ook echt gebruikt ($PNPM_HOME/store/v11); met het
|
||||||
|
# oude pad (/root/.local/share/pnpm/store) cache'te de mount niets en
|
||||||
|
# downloadde elke build alle packages opnieuw.
|
||||||
|
RUN --mount=type=cache,target=/usr/local/share/pnpm/store \
|
||||||
pnpm install --frozen-lockfile --ignore-scripts
|
pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -52,7 +74,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
|||||||
pnpm run build \
|
pnpm run build \
|
||||||
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||||
|
|
||||||
FROM node:26.10.0-alpine AS runner
|
FROM node:26.12.1-alpine AS runner
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noDescendingSpecificity": "off"
|
"noDescendingSpecificity": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noComponentHookFactories": "off",
|
||||||
|
"usePlaywrightValidDescribeCallback": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,4 +6,10 @@ services:
|
|||||||
HOSTNAME: 127.0.0.1
|
HOSTNAME: 127.0.0.1
|
||||||
PORT: "3002"
|
PORT: "3002"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
@@ -39,6 +39,13 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
keepalive_timeout 30s;
|
keepalive_timeout 30s;
|
||||||
|
|
||||||
|
# Gamedata/icons are served from disk by alias locations. Caching the
|
||||||
|
# stat/open results avoids a syscall storm on every room load.
|
||||||
|
open_file_cache max=10000 inactive=30s;
|
||||||
|
open_file_cache_valid 60s;
|
||||||
|
open_file_cache_min_uses 2;
|
||||||
|
open_file_cache_errors off;
|
||||||
|
|
||||||
client_max_body_size 64m;
|
client_max_body_size 64m;
|
||||||
client_body_buffer_size 16k;
|
client_body_buffer_size 16k;
|
||||||
client_header_buffer_size 1k;
|
client_header_buffer_size 1k;
|
||||||
|
|||||||
+46
-17
@@ -1,5 +1,20 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Next.js CMS — blue/green
|
# Next.js CMS — single slot on port 3002
|
||||||
|
#
|
||||||
|
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
|
||||||
|
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
|
||||||
|
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
|
||||||
|
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
|
||||||
|
# migrations en zet nginx pas om na health + e2e + imaging.
|
||||||
|
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
|
||||||
|
#
|
||||||
|
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
|
||||||
|
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
|
||||||
|
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
|
||||||
|
# van de site drukken. De service start alleen als je hem expliciet noemt
|
||||||
|
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
|
||||||
|
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
|
||||||
|
# je dat dus juist NOOIT doen.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
x-cms: &cms
|
x-cms: &cms
|
||||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||||
@@ -14,8 +29,20 @@ x-cms: &cms
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||||
# Runtime host networking IS required: blue/green needs per-release host ports
|
# Lokale buildcache op schijf. `scripts/docker-prune.sh` brengt de
|
||||||
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
|
# daemon-cache na elke deploy terug tot 4GB, waardoor de lagen van de
|
||||||
|
# vorige release verdwijnen. Deze export overleeft die prune: een rebuild
|
||||||
|
# na een lockfile-wijziging hergebruikt dan de al gedownloade packages
|
||||||
|
# (pnpm store) in plaats van ze opnieuw te downloaden.
|
||||||
|
# De map ligt binnen de buildcontext, zodat buildx een ontbrekende
|
||||||
|
# cachemap stilzwijgend negeert i.p.v. de build te laten falen; zowel
|
||||||
|
# .dockerignore als .gitignore sluiten de map uit.
|
||||||
|
cache_from:
|
||||||
|
- type=local,src=.docker-cache/cms
|
||||||
|
cache_to:
|
||||||
|
- type=local,dest=.docker-cache/cms,mode=max
|
||||||
|
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
|
||||||
|
# on the single port that is actually served (3002).
|
||||||
network_mode: host
|
network_mode: host
|
||||||
stop_grace_period: 15s
|
stop_grace_period: 15s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -28,13 +55,17 @@ x-cms: &cms
|
|||||||
- /var/www/Gamedata:/var/www/Gamedata
|
- /var/www/Gamedata:/var/www/Gamedata
|
||||||
|
|
||||||
# ── Resource limits ──
|
# ── Resource limits ──
|
||||||
mem_limit: 6g
|
mem_limit: 4g
|
||||||
memswap_limit: 7g
|
memswap_limit: 5g
|
||||||
cpus: 2.0
|
|
||||||
pids_limit: 512
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -43,21 +74,19 @@ x-cms: &cms
|
|||||||
services:
|
services:
|
||||||
cms:
|
cms:
|
||||||
<<: *cms
|
<<: *cms
|
||||||
|
# Alleen starten als je deze host bewust compose-only beheert.
|
||||||
|
profiles: ["cms"]
|
||||||
container_name: epicnext-cms
|
container_name: epicnext-cms
|
||||||
environment:
|
environment:
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
|
|
||||||
cms-green:
|
|
||||||
<<: *cms
|
|
||||||
container_name: epicnext-cms-green
|
|
||||||
profiles: ["green"]
|
|
||||||
environment:
|
|
||||||
- HOSTNAME=0.0.0.0
|
|
||||||
- PORT=3003
|
|
||||||
|
|
||||||
byparr:
|
byparr:
|
||||||
image: ghcr.io/thephaseless/byparr:latest
|
image: ghcr.io/thephaseless/byparr:v3.0.4
|
||||||
|
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
|
||||||
|
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
|
||||||
|
# versie die ooit werd binnengehaald.
|
||||||
|
pull_policy: always
|
||||||
container_name: byparr
|
container_name: byparr
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Operator changes
|
## Operator changes
|
||||||
|
|
||||||
| Area | Behavior and location |
|
| Area | Behavior and location |
|
||||||
| --- | --- |
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Local verification on 2026-09-09:
|
Local verification on 2026-09-09:
|
||||||
|
|
||||||
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||||
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||||
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
|
|||||||
|
|
||||||
### Local verification and deployment boundary
|
### Local verification and deployment boundary
|
||||||
|
|
||||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||||
|
|
||||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ For each configured App Router route, resolve its exact app path using `app-path
|
|||||||
|
|
||||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
||||||
|
|
||||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||||
|
|
||||||
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||||
|
|
||||||
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||||
|
|
||||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||||
@@ -35,7 +34,7 @@ Baselines were last recalibrated against a local webpack production build (Next
|
|||||||
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
|
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
|
||||||
|
|
||||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
| --------------------- | -----------------: | ------------------: | --------: | ---------: |
|
||||||
| `/me` | 621460 | 195411 | 716800 | 225280 |
|
| `/me` | 621460 | 195411 | 716800 | 225280 |
|
||||||
| `/news` | 616712 | 193199 | 716800 | 225280 |
|
| `/news` | 616712 | 193199 | 716800 | 225280 |
|
||||||
| `/events` | 617121 | 193450 | 716800 | 225280 |
|
| `/events` | 617121 | 193450 | 716800 | 225280 |
|
||||||
@@ -46,4 +45,3 @@ The image build compiles the app, then runs `scripts/performance-report.mjs` in
|
|||||||
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
|
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
|
||||||
|
|
||||||
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||||
|
|
||||||
+6
-2
@@ -6,12 +6,16 @@ import { defineConfig } from "drizzle-kit";
|
|||||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
||||||
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
||||||
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
||||||
|
const dbUrl = process.env.DATABASE_URL ?? "";
|
||||||
|
const isPostgres =
|
||||||
|
dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "mysql",
|
dialect: isPostgres ? "postgresql" : "mysql",
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./drizzle/drafts",
|
out: "./drizzle/drafts",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? "",
|
url: dbUrl,
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
+24
-1
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
// Barrel-heavy packages: without this every import pulls the whole
|
||||||
|
// module graph into the chunk that first touches it.
|
||||||
|
optimizePackageImports: [
|
||||||
|
"lucide-react",
|
||||||
|
"motion",
|
||||||
|
"@dnd-kit/core",
|
||||||
|
"@dnd-kit/sortable",
|
||||||
|
"@tanstack/react-virtual",
|
||||||
|
],
|
||||||
useTypeScriptCli: true,
|
useTypeScriptCli: true,
|
||||||
hideLogsAfterAbort: true,
|
hideLogsAfterAbort: true,
|
||||||
},
|
},
|
||||||
@@ -167,6 +175,21 @@ const nextConfig: NextConfig = {
|
|||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The SWF/Nitro image pipeline (src/lib/services/swf, src/lib/services/image-*)
|
||||||
|
// pulls shared helpers into several server chunks that webpack reports as a
|
||||||
|
// circular dependency between chunks with runtime. The cycle only affects
|
||||||
|
// server-side module caching of already-loaded chunks and has no runtime
|
||||||
|
// impact, so suppress the noisy warning instead of refactoring the shared
|
||||||
|
// helpers (which would risk the hot image path). Revisit if a real deadlock
|
||||||
|
// appears.
|
||||||
|
webpack(config) {
|
||||||
|
config.ignoreWarnings = [
|
||||||
|
...(config.ignoreWarnings ?? []),
|
||||||
|
/Circular dependency between chunks with runtime/,
|
||||||
|
];
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+22
-20
File diff suppressed because it is too large.
Load diff
+26
-9
@@ -1,22 +1,39 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Deploy gate: keep this suite deterministic and light. One worker, one browser
|
||||||
|
// engine, no live dev server and no parallelism — that is what keeps the host
|
||||||
|
// from pinning at 100% CPU while the specs run.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
|
testIgnore: ["**/ui/**", "**/imaging/**", "**/news-real/**", "**/quality/**"],
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 1,
|
retries: process.env.CI ? 1 : 0,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
expect: { timeout: 10000 },
|
||||||
|
globalTimeout: 10 * 60 * 1000,
|
||||||
|
maxFailures: process.env.CI ? 10 : undefined,
|
||||||
reporter: [["list"], ["html", { open: "never" }]],
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
expect: {
|
|
||||||
toHaveScreenshot: {
|
|
||||||
animations: "disabled",
|
|
||||||
caret: "hide",
|
|
||||||
maxDiffPixelRatio: 0.02,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "off",
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-software-rasterizer",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-default-apps",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-sync",
|
||||||
|
"--metrics-recording-only",
|
||||||
|
"--mute-audio",
|
||||||
|
"--no-first-run",
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
});
|
});
|
||||||
+20
-4
@@ -1,8 +1,24 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
const port = Number(process.env.UI_TEST_PORT || 3107);
|
// The fixture server binds a loopback port. The CI runner packs several jobs
|
||||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
// onto the shared host network (capacity > 1), so a fixed port makes concurrent
|
||||||
throw Error("Invalid UI_TEST_PORT");
|
// runs fight over the bind: one wins, the other's browser then gets
|
||||||
|
// "connection refused". Pick one random high port for the whole run and pass it
|
||||||
|
// to every worker through the environment, unless a port is pinned explicitly
|
||||||
|
// (e.g. a developer reusing a manually started fixture server).
|
||||||
|
function fixturePort() {
|
||||||
|
const requested = Number(process.env.UI_TEST_PORT);
|
||||||
|
if (Number.isInteger(requested) && requested >= 1024 && requested <= 65535)
|
||||||
|
return requested;
|
||||||
|
const shared = Number(process.env.PW_UI_TEST_PORT);
|
||||||
|
if (Number.isInteger(shared) && shared >= 1024 && shared <= 65535)
|
||||||
|
return shared;
|
||||||
|
const port = randomInt(30000, 50000);
|
||||||
|
process.env.PW_UI_TEST_PORT = String(port);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
const port = fixturePort();
|
||||||
const baseURL = `http://127.0.0.1:${port}`;
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e/ui",
|
testDir: "./e2e/ui",
|
||||||
@@ -65,7 +81,7 @@ export default defineConfig({
|
|||||||
command: "node e2e/ui/fixture-server.mjs",
|
command: "node e2e/ui/fixture-server.mjs",
|
||||||
url: `${baseURL}/health`,
|
url: `${baseURL}/health`,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: { UI_TEST_PORT: String(port) },
|
env: { UI_TEST_PORT: String(port) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Generated
+4788
-2549
File diff suppressed because it is too large.
Load diff
+8
-8
@@ -1,9 +1,9 @@
|
|||||||
packages:
|
packages:
|
||||||
- '.'
|
- "."
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': true
|
"@parcel/watcher": true
|
||||||
'@swc/core': true
|
"@swc/core": true
|
||||||
cpu-features: true
|
cpu-features: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msw: true
|
msw: true
|
||||||
@@ -13,12 +13,12 @@ allowBuilds:
|
|||||||
minimumReleaseAge: 60
|
minimumReleaseAge: 60
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
glob: '^11.0.0'
|
glob: "^11.0.0"
|
||||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
"@esbuild-kit/core-utils": "npm:tsx@^4.23.15"
|
||||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
"@esbuild-kit/esm-loader": "npm:tsx@^4.23.15"
|
||||||
source-map-js: '1.2.2'
|
source-map-js: "1.2.2"
|
||||||
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
|
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
|
||||||
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
|
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
|
||||||
# exactly and has no upstream bump. The API subset it uses is unchanged in
|
# exactly and has no upstream bump. The API subset it uses is unchanged in
|
||||||
# v7, so force the patched major.
|
# v7, so force the patched major.
|
||||||
postcss-selector-parser: '^7.1.6'
|
postcss-selector-parser: "^7.1.6"
|
||||||
+1
-1
@@ -61,7 +61,7 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "De pnpm packageManager-pin is afgestemd op Docker en de runner-toolchain; niet automatisch bumpen",
|
"description": "De pnpm packageManager-pin blijft handmatig: Docker (corepack) en de runner volgen de pin automatisch, maar een bump raakt ook de lockfile en de README-versietabel (scripts/sync-readme-versions.mjs)",
|
||||||
"matchPackageNames": ["pnpm"],
|
"matchPackageNames": ["pnpm"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
|||||||
## Scripts Overview
|
## Scripts Overview
|
||||||
|
|
||||||
| Script | Description |
|
| Script | Description |
|
||||||
| :--- | :--- |
|
| :------------------------- | :---------------------------------------------------------------- |
|
||||||
| `dashboard.sh` | Main interactive menu to run all other scripts. |
|
| `dashboard.sh` | Main interactive menu to run all other scripts. |
|
||||||
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
|
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
|
||||||
| `logs.sh` | View logs for cms and mariadb services. |
|
| `logs.sh` | View logs for cms and mariadb services. |
|
||||||
@@ -28,6 +28,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
|||||||
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
|
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Run any script directly, or use the dashboard:
|
Run any script directly, or use the dashboard:
|
||||||
```bash
|
```bash
|
||||||
./scripts/dashboard.sh
|
./scripts/dashboard.sh
|
||||||
@@ -69,7 +70,7 @@ menu, so it is usable from cron.
|
|||||||
Every step the engine exposes is available as a flag:
|
Every step the engine exposes is available as a flag:
|
||||||
|
|
||||||
| Flag | What it does |
|
| Flag | What it does |
|
||||||
| :--- | :--- |
|
| :--------------------------------------------- | :------------------------------------------------------------------------- |
|
||||||
| `--sources=<a,b>` | Compare against these sources (id or display name) |
|
| `--sources=<a,b>` | Compare against these sources (id or display name) |
|
||||||
| `--icons` | Re-download missing furniture icons |
|
| `--icons` | Re-download missing furniture icons |
|
||||||
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
|
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
|
||||||
@@ -94,7 +95,6 @@ catalog page plus the offer, exactly like the app's own import. It skips
|
|||||||
anything already present, so it is safe to re-run.
|
anything already present, so it is safe to re-run.
|
||||||
|
|
||||||
Safety notes:
|
Safety notes:
|
||||||
|
|
||||||
- **An audit writes nothing** — no repair flag means read-only.
|
- **An audit writes nothing** — no repair flag means read-only.
|
||||||
- `--dry-run` together with `--import-new` lists what would be imported and
|
- `--dry-run` together with `--import-new` lists what would be imported and
|
||||||
imports nothing; use it first on a source before importing thousands of items.
|
imports nothing; use it first on a source before importing thousands of items.
|
||||||
@@ -106,7 +106,7 @@ Safety notes:
|
|||||||
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
|
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
|
||||||
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
|
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
|
||||||
Both only report without it.
|
Both only report without it.
|
||||||
- The engine is the same one the admin *Catalog audit* page streams
|
- The engine is the same one the admin _Catalog audit_ page streams
|
||||||
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
|
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
|
||||||
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
|
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
|
||||||
error-severity issues remain, which is what a scheduled check should use.
|
error-severity issues remain, which is what a scheduled check should use.
|
||||||
|
|||||||
+19
-6
@@ -42,13 +42,10 @@ old_container=""
|
|||||||
new_container=""
|
new_container=""
|
||||||
blue_green=0
|
blue_green=0
|
||||||
|
|
||||||
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
|
# CPU en pids zijn bewust verwijderd; memory blijft gelimiteerd op 4g/5g swap
|
||||||
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
|
# om een geheugenlek niet de host te laten vullen (MariaDB, nginx, Traefik).
|
||||||
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
|
|
||||||
mem_limit="4g"
|
mem_limit="4g"
|
||||||
mem_swap_limit="5g"
|
mem_swap_limit="5g"
|
||||||
cpu_limit="2"
|
|
||||||
pids_limit="512"
|
|
||||||
|
|
||||||
is_current() {
|
is_current() {
|
||||||
local head
|
local head
|
||||||
@@ -300,7 +297,6 @@ start_candidate() {
|
|||||||
done < "$deploy_dir/.env"
|
done < "$deploy_dir/.env"
|
||||||
docker run -d --name "$name" --restart always --net=host \
|
docker run -d --name "$name" --restart always --net=host \
|
||||||
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
||||||
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
|
|
||||||
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
||||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||||
@@ -416,9 +412,26 @@ pnpm install --frozen-lockfile
|
|||||||
pnpm exec playwright install chromium firefox webkit
|
pnpm exec playwright install chromium firefox webkit
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
|
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
|
||||||
|
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
|
||||||
|
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
|
||||||
|
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
|
||||||
|
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
|
||||||
|
build_cache_flags=()
|
||||||
|
if [ -n "$build_cache_dir" ]; then
|
||||||
|
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||||
|
build_cache_flags=(
|
||||||
|
--cache-from "type=local,src=$build_cache_dir"
|
||||||
|
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||||
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||||
|
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
check_current
|
check_current
|
||||||
# Read reports from the already-built image; do not start an extra application.
|
# Read reports from the already-built image; do not start an extra application.
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
|
|||||||
export NEWS_E2E_IMAGE="$image"
|
export NEWS_E2E_IMAGE="$image"
|
||||||
export NEWS_E2E_RELEASE="$sha"
|
export NEWS_E2E_RELEASE="$sha"
|
||||||
build_attempted=1
|
build_attempted=1
|
||||||
|
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
|
||||||
|
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
|
||||||
|
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
|
||||||
|
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
|
||||||
|
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
|
||||||
|
build_cache_flags=()
|
||||||
|
if [ -n "$build_cache_dir" ]; then
|
||||||
|
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||||
|
build_cache_flags=(
|
||||||
|
--cache-from "type=local,src=$build_cache_dir"
|
||||||
|
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||||
|
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||||
@@ -126,7 +126,9 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
|||||||
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
||||||
docker tag "$remote_migration_image" "$migration_image"
|
docker tag "$remote_migration_image" "$migration_image"
|
||||||
else
|
else
|
||||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" \
|
||||||
|
--cache-from type=local,src=.docker-cache/cms \
|
||||||
|
-t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||||
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||||
fi
|
fi
|
||||||
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||||
|
|||||||
+15
-4
@@ -78,13 +78,24 @@ else
|
|||||||
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
# sites-enabled/cms.conf must be a symlink, not a copy. A regular file here
|
||||||
|
# silently shadows sites-available/cms.conf, so nginx keeps serving stale
|
||||||
|
# config even though the sync updated sites-available.
|
||||||
|
enabled="$NGINX_DIR/sites-enabled/cms.conf"
|
||||||
|
if [[ -L "$enabled" && "$(readlink "$enabled")" == "../sites-available/cms.conf" ]]; then
|
||||||
|
echo "= $enabled symlink up to date"
|
||||||
|
else
|
||||||
if [[ "$MODE" == "check" ]]; then
|
if [[ "$MODE" == "check" ]]; then
|
||||||
echo "- sites-enabled/cms.conf missing"
|
echo "- $enabled is not the expected symlink"
|
||||||
changed=1
|
changed=1
|
||||||
else
|
else
|
||||||
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
mkdir -p "$NGINX_DIR/sites-enabled"
|
||||||
echo "+ linked sites-enabled/cms.conf"
|
if [[ -e "$enabled" || -L "$enabled" ]]; then
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cp -a "$enabled" "$BACKUP_DIR/cms-enabled.conf"
|
||||||
|
fi
|
||||||
|
ln -sfn ../sites-available/cms.conf "$enabled"
|
||||||
|
echo "+ linked $enabled"
|
||||||
changed=1
|
changed=1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"mode": "enforce",
|
"mode": "enforce",
|
||||||
"routes": [
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/",
|
||||||
|
"maxInitialRawBytes": 716800,
|
||||||
|
"maxInitialGzipBytes": 225280
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"route": "/me",
|
"route": "/me",
|
||||||
"maxInitialRawBytes": 716800,
|
"maxInitialRawBytes": 716800,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const hasCompose =
|
|||||||
}).status === 0;
|
}).status === 0;
|
||||||
|
|
||||||
it.skipIf(!hasCompose)(
|
it.skipIf(!hasCompose)(
|
||||||
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
|
"loads the loopback override from .env for the cms service without changing mounts or host networking",
|
||||||
() => {
|
() => {
|
||||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
||||||
try {
|
try {
|
||||||
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
|
|||||||
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
||||||
)
|
)
|
||||||
delete environment[key];
|
delete environment[key];
|
||||||
|
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
|
||||||
|
// can never squat port 3002 on a CI-managed host. Selecting the
|
||||||
|
// profile here keeps the loopback/port-override contract under test.
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
"docker",
|
"docker",
|
||||||
["compose", "config", "--format", "json"],
|
["compose", "--profile", "cms", "config", "--format", "json"],
|
||||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||||
);
|
);
|
||||||
expect(result.status, result.stderr).toBe(0);
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ systemd_cmd=()
|
|||||||
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
||||||
export CMS_MEMORY_CAPPED=1
|
export CMS_MEMORY_CAPPED=1
|
||||||
|
|
||||||
|
# Gebruik hoogstens de helft van de cores, zodat de machine niet op 100% CPU
|
||||||
|
# piekt, maar behoud voldoende parallelle kracht op (Ryzen) hosts.
|
||||||
|
cpu_count="$(nproc 2>/dev/null || echo 1)"
|
||||||
|
worker_cores=$((cpu_count / 2))
|
||||||
|
[ "$worker_cores" -lt 1 ] && worker_cores=1
|
||||||
|
cpu_affinity="0-$((worker_cores - 1))"
|
||||||
|
|
||||||
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
||||||
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||||
# terugval naar ulimit worden in plaats van een kapotte build.
|
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||||
@@ -174,7 +181,7 @@ run_systemd() {
|
|||||||
# expliciet: deze host heeft geen swap.
|
# expliciet: deze host heeft geen swap.
|
||||||
local high=$((effective * 95 / 100))
|
local high=$((effective * 95 / 100))
|
||||||
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
|
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
|
||||||
exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@"
|
exec "${systemd_cmd[@]}" -E "PATH=$PATH" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" -p "AllowedCPUs=$cpu_affinity" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_ulimit() {
|
run_ulimit() {
|
||||||
@@ -186,7 +193,11 @@ run_ulimit() {
|
|||||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if command -v taskset >/dev/null 2>&1; then
|
||||||
|
exec taskset -c "$cpu_affinity" "$@"
|
||||||
|
else
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_refuse() {
|
run_refuse() {
|
||||||
@@ -197,7 +208,11 @@ run_refuse() {
|
|||||||
|
|
||||||
run_opted_out() {
|
run_opted_out() {
|
||||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||||
|
if command -v taskset >/dev/null 2>&1; then
|
||||||
|
exec taskset -c "$cpu_affinity" "$@"
|
||||||
|
else
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$backend" in
|
case "$backend" in
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const gif = await toBadgeGif(buffer);
|
const gif = await toBadgeGif(buffer);
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(target, gif);
|
await writeFile(target, gif);
|
||||||
} catch {
|
} catch {
|
||||||
back("error", "Could not process or write the badge file");
|
back("error", "Could not process or write the badge file");
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
|
|||||||
.values({
|
.values({
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
// key is one of 3 known const values
|
||||||
comment: COMMENTS[key] ?? null,
|
comment: COMMENTS[key] ?? null,
|
||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
|
|||||||
POINTS_KEYS.map((key) =>
|
POINTS_KEYS.map((key) =>
|
||||||
db
|
db
|
||||||
.insert(WebsiteSetting)
|
.insert(WebsiteSetting)
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
// key from POINTS_KEYS const
|
||||||
.values({ key, value: values[key], comment: "Radio points" })
|
.values({ key, value: values[key], comment: "Radio points" })
|
||||||
.onDuplicateKeyUpdate({
|
.onDuplicateKeyUpdate({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
// key from POINTS_KEYS const
|
||||||
set: { value: values[key] },
|
set: { value: values[key] },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
export async function applyPreset(formData: FormData): Promise<void> {
|
export async function applyPreset(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
// guarded by null check below
|
||||||
const preset = PRESETS[name];
|
const preset = PRESETS[name];
|
||||||
if (!preset) redirect("/admin/theme");
|
if (!preset) redirect("/admin/theme");
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { invalidateLoginCache } from "@/lib/auth/login-core";
|
import { invalidateLoginCache } from "@/lib/auth/login-core";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
|
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||||
import { db, PasswordReset, User } from "@/lib/db";
|
import { db, PasswordReset, User } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let error: "password" | "invalid" | "failed" | null = null;
|
let error: "password" | "invalid" | "failed" | null = null;
|
||||||
if (password.length < 12) error = "password";
|
if (
|
||||||
|
password.length < passwordPolicy.minLength ||
|
||||||
|
password.length > passwordPolicy.maxLength
|
||||||
|
)
|
||||||
|
error = "password";
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+10
-4
@@ -5,6 +5,7 @@ import { after } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { sendVerification } from "@/lib/auth/email-verification";
|
import { sendVerification } from "@/lib/auth/email-verification";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
|
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||||
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
|
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
|
||||||
import { invalidateKey } from "@/lib/cached-db";
|
import { invalidateKey } from "@/lib/cached-db";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
@@ -83,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean {
|
|||||||
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rendered into the schema below and mapped back to a locale-independent code
|
||||||
|
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
|
||||||
|
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
|
||||||
|
const PASSWORD_MAX_MESSAGE = "Password is too long";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
username: z
|
username: z
|
||||||
@@ -105,8 +111,8 @@ const registerSchema = z
|
|||||||
),
|
),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
|
||||||
.max(128, "Password is too long") // Added max length
|
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
|
||||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||||
@@ -160,8 +166,8 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
|||||||
"This username is reserved": "usernameReserved",
|
"This username is reserved": "usernameReserved",
|
||||||
"Enter a valid email address": "emailValid",
|
"Enter a valid email address": "emailValid",
|
||||||
"Temporary email domains are not allowed": "emailDisposable",
|
"Temporary email domains are not allowed": "emailDisposable",
|
||||||
"Password must be at least 12 characters": "passwordMinLength",
|
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
|
||||||
"Password is too long": "passwordMaxLength",
|
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength",
|
||||||
"Password must contain at least one uppercase letter": "passwordUpper",
|
"Password must contain at least one uppercase letter": "passwordUpper",
|
||||||
"Password must contain at least one lowercase letter": "passwordLower",
|
"Password must contain at least one lowercase letter": "passwordLower",
|
||||||
"Password must contain at least one digit": "passwordDigit",
|
"Password must contain at least one digit": "passwordDigit",
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ export async function saveFavicon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = upload.bytes;
|
const buffer = upload.bytes;
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(baseDir, { recursive: true });
|
await mkdir(baseDir, { recursive: true });
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, buffer);
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
const url = `/api/media/favicon/${filename}`;
|
const url = `/api/media/favicon/${filename}`;
|
||||||
@@ -46,7 +44,6 @@ export async function saveFavicon(
|
|||||||
const oldPath = path.resolve(baseDir, oldName);
|
const oldPath = path.resolve(baseDir, oldName);
|
||||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await unlink(oldPath);
|
await unlink(oldPath);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore if file doesn't exist */
|
/* ignore if file doesn't exist */
|
||||||
@@ -85,7 +82,6 @@ export async function deleteFavicon(): Promise<{
|
|||||||
const oldPath = path.resolve(baseDir, oldName);
|
const oldPath = path.resolve(baseDir, oldName);
|
||||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await unlink(oldPath);
|
await unlink(oldPath);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ export async function saveLogo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = upload.bytes;
|
const buffer = upload.bytes;
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(baseDir, { recursive: true });
|
await mkdir(baseDir, { recursive: true });
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, buffer);
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
const url = `/api/media/logo/${filename}`;
|
const url = `/api/media/logo/${filename}`;
|
||||||
|
|||||||
@@ -278,14 +278,12 @@ export async function uploadBackground(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
let uploaded = false;
|
let uploaded = false;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(MEDIA_ROOT, { recursive: true });
|
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||||
const ext = file.name.split(".").pop() ?? "png";
|
const ext = file.name.split(".").pop() ?? "png";
|
||||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||||
const bytes = await file.arrayBuffer();
|
const bytes = await file.arrayBuffer();
|
||||||
const filePath = resolveMediaPath(name);
|
const filePath = resolveMediaPath(name);
|
||||||
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
|
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, Buffer.from(bytes));
|
await writeFile(filePath, Buffer.from(bytes));
|
||||||
await writeSetting("background_image", `/api/media/${name}`);
|
await writeSetting("background_image", `/api/media/${name}`);
|
||||||
await writeSetting(
|
await writeSetting(
|
||||||
@@ -319,7 +317,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
|
|||||||
const name = String(formData.get("preset") ?? "")
|
const name = String(formData.get("preset") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
// guarded by null check below
|
||||||
const preset = PRESETS[name];
|
const preset = PRESETS[name];
|
||||||
if (!preset) redirect(MANAGER_PATH);
|
if (!preset) redirect(MANAGER_PATH);
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export default async function ApplyStaffPage({
|
|||||||
<article key={String(position.id)} className="content-card">
|
<article key={String(position.id)} className="content-card">
|
||||||
<div className="content-card-head">
|
<div className="content-card-head">
|
||||||
{team?.badge ? (
|
{team?.badge ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
|
||||||
<img
|
<img
|
||||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||||
alt={rankName}
|
alt={rankName}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export default async function ApplyTeamPage({
|
|||||||
<article key={String(team.id)} className="content-card">
|
<article key={String(team.id)} className="content-card">
|
||||||
<div className="content-card-head">
|
<div className="content-card-head">
|
||||||
{team.badge ? (
|
{team.badge ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
|
||||||
<img
|
<img
|
||||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||||
alt={team.rankName}
|
alt={team.rankName}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export default async function BadgesPage() {
|
|||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
|
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
|
||||||
alt={badge.badgeName}
|
alt={badge.badgeName}
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ export default async function DrawBadgePage({
|
|||||||
{b.badgeUrl ? (
|
{b.badgeUrl ? (
|
||||||
// Badge images are arbitrary external URLs stored by the
|
// Badge images are arbitrary external URLs stored by the
|
||||||
// draw-badge tool, so a plain <img> is correct here.
|
// draw-badge tool, so a plain <img> is correct here.
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={b.badgeUrl}
|
src={b.badgeUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ export default async function GuildPage({
|
|||||||
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
||||||
>
|
>
|
||||||
{guild.badge ? (
|
{guild.badge ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||||
alt={`${guild.name} badge`}
|
alt={`${guild.name} badge`}
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export default async function GuildsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{g.badge ? (
|
{g.badge ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||||
alt={`${g.name} badge`}
|
alt={`${g.name} badge`}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui";
|
|||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { publicAssetExists } from "@/lib/public-asset";
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
|
||||||
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||||
@@ -99,7 +100,10 @@ export default async function HelpCategoryPage({
|
|||||||
const title = withHotel(cat.name, hotelName);
|
const title = withHotel(cat.name, hotelName);
|
||||||
const content = withHotel(cat.content, hotelName);
|
const content = withHotel(cat.content, hotelName);
|
||||||
const buttonText = withHotel(cat.buttonText, hotelName);
|
const buttonText = withHotel(cat.buttonText, hotelName);
|
||||||
const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : "";
|
const imageSrc =
|
||||||
|
cat.imageUrl && publicAssetExists(`${HELP_IMAGE_BASE}/${cat.imageUrl}`)
|
||||||
|
? `${HELP_IMAGE_BASE}/${cat.imageUrl}`
|
||||||
|
: "";
|
||||||
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,7 +114,6 @@ export default async function HelpCategoryPage({
|
|||||||
|
|
||||||
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
|
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
|
||||||
{imageSrc ? (
|
{imageSrc ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
WebsiteRuleCategories,
|
WebsiteRuleCategories,
|
||||||
WebsiteRules,
|
WebsiteRules,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { publicAssetExists } from "@/lib/public-asset";
|
||||||
|
|
||||||
|
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||||
|
const HELP_IMAGE_BASE = "/assets/images/help-center";
|
||||||
|
|
||||||
type HelpCategory = {
|
type HelpCategory = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -149,7 +153,11 @@ export default async function HelpCenterPage() {
|
|||||||
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
|
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => {
|
||||||
|
const imageSrc = c.imageUrl
|
||||||
|
? `${HELP_IMAGE_BASE}/${c.imageUrl}`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
<article key={String(c.id)} className="card hover">
|
<article key={String(c.id)} className="card hover">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -158,10 +166,9 @@ export default async function HelpCenterPage() {
|
|||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.imageUrl ? (
|
{imageSrc && publicAssetExists(imageSrc) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={c.imageUrl}
|
src={imageSrc}
|
||||||
alt=""
|
alt=""
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
@@ -209,7 +216,8 @@ export default async function HelpCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale, getMessages } from "next-intl/server";
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -34,28 +35,43 @@ export default async function SiteLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [session, locale, messages] = await Promise.all([
|
const [session, locale, messages, h] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
getLocale(),
|
getLocale(),
|
||||||
getMessages(),
|
getMessages(),
|
||||||
|
headers(),
|
||||||
]);
|
]);
|
||||||
const clientMessages = publicClientMessages(messages);
|
const clientMessages = publicClientMessages(messages);
|
||||||
|
|
||||||
|
// The landing pages render their own AuthTopBar (logo + live online), so the
|
||||||
|
// global site banner / navigation is hidden there to keep them clean and
|
||||||
|
// avoid showing the same chrome twice. Everything else keeps the chrome.
|
||||||
|
const pathname = h.get("x-pathname");
|
||||||
|
const isLandingPage =
|
||||||
|
pathname === "/" || pathname === "/login" || pathname === "/register";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
||||||
<CloudsField />
|
<CloudsField />
|
||||||
{/* Site chrome is public: hiding it all for anonymous visitors used to
|
{/* Site chrome is public: hiding it all for anonymous visitors used to
|
||||||
strand them — from /news, /leaderboard or /shop there was no way to
|
strand them — from /news, /leaderboard or /shop there was no way to
|
||||||
reach any other page at all. */}
|
reach any other page at all. Only the standalone landing pages opt
|
||||||
|
out, because they carry their own header. */}
|
||||||
|
{!isLandingPage && (
|
||||||
<div data-theme-block="top_header">
|
<div data-theme-block="top_header">
|
||||||
<TopHeader session={session} />
|
<TopHeader session={session} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLandingPage && (
|
||||||
<div data-theme-block="site_header">
|
<div data-theme-block="site_header">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLandingPage && (
|
||||||
<div data-theme-block="navigation">
|
<div data-theme-block="navigation">
|
||||||
<Navigation session={session} />
|
<Navigation session={session} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<main>
|
<main>
|
||||||
<div
|
<div
|
||||||
data-theme-block="content_grid"
|
data-theme-block="content_grid"
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
|
|||||||
const rows = await (active === "credits"
|
const rows = await (active === "credits"
|
||||||
? loadCreditsRows()
|
? loadCreditsRows()
|
||||||
: active === "diamonds" || active === "duckets"
|
: active === "diamonds" || active === "duckets"
|
||||||
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
|
? // active validated as "diamonds"|"duckets" in this branch
|
||||||
loadCurrencyRows(CURRENCY_TYPE[active])
|
loadCurrencyRows(CURRENCY_TYPE[active])
|
||||||
: loadSettingsRows(
|
: loadSettingsRows(
|
||||||
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
|
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
|
||||||
|
|||||||
+25
-163
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { buildNewsRss } from "@/lib/news-rss";
|
||||||
|
import { appBaseUrl } from "@/lib/seo";
|
||||||
|
import { getNewsList } from "@/lib/services/news-list";
|
||||||
|
|
||||||
|
// Built per request so the feed never needs a database connection during
|
||||||
|
// `next build`, matching the sitemap and robots routes.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [hotelName, items] = await Promise.all([
|
||||||
|
resolveHotelName(),
|
||||||
|
getNewsList(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
buildNewsRss(items, { hotelName, appUrl: appBaseUrl() }),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||||
|
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+311
-419
File diff suppressed because it is too large.
Load diff
@@ -108,7 +108,6 @@ export default async function RareCategoryPage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{badgeSrc ? (
|
{badgeSrc ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={badgeSrc}
|
src={badgeSrc}
|
||||||
alt={`${cat.name} badge`}
|
alt={`${cat.name} badge`}
|
||||||
@@ -150,7 +149,6 @@ export default async function RareCategoryPage({
|
|||||||
<tr key={String(r.id)}>
|
<tr key={String(r.id)}>
|
||||||
<td>
|
<td>
|
||||||
{iconSrc ? (
|
{iconSrc ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={iconSrc}
|
src={iconSrc}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export default async function RareValuesPage() {
|
|||||||
>
|
>
|
||||||
<div className="content-card-head">
|
<div className="content-card-head">
|
||||||
{badgeSrc ? (
|
{badgeSrc ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={badgeSrc}
|
src={badgeSrc}
|
||||||
alt={`${c.name} badge`}
|
alt={`${c.name} badge`}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { resetPassword } from "@/actions/password-reset";
|
import { resetPassword } from "@/actions/password-reset";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations("pages.reset");
|
const t = await getTranslations("pages.reset");
|
||||||
@@ -41,8 +42,12 @@ export default async function ResetPage({
|
|||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t("passwordPlaceholder")}
|
placeholder={t("passwordPlaceholder", {
|
||||||
|
min: passwordPolicy.minLength,
|
||||||
|
})}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
minLength={passwordPolicy.minLength}
|
||||||
|
maxLength={passwordPolicy.maxLength}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
@@ -58,7 +63,7 @@ export default async function ResetPage({
|
|||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(errorKey)}
|
{t(errorKey, { min: passwordPolicy.minLength })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const STATE_LABELS: Record<string, { label: string; icon: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function describeState(state: string): { label: string; icon: string } {
|
function describeState(state: string): { label: string; icon: string } {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
|
// STATE_LABELS has known keys, fallback provided
|
||||||
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ export default async function ShopPage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{a.iconUrl ? (
|
{a.iconUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={a.iconUrl}
|
src={a.iconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -366,7 +366,6 @@ async function ProfilePage({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
||||||
@@ -27,5 +29,13 @@ export default async function AdminNextLayout({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
// Staff tree: re-install the full catalogue the root layout scopes down to
|
||||||
|
// the public subset (nested providers replace the parent set).
|
||||||
|
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,6 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
|
|||||||
{ads.map((ad) => (
|
{ads.map((ad) => (
|
||||||
<tr key={ad.id}>
|
<tr key={ad.id}>
|
||||||
<td>
|
<td>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={ad.image}
|
src={ad.image}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ export default async function AdminCalendarCampaign({
|
|||||||
|
|
||||||
<div className="admin-card flex items-center gap-5 mb-6">
|
<div className="admin-card flex items-center gap-5 mb-6">
|
||||||
{campaign.image ? (
|
{campaign.image ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
className="w-24 h-24 object-cover"
|
className="w-24 h-24 object-cover"
|
||||||
src={campaign.image}
|
src={campaign.image}
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export default async function AdminCalendar() {
|
|||||||
className="admin-card hover article"
|
className="admin-card hover article"
|
||||||
>
|
>
|
||||||
{c.image ? (
|
{c.image ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img className="article-img" src={c.image} alt={c.name} />
|
<img className="article-img" src={c.image} alt={c.name} />
|
||||||
) : (
|
) : (
|
||||||
<div className="article-img" />
|
<div className="article-img" />
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default async function AdminAntiDdosPage() {
|
|||||||
const ip = key.replace("antiddos:block:", "");
|
const ip = key.replace("antiddos:block:", "");
|
||||||
return {
|
return {
|
||||||
ip,
|
ip,
|
||||||
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
ttlMs: Math.max(ttlMs, 0),
|
||||||
count: violationCounts.get(ip) ?? 0,
|
count: violationCounts.get(ip) ?? 0,
|
||||||
source: "gate" as const,
|
source: "gate" as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export default async function AdminGuildDetailPage({
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{guild.badge ? (
|
{guild.badge ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ export default async function AdminGuildsPage({
|
|||||||
<td className="muted">{g.id}</td>
|
<td className="muted">{g.id}</td>
|
||||||
<td>
|
<td>
|
||||||
{g.badge ? (
|
{g.badge ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages, getTranslations } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
||||||
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
||||||
@@ -40,8 +41,13 @@ export default async function AdminLayout({
|
|||||||
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The root layout ships the reduced catalogue so public pages do not carry
|
||||||
|
// the ~195 KB of staff-tool strings. Nested providers replace the parent
|
||||||
|
// set rather than merging, so staff route groups re-install the full one.
|
||||||
|
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||||
<div
|
<div
|
||||||
@@ -56,7 +62,7 @@ export default async function AdminLayout({
|
|||||||
</div>
|
</div>
|
||||||
</AdminMobileWrapper>
|
</AdminMobileWrapper>
|
||||||
<AdminToaster />
|
<AdminToaster />
|
||||||
</>
|
</NextIntlClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export default async function AdminPhotos({
|
|||||||
className="admin-card"
|
className="admin-card"
|
||||||
style={{ padding: 0, overflow: "hidden" }}
|
style={{ padding: 0, overflow: "hidden" }}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt={t("photoAlt", { id: String(p.id) })}
|
alt={t("photoAlt", { id: String(p.id) })}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default async function AdminRadioHistoryPage() {
|
|||||||
|
|
||||||
const liveCount = history.filter((h) => !h.endedAt).length;
|
const liveCount = history.filter((h) => !h.endedAt).length;
|
||||||
const peakListeners = history.reduce(
|
const peakListeners = history.reduce(
|
||||||
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
|
(max, h) => Math.max(h.listenersCount, max),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
|
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickString(obj: Record<string, unknown>, key: string): string | null {
|
function pickString(obj: Record<string, unknown>, key: string): string | null {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
|
// only called with hardcoded keys
|
||||||
const v = obj[key];
|
const v = obj[key];
|
||||||
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ function findNumberDeep(
|
|||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
if (!isRecord(value)) return null;
|
if (!isRecord(value)) return null;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
// keys from hardcoded array
|
||||||
const v = value[key];
|
const v = value[key];
|
||||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -577,7 +577,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
|
|||||||
)}
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={icon}
|
src={icon}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
@@ -733,7 +732,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
|
|||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={icon}
|
src={icon}
|
||||||
alt={g.base.name}
|
alt={g.base.name}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ import {
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
import { logger } from "@/lib/browser-log";
|
||||||
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { parseTraxChannels } from "@/lib/trax-format";
|
import { parseTraxChannels } from "@/lib/trax-format";
|
||||||
import { TraxPlayer } from "./trax-player";
|
import { TraxPlayer } from "./trax-player";
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Loader2, Play, Square } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/browser-log";
|
||||||
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
|
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
|
||||||
|
|
||||||
const TICK_SECONDS = TRAX_TICK_SECONDS;
|
const TICK_SECONDS = TRAX_TICK_SECONDS;
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ export default async function AdminTheme({
|
|||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
|
// k from fixed theme color key list
|
||||||
background: theme.settings[k] ?? "#64748b",
|
background: theme.settings[k] ?? "#64748b",
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export async function GET(req: Request) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const rarity = Object.fromEntries(
|
const rarity = Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
// rk from rarityKeys const, i is array index
|
||||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||||
) as Record<BadgeRarityKey, BoardCore>;
|
) as Record<BadgeRarityKey, BoardCore>;
|
||||||
|
|
||||||
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
|
|||||||
totalBadgesViewer = tb;
|
totalBadgesViewer = tb;
|
||||||
achievementViewer = ach;
|
achievementViewer = ach;
|
||||||
rarityViewer = Object.fromEntries(
|
rarityViewer = Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
// rk from rarityKeys const, i is array index
|
||||||
rarityKeys.map((rk, i) => [rk, rvs[i]]),
|
rarityKeys.map((rk, i) => [rk, rvs[i]]),
|
||||||
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
|
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
|
||||||
}
|
}
|
||||||
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
|
|||||||
viewerEntry: achievementViewer,
|
viewerEntry: achievementViewer,
|
||||||
},
|
},
|
||||||
rarity: Object.fromEntries(
|
rarity: Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
|
// rk from rarityKeys const
|
||||||
rarityKeys.map((rk) => [
|
rarityKeys.map((rk) => [
|
||||||
rk,
|
rk,
|
||||||
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
|
|||||||
async () =>
|
async () =>
|
||||||
type === "credits"
|
type === "credits"
|
||||||
? await loadCreditsRows()
|
? await loadCreditsRows()
|
||||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
: // type validated to "diamonds"|"duckets"
|
||||||
await loadCurrencyRows(CURRENCY_TYPE[type]),
|
await loadCurrencyRows(CURRENCY_TYPE[type]),
|
||||||
// Rankings are re-sorted on every read; serving the previous order for a
|
// Rankings are re-sorted on every read; serving the previous order for a
|
||||||
// minute keeps a busy leaderboard off the database.
|
// minute keeps a busy leaderboard off the database.
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export async function GET(
|
|||||||
headers: SECURITY_HEADERS,
|
headers: SECURITY_HEADERS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
return new NextResponse("Not found", {
|
return new NextResponse("Not found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -59,7 +58,6 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
const bytes = await readFile(filePath);
|
const bytes = await readFile(filePath);
|
||||||
const mime: Record<string, string> = {
|
const mime: Record<string, string> = {
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
@@ -76,7 +74,7 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
...SECURITY_HEADERS,
|
...SECURITY_HEADERS,
|
||||||
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
||||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
// ext validated against ALLOWED_EXT
|
||||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
*/
|
*/
|
||||||
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
|
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
|
||||||
const dir = MEDIA_ROOT;
|
const dir = MEDIA_ROOT;
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
return apiOk({ files: [] });
|
return apiOk({ files: [] });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
const files = readdirSync(dir)
|
const files = readdirSync(dir)
|
||||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||||
.map((f) => {
|
.map((f) => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
|
|||||||
"count",
|
"count",
|
||||||
];
|
];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
// keys from hardcoded array
|
||||||
const v = value[key];
|
const v = value[key];
|
||||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
if (
|
if (
|
||||||
|
|||||||
+193
-83
File diff suppressed because it is too large.
Load diff
+25
-4
@@ -13,6 +13,7 @@ import { ThemeVars } from "@/components/theme-vars";
|
|||||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||||
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
|
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { publicClientMessages } from "@/lib/i18n-client-messages";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -37,7 +38,10 @@ const pixelify = Pixelify_Sans({
|
|||||||
// export const instant = false;
|
// export const instant = false;
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
const hotel = await resolveHotelName();
|
const hotel = await resolveHotelName();
|
||||||
const customFavicon = await siteSettings.get("cms_favicon");
|
const customFavicon = await siteSettings.get("cms_favicon");
|
||||||
const faviconUrl = customFavicon ?? getDefaultFaviconDataUri();
|
const faviconUrl = customFavicon ?? getDefaultFaviconDataUri();
|
||||||
@@ -49,15 +53,28 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` },
|
title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` },
|
||||||
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
||||||
metadataBase: new URL(appUrl),
|
metadataBase: new URL(appUrl),
|
||||||
alternates: { canonical: appUrl },
|
alternates: {
|
||||||
|
canonical: appUrl,
|
||||||
|
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
|
||||||
|
},
|
||||||
icons,
|
icons,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${hotel} - Hotel`,
|
title: `${hotel} - Hotel`,
|
||||||
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
||||||
type: "website",
|
type: "website",
|
||||||
siteName: hotel,
|
siteName: hotel,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: `${appUrl}/assets/images/next_header.jpg`,
|
||||||
|
alt: `${hotel} - Hotel`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: `${hotel} - Hotel`,
|
||||||
|
images: [`${appUrl}/assets/images/next_header.jpg`],
|
||||||
},
|
},
|
||||||
twitter: { card: "summary_large_image", title: `${hotel} - Hotel` },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +85,11 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
await enforceSiteAccess();
|
await enforceSiteAccess();
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const messages = await getMessages();
|
// Only the namespaces non-staff routes can reach: the full catalogue is
|
||||||
|
// ~254 KB and would otherwise be serialised into every public page's RSC
|
||||||
|
// payload. `/admin`, `/admin-next` and `/mod` install a nested provider
|
||||||
|
// seeded with the full set (see i18n-client-messages).
|
||||||
|
const messages = publicClientMessages(await getMessages());
|
||||||
const defaultDark = await siteSettings.getBool("default_dark", false);
|
const defaultDark = await siteSettings.getBool("default_dark", false);
|
||||||
const nitroUrl = await siteSettings.get("nitro_client_url", "");
|
const nitroUrl = await siteSettings.get("nitro_client_url", "");
|
||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages, getTranslations } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
@@ -81,8 +82,13 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
|||||||
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
||||||
].filter((item) => item.show);
|
].filter((item) => item.show);
|
||||||
|
|
||||||
|
// The root layout ships the reduced catalogue so public pages do not carry
|
||||||
|
// the ~195 KB of staff-tool strings. Nested providers replace the parent
|
||||||
|
// set rather than merging, so staff route groups re-install the full one.
|
||||||
|
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||||
<div
|
<div
|
||||||
data-admin
|
data-admin
|
||||||
@@ -140,6 +146,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
|
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</NextIntlClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,6 @@ export function ArticleCard({
|
|||||||
<div className="admin-card overflow-hidden flex flex-col">
|
<div className="admin-card overflow-hidden flex flex-col">
|
||||||
{confirmDialog}
|
{confirmDialog}
|
||||||
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={image || ""}
|
src={image || ""}
|
||||||
alt={title}
|
alt={title}
|
||||||
|
|||||||
@@ -296,7 +296,6 @@ export function ArticleForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{image && !imageError ? (
|
{image && !imageError ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={t("imagePreview")}
|
alt={t("imagePreview")}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import {
|
|||||||
suggestCatalogLayoutFromBaseItems,
|
suggestCatalogLayoutFromBaseItems,
|
||||||
suggestCategoryName,
|
suggestCategoryName,
|
||||||
} from "@/features/catalog/domain/organize";
|
} from "@/features/catalog/domain/organize";
|
||||||
|
import { logger } from "@/lib/browser-log";
|
||||||
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
|
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
|
||||||
import { getLocalIconUrl } from "@/lib/furni/classname";
|
import { getLocalIconUrl } from "@/lib/furni/classname";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LiveCatalogPreview } from "./live-catalog-preview";
|
import { LiveCatalogPreview } from "./live-catalog-preview";
|
||||||
|
|
||||||
@@ -715,7 +715,6 @@ function FurniThumb({
|
|||||||
className="flex items-center justify-center rounded bg-muted/50"
|
className="flex items-center justify-center rounded bg-muted/50"
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={primary}
|
src={primary}
|
||||||
alt={item.itemName}
|
alt={item.itemName}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ChevronRight, Home } from "lucide-react";
|
import { ChevronRight, Home } from "lucide-react";
|
||||||
import { Fragment, useEffect } from "react";
|
import { Fragment, useEffect } from "react";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/browser-log";
|
||||||
import { useCatalogManager } from "./use-catalog-store";
|
import { useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
export function BreadcrumbBar({
|
export function BreadcrumbBar({
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-
|
|||||||
import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog";
|
import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog";
|
||||||
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/browser-log";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
type FlatTreeNode,
|
type FlatTreeNode,
|
||||||
@@ -893,7 +893,7 @@ export function SortableTree({
|
|||||||
handleToggleEnabled,
|
handleToggleEnabled,
|
||||||
handleAddSubpage,
|
handleAddSubpage,
|
||||||
],
|
],
|
||||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export function FurniIcon({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={localUrl}
|
src={localUrl}
|
||||||
alt={classname}
|
alt={classname}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { logger } from "@/lib/browser-log";
|
||||||
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface IconPickerProps {
|
interface IconPickerProps {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
import { logger } from "@/lib/browser-log";
|
||||||
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
interface FurniResult {
|
interface FurniResult {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -231,7 +231,6 @@ function FurniThumb({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={primary}
|
src={primary}
|
||||||
alt={classname}
|
alt={classname}
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ export function ShopFurniIcon({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={primary}
|
src={primary}
|
||||||
alt={classname}
|
alt={classname}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
|
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
|
import { logger } from "@/lib/browser-log";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import type { MediaFile } from "@/lib/media-files";
|
import type { MediaFile } from "@/lib/media-files";
|
||||||
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
|
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export function AdminMediaGrid() {
|
|||||||
const [copied, setCopied] = useState<string | null>(null);
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const copyTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -40,6 +41,12 @@ export function AdminMediaGrid() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const t of copyTimers.current) clearTimeout(t);
|
||||||
|
copyTimers.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -226,7 +233,6 @@ export function AdminMediaGrid() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={validImageUrl(f.url)}
|
src={validImageUrl(f.url)}
|
||||||
alt={f.name}
|
alt={f.name}
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ export function MediaPicker({
|
|||||||
e.currentTarget.style.borderColor = "transparent";
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={validImageUrl(f.url)}
|
src={validImageUrl(f.url)}
|
||||||
alt={f.name}
|
alt={f.name}
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
|
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
|
||||||
|
import { logger } from "@/lib/browser-log";
|
||||||
import {
|
import {
|
||||||
CATALOG_LAYOUTS,
|
CATALOG_LAYOUTS,
|
||||||
type CatalogLayout,
|
type CatalogLayout,
|
||||||
isValidCatalogLayout,
|
isValidCatalogLayout,
|
||||||
LAYOUT_DESCRIPTIONS,
|
LAYOUT_DESCRIPTIONS,
|
||||||
} from "@/lib/catalog-layouts";
|
} from "@/lib/catalog-layouts";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MAX_ITEMS_PER_RUN = 500;
|
const MAX_ITEMS_PER_RUN = 500;
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export function FurniThumb({
|
|||||||
className="flex items-center justify-center rounded bg-muted/50"
|
className="flex items-center justify-center rounded bg-muted/50"
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={primary}
|
src={primary}
|
||||||
alt={item.itemName}
|
alt={item.itemName}
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
|
|||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
|
// k from fixed key list
|
||||||
background: palette.light[k],
|
background: palette.light[k],
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
||||||
marginLeft: -3,
|
marginLeft: -3,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AuthAlertProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Tint of the box: red for errors, green for confirmations. */
|
||||||
|
variant?: "error" | "success";
|
||||||
|
/** Extra classes, e.g. spacing (`mb-4`) or entrance animation. */
|
||||||
|
className?: string;
|
||||||
|
/** Optional id so inputs can point at the message via `aria-describedby`. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single status/error banner for the auth forms. Uses `role="alert"` for
|
||||||
|
* failures (screen readers announce them immediately) and `role="status"` for
|
||||||
|
* confirmations, so the two are wired correctly instead of every caller
|
||||||
|
* repeating the markup and risking an unannounced message.
|
||||||
|
*/
|
||||||
|
export function AuthAlert({
|
||||||
|
children,
|
||||||
|
variant = "error",
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
}: AuthAlertProps) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
id={id}
|
||||||
|
role={variant === "error" ? "alert" : "status"}
|
||||||
|
className={`auth-alert animate-fade-in-up m-0 ${
|
||||||
|
variant === "success" ? "auth-alert--success" : ""
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user