Compare commits
52
Commits
095353012a
...
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 |
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
@@ -29,16 +29,16 @@ jobs:
|
|||||||
LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }}
|
LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--memory=6g \
|
--memory=6g \
|
||||||
--memory-swap=6g \
|
--memory-swap=6g \
|
||||||
-e RENOVATE_PLATFORM=gitea \
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
|
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
|
||||||
-e RENOVATE_TOKEN \
|
-e RENOVATE_TOKEN \
|
||||||
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
|
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
|
||||||
-e RENOVATE_AUTODISCOVER=false \
|
-e RENOVATE_AUTODISCOVER=false \
|
||||||
-e RENOVATE_BINARY_SOURCE=install \
|
-e RENOVATE_BINARY_SOURCE=install \
|
||||||
-e LOG_LEVEL \
|
-e LOG_LEVEL \
|
||||||
-e RENOVATE_ONBOARDING=true \
|
-e RENOVATE_ONBOARDING=true \
|
||||||
-e RENOVATE_REQUIRE_CONFIG=required \
|
-e RENOVATE_REQUIRE_CONFIG=required \
|
||||||
"renovate/renovate:${RENOVATE_VERSION}"
|
"renovate/renovate:${RENOVATE_VERSION}"
|
||||||
@@ -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/
|
||||||
+9
-9
@@ -26,15 +26,15 @@ 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` |
|
||||||
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
||||||
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
||||||
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
||||||
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
||||||
|
|
||||||
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
|
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
|
||||||
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
|
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+14
-15
@@ -2,20 +2,20 @@
|
|||||||
|
|
||||||
## 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. |
|
||||||
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||||
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||||
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||||
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||||
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||||
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||||
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||||
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||||
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||||
|
|
||||||
## Deployment requirements
|
## Deployment requirements
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -34,16 +33,15 @@ 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 |
|
||||||
| `/search` | 618536 | 194216 | 716800 | 225280 |
|
| `/search` | 618536 | 194216 | 716800 | 225280 |
|
||||||
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
|
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
|
||||||
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
|
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
+9
-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,
|
||||||
},
|
},
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-41
File diff suppressed because it is too large.
Load diff
+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
|
||||||
}
|
}
|
||||||
exec "$@"
|
if command -v taskset >/dev/null 2>&1; then
|
||||||
|
exec taskset -c "$cpu_affinity" "$@"
|
||||||
|
else
|
||||||
|
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
|
||||||
exec "$@"
|
if command -v taskset >/dev/null 2>&1; then
|
||||||
|
exec taskset -c "$cpu_affinity" "$@"
|
||||||
|
else
|
||||||
|
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=""
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+27
-11
@@ -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
|
||||||
<div data-theme-block="top_header">
|
out, because they carry their own header. */}
|
||||||
<TopHeader session={session} />
|
{!isLandingPage && (
|
||||||
</div>
|
<div data-theme-block="top_header">
|
||||||
<div data-theme-block="site_header">
|
<TopHeader session={session} />
|
||||||
<SiteHeader />
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div data-theme-block="navigation">
|
{!isLandingPage && (
|
||||||
<Navigation session={session} />
|
<div data-theme-block="site_header">
|
||||||
</div>
|
<SiteHeader />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLandingPage && (
|
||||||
|
<div data-theme-block="navigation">
|
||||||
|
<Navigation session={session} />
|
||||||
|
</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],
|
||||||
|
|||||||
+26
-164
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+556
-664
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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
+194
-84
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")}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -128,6 +128,8 @@ function AvatarGrid({
|
|||||||
interface AuthPageFrameProps {
|
interface AuthPageFrameProps {
|
||||||
hotelName: string;
|
hotelName: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
|
/** Live online count shown in the top bar; omit to hide it. */
|
||||||
|
online?: number;
|
||||||
/** Title of the form card on the right. */
|
/** Title of the form card on the right. */
|
||||||
title: string;
|
title: string;
|
||||||
/** One-line intro above the form. */
|
/** One-line intro above the form. */
|
||||||
@@ -148,6 +150,7 @@ interface AuthPageFrameProps {
|
|||||||
export function AuthPageFrame({
|
export function AuthPageFrame({
|
||||||
hotelName,
|
hotelName,
|
||||||
logo,
|
logo,
|
||||||
|
online,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
notice,
|
notice,
|
||||||
@@ -158,7 +161,7 @@ export function AuthPageFrame({
|
|||||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<AuthAurora />
|
<AuthAurora />
|
||||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,41 +1,38 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Clock } from "@/components/clock";
|
import { Clock } from "@/components/clock";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
|
import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared frosted-glass top bar for the public auth pages (login, register,
|
* Shared frosted-glass top bar for the public pages (index, login, register).
|
||||||
* etc.). Shows the hotel logo + name on the left and language / clock / theme
|
* Shows the hotel logo on the left and the live "online" counter (which carries
|
||||||
* controls on the right. Defaults mirror the homepage top bar so auth pages
|
* the hotel name), language / clock / theme controls on the right. One
|
||||||
* share one consistent look.
|
* component so every public page keeps the exact same chrome instead of
|
||||||
|
* duplicating it.
|
||||||
*/
|
*/
|
||||||
export function AuthTopBar({
|
export function AuthTopBar({
|
||||||
hotelName,
|
hotelName,
|
||||||
logo,
|
logo,
|
||||||
showHotelName = true,
|
online,
|
||||||
}: {
|
}: {
|
||||||
hotelName: string;
|
hotelName: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
showHotelName?: boolean;
|
/** Live online count; omit (or pass null) to hide the indicator. */
|
||||||
|
online?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="relative flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
className="panel relative flex items-center justify-between gap-3 rounded-full px-3 py-2.5 sm:px-5"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
"linear-gradient(90deg, var(--panel-surface), color-mix(in srgb, var(--color-primary) 7%, var(--panel-surface)))",
|
||||||
backdropFilter: "blur(14px)",
|
|
||||||
WebkitBackdropFilter: "blur(14px)",
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
|
||||||
boxShadow:
|
|
||||||
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<Image
|
<Image
|
||||||
src={logo}
|
src={logo}
|
||||||
@@ -67,14 +64,6 @@ export function AuthTopBar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showHotelName ? (
|
|
||||||
<span
|
|
||||||
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
|
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
|
||||||
>
|
|
||||||
{hotelName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
|
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
|
||||||
@@ -83,6 +72,31 @@ export function AuthTopBar({
|
|||||||
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{typeof online === "number" && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 px-1"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
|
<span
|
||||||
|
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
|
||||||
|
style={{ background: "var(--color-primary)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="relative inline-flex h-2 w-2 rounded-full"
|
||||||
|
style={{ background: "var(--color-primary)" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-black tabular-nums"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div
|
<div
|
||||||
className="hidden sm:block w-px h-5"
|
className="hidden sm:block w-px h-5"
|
||||||
|
|||||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user