Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c605858bc | ||
|
|
1e3406a7ae | ||
|
|
ef6c671903 | ||
|
|
ef2f8c30f0 | ||
|
|
73005a3c05 | ||
|
|
918134df69 | ||
|
|
d64d864175 | ||
|
|
62de3e3a8b | ||
|
|
9f024dc6ac | ||
|
|
05c2706eaf | ||
|
|
ac23d7d1d9 | ||
|
|
3c8abb6fbf | ||
|
|
640e5a5dd0 | ||
|
|
3fa643828d | ||
|
|
888765c47f | ||
|
|
f3407ebfdf |
No files matched your search
@@ -18,6 +18,21 @@ CACHE_MEMORY_MAX_ENTRIES=2000
|
|||||||
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
||||||
IMAGING_CACHE_MAX_ENTRIES=20000
|
IMAGING_CACHE_MAX_ENTRIES=20000
|
||||||
|
|
||||||
|
# --- CACHE TTL TUNING (seconds; defaults apply when unset) ---
|
||||||
|
# Higher = less DB load but staler data. Publishing news rotates its cache
|
||||||
|
# revision immediately, so news TTLs only delay content that bypasses that path.
|
||||||
|
CMS_HOME_PAYLOAD_TTL_SEC=15
|
||||||
|
CMS_NEWS_LIST_TTL_SEC=300
|
||||||
|
CMS_NEWS_ARCHIVE_TTL_SEC=60
|
||||||
|
CMS_SEARCH_TTL_SEC=30
|
||||||
|
CMS_ONLINE_COUNT_TTL_SEC=10
|
||||||
|
CMS_COUNTERS_TTL_SEC=300
|
||||||
|
CMS_SITE_SETTINGS_TTL_SEC=300
|
||||||
|
CMS_SITE_SETTINGS_MEMORY_TTL_SEC=60
|
||||||
|
# Boot-time cache warm-up (primes hot keys after each deploy). Set false on
|
||||||
|
# tiny hosts where the boot COUNT(*) burst is undesirable.
|
||||||
|
CMS_WARMUP_ENABLED=true
|
||||||
|
|
||||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3002
|
PORT=3002
|
||||||
@@ -78,6 +93,18 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
|||||||
# Override for tests/staging (production uses the public endpoint by default).
|
# Override for tests/staging (production uses the public endpoint by default).
|
||||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||||
|
|
||||||
|
# --- CAPTCHA (env fallback; admin settings win when set) ---
|
||||||
|
# Enable a captcha on register/login/forgot/resend without touching the DB.
|
||||||
|
# "none" (default), "turnstile", "recaptcha" or "hcaptcha". Example:
|
||||||
|
# CAPTCHA_PROVIDER=turnstile
|
||||||
|
CAPTCHA_PROVIDER=
|
||||||
|
# Provider keys. The SITE key is public (it ships to every browser), the
|
||||||
|
# SECRET is server-only and must never be committed — set it on the host or as
|
||||||
|
# a masked CI/CD variable. Cloudflare Turnstile keys:
|
||||||
|
# https://dash.cloudflare.com/turnstile
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# --- PATHS ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
@@ -89,6 +116,13 @@ DB_BACKUP_KEEP=7
|
|||||||
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||||
HEALTH_ALERT_COOLDOWN_MIN=15
|
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||||
|
|
||||||
|
# --- FURNITURE ASSETS ---
|
||||||
|
# Extension the local Nitro client requests for furniture bundles. The CMS
|
||||||
|
# writes new/imported/repaired bundles with this extension and prefers it when
|
||||||
|
# resolving existing ones (the other stays readable as a legacy fallback).
|
||||||
|
# ".hab" (default) or ".nitro".
|
||||||
|
FURNI_BUNDLE_EXT=.hab
|
||||||
|
|
||||||
# --- RCON (Low Latency Loop) ---
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3003
|
RCON_PORT=3003
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ jobs:
|
|||||||
- name: Toolchain check
|
- name: Toolchain check
|
||||||
run: node scripts/check-node-toolchain.mjs
|
run: node scripts/check-node-toolchain.mjs
|
||||||
|
|
||||||
|
- name: README version sync
|
||||||
|
run: node scripts/sync-readme-versions.mjs --check
|
||||||
|
|
||||||
- name: Deploy port-selection tests
|
- name: Deploy port-selection tests
|
||||||
run: bash scripts/ci-deploy-ports.test.sh
|
run: bash scripts/ci-deploy-ports.test.sh
|
||||||
|
|
||||||
@@ -116,6 +119,75 @@ jobs:
|
|||||||
test-results/ui/
|
test-results/ui/
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
# Cross-browser imaging: the deploy gate only runs chromium, so this verifies
|
||||||
|
# a real render (decode + APNG animation + visual baseline) across every
|
||||||
|
# bundled engine against the public site. Blocking on purpose: this is the
|
||||||
|
# signal that avatar imaging works on every system, so a failure here must
|
||||||
|
# stop the pipeline. IMAGING_EDGE marks the target as edge-fronted, enabling
|
||||||
|
# the public-routing smoke and skipping the CMS-only /imaging assertions.
|
||||||
|
tests-imaging-browsers:
|
||||||
|
needs: check
|
||||||
|
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install cross-browser engines
|
||||||
|
run: pnpm exec playwright install --with-deps chromium firefox webkit
|
||||||
|
|
||||||
|
- name: Cross-browser imaging checks
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
|
||||||
|
IMAGING_EDGE: "1"
|
||||||
|
run: pnpm test:imaging
|
||||||
|
|
||||||
|
# Advisory live-quality checks: broken uploads/theme assets, legacy malformed
|
||||||
|
# avatar figures and warm-page latency. These depend on live content and data
|
||||||
|
# rather than the code under test, so they are informational only —
|
||||||
|
# `continue-on-error` keeps the pipeline green while still surfacing the
|
||||||
|
# signal in the job log and uploaded report. The deterministic caching contract
|
||||||
|
# is enforced by the blocking deploy gate instead.
|
||||||
|
tests-quality:
|
||||||
|
needs: check
|
||||||
|
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install quality test browser
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
|
- name: Advisory quality checks
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
|
||||||
|
run: pnpm test:e2e:quality
|
||||||
|
|
||||||
|
- name: Upload quality results
|
||||||
|
if: always()
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||||
|
with:
|
||||||
|
name: quality-results
|
||||||
|
path: |
|
||||||
|
playwright-report/quality/
|
||||||
|
test-results/quality/
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
preflight:
|
preflight:
|
||||||
needs: [tests-unit, tests-integration, tests-ui]
|
needs: [tests-unit, tests-integration, tests-ui]
|
||||||
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public/cache/
|
|||||||
|
|
||||||
prod.log
|
prod.log
|
||||||
*.log
|
*.log
|
||||||
|
# Imager health-alert state (last-known up/down + timestamp)
|
||||||
|
logs/imager-health.state
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
db_backup_*.sql
|
db_backup_*.sql
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
| Component | Version | Notes |
|
| Component | Version | Notes |
|
||||||
| --------------- | -------------- | ---------------------------------------- |
|
| --------------- | -------------- | ---------------------------------------- |
|
||||||
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
|
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
|
||||||
| pnpm | 12.10.1 | Pinned via `packageManager` |
|
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||||
| npm | >= 11.x | Supported alternative |
|
| npm | >= 11.x | Supported alternative |
|
||||||
| yarn | >= 4.x | Supported alternative |
|
| yarn | >= 4.x | Supported alternative |
|
||||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||||
| Docker | 24+ | Optional — for containerized deployment |
|
| Docker | 24+ | Optional — for containerized deployment |
|
||||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||||
|
|
||||||
Core stack: **Next.js 16.4.0** (App Router) · **React 19.3.0** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.3** · **Zod 4.6.5** · **Vitest 5.0.3** · **Biome 2.5.15** · **Playwright 1.63.0**.
|
Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.
|
||||||
|
|
||||||
> Builds must run through the `pnpm` scripts. This host has no swap and
|
> Builds must run through the `pnpm` scripts. This host has no swap and
|
||||||
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
|
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
|
||||||
@@ -191,7 +191,9 @@ it does not fetch changes to this Git-built CMS. Use the updater for releases.
|
|||||||
A clone does **not** install an automatic scheduler. If desired, explicitly add a
|
A clone does **not** install an automatic scheduler. If desired, explicitly add a
|
||||||
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
|
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
|
||||||
directory. Do not configure both CI and Compose updates for the same instance.
|
directory. Do not configure both CI and Compose updates for the same instance.
|
||||||
The updater refuses an active `epicnext-cms-app` CI-managed container.
|
On a CI-managed host (either slot container running, or the nginx upstream
|
||||||
|
pointing at a blue/green slot) the updater skips with exit 0, so a cron run
|
||||||
|
that legitimately has nothing to do does not look like a failed deployment.
|
||||||
|
|
||||||
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
|
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
|
||||||
failure automatically recreates the CMS with its previous image and verifies its
|
failure automatically recreates the CMS with its previous image and verifies its
|
||||||
@@ -594,7 +596,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
|
|||||||
### Requirements (host)
|
### Requirements (host)
|
||||||
|
|
||||||
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
||||||
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab`, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
||||||
|
|
||||||
### Configuration — `/docker/Polaris-imager/.env`
|
### Configuration — `/docker/Polaris-imager/.env`
|
||||||
|
|
||||||
@@ -849,6 +851,78 @@ Open **DevOps → Anti-DDoS protection**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Captcha (Cloudflare Turnstile)
|
||||||
|
|
||||||
|
The **register**, **login**, **forgot-password** and **resend-verification**
|
||||||
|
forms can be protected by a captcha. **Cloudflare Turnstile** is the supported
|
||||||
|
provider and is free; reCAPTCHA and hCaptcha are also wired up. If you only
|
||||||
|
read one thing, read the precedence table below.
|
||||||
|
|
||||||
|
### Where the captcha is configured (and who wins)
|
||||||
|
|
||||||
|
There are two places, and the **admin panel always wins**:
|
||||||
|
|
||||||
|
1. **Admin panel** — *Admin → CMS Settings → Captcha* (stored in the database).
|
||||||
|
2. **Environment variables** — used **only** as a fallback when the admin panel
|
||||||
|
has no value.
|
||||||
|
|
||||||
|
Resolution order, highest first:
|
||||||
|
|
||||||
|
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `turnstile` | anything | **Turnstile** (panel wins) |
|
||||||
|
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
|
||||||
|
| *empty / never set* | `turnstile` | **Turnstile** (env fallback) |
|
||||||
|
| *empty / never set* | *empty / never set* | **Off** |
|
||||||
|
|
||||||
|
In short: **enable it in the admin panel and the env vars are ignored; leave the
|
||||||
|
panel empty and the env vars take over.** An explicit `none` in the panel turns
|
||||||
|
the captcha **off** even when `CAPTCHA_PROVIDER=turnstile` is set.
|
||||||
|
|
||||||
|
### Option A — configure with environment variables (per deploy)
|
||||||
|
|
||||||
|
1. Create a Turnstile widget in the
|
||||||
|
[Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile):
|
||||||
|
**Add widget**, name it, and add the hostnames your hotel is served on
|
||||||
|
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). *Managed* mode is fine. Copy the
|
||||||
|
**Site Key** and the **Secret Key**.
|
||||||
|
2. Put them in your real `.env` (never commit this file):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CAPTCHA_PROVIDER=turnstile
|
||||||
|
TURNSTILE_SITE_KEY=<your site key> # public — safe to expose
|
||||||
|
TURNSTILE_SECRET_KEY=<your secret key> # SECRET — server-side only
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **site key** is **public**: it is rendered into the page for the
|
||||||
|
browser.
|
||||||
|
- The **secret key** is **secret**: only the server uses it to call
|
||||||
|
Cloudflare's `siteverify` endpoint. **Never commit it** — keep it in the
|
||||||
|
host `.env`, or a **masked CI/CD variable** if you deploy from CI.
|
||||||
|
3. Restart the container so the new env is picked up.
|
||||||
|
|
||||||
|
### Option B — configure in the admin panel
|
||||||
|
|
||||||
|
**Admin → CMS Settings → Captcha**: pick the provider and paste the site key and
|
||||||
|
secret. This overrides the env vars and takes effect **without a restart**.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Open `/register`. When active, the Turnstile widget appears above the submit
|
||||||
|
button.
|
||||||
|
- The e2e suite can assert the widget contract:
|
||||||
|
`E2E_EXPECT_CAPTCHA=turnstile pnpm test:e2e` (spec: `e2e/register.spec.ts`).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Fail-closed**: if the provider is Turnstile but no secret key is available,
|
||||||
|
verification fails and the form is blocked — it never silently lets bots in.
|
||||||
|
- The Content-Security-Policy already allows `https://challenges.cloudflare.com`
|
||||||
|
(`src/lib/csp.ts`), so no header changes are needed.
|
||||||
|
- Turnstile does **not** require the Cloudflare proxy (orange cloud); it works
|
||||||
|
independently of the anti-DDoS layer above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Production Deployment (blue/green)
|
## Production Deployment (blue/green)
|
||||||
|
|
||||||
@@ -898,7 +972,8 @@ that:
|
|||||||
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
|
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
|
||||||
`epicnext-cms-app`, but after a cutover to the green slot that container is
|
`epicnext-cms-app`, but after a cutover to the green slot that container is
|
||||||
stopped and deleted, so the guard stopped firing while the host stayed
|
stopped and deleted, so the guard stopped firing while the host stayed
|
||||||
CI-managed. It now checks both slot containers and the nginx upstream.
|
CI-managed. It now checks both slot containers and the nginx upstream, and
|
||||||
|
reports the skip with exit 0 instead of failing the cron.
|
||||||
- `ci-deploy.sh` retires a compose replica of *this* checkout
|
- `ci-deploy.sh` retires a compose replica of *this* checkout
|
||||||
(`com.docker.compose.project.config_files`) from the candidate port before
|
(`com.docker.compose.project.config_files`) from the candidate port before
|
||||||
starting the candidate — but never a slot container, and never the port nginx
|
starting the candidate — but never a slot container, and never the port nginx
|
||||||
@@ -990,6 +1065,7 @@ fewer than 1 GB would remain, the script refuses to start.
|
|||||||
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
|
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
|
||||||
| `pnpm test:ui` | 8 GB | Playwright |
|
| `pnpm test:ui` | 8 GB | Playwright |
|
||||||
| `pnpm test:e2e` | 8 GB | Playwright |
|
| `pnpm test:e2e` | 8 GB | Playwright |
|
||||||
|
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
|
||||||
| `pnpm test:integration` | 8 GB | Vitest integration config |
|
| `pnpm test:integration` | 8 GB | Vitest integration config |
|
||||||
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
|
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
|
||||||
|
|
||||||
@@ -1057,7 +1133,8 @@ the headroom left for everything else on the machine; the effective ceiling is
|
|||||||
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
|
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
|
||||||
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
|
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
|
||||||
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
|
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
|
||||||
| `pnpm test:e2e` | Playwright end-to-end tests |
|
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
|
||||||
|
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
|
||||||
| `pnpm test:integration` | Integration tests (own Vitest config) |
|
| `pnpm test:integration` | Integration tests (own Vitest config) |
|
||||||
| `pnpm test:housekeeping` | Housekeeping feature tests |
|
| `pnpm test:housekeeping` | Housekeeping feature tests |
|
||||||
| `pnpm lint` | Lint and format check (Biome) |
|
| `pnpm lint` | Lint and format check (Biome) |
|
||||||
@@ -1178,6 +1255,13 @@ pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
|
|||||||
|
|
||||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||||
|
|
||||||
|
### Captcha — Cloudflare Turnstile
|
||||||
|
|
||||||
|
Protect the register, login, forgot-password and resend-verification forms.
|
||||||
|
Set `CAPTCHA_PROVIDER=turnstile` plus the Turnstile keys in `.env`, or configure
|
||||||
|
it in **Admin → CMS Settings**. Full setup, precedence and verification:
|
||||||
|
[Captcha (Cloudflare Turnstile)](#captcha-cloudflare-turnstile).
|
||||||
|
|
||||||
### Byparr (Cloudflare Bypass)
|
### Byparr (Cloudflare Bypass)
|
||||||
|
|
||||||
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
|
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
|
||||||
@@ -1213,6 +1297,25 @@ pnpm analyze # Build and report per-route bundle sizes
|
|||||||
pnpm biome:check # Lint and format
|
pnpm biome:check # Lint and format
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### End-to-end tests
|
||||||
|
|
||||||
|
`pnpm test:e2e` is the **blocking deploy gate**. It only contains checks the CMS
|
||||||
|
can guarantee on its own (anonymous redirects, the register form fields and the
|
||||||
|
captcha DOM contract, cache-control headers), so a green run means a code deploy
|
||||||
|
is safe.
|
||||||
|
|
||||||
|
Checks that depend on **live content or data** — a missing upload or theme asset
|
||||||
|
(`/help` article images), a legacy malformed avatar figure, warm-page latency —
|
||||||
|
live in a separate advisory suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:e2e:quality # playwright.quality.config.ts, never blocks a deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
It is run against the public site as a `continue-on-error` CI job, so it still
|
||||||
|
surfaces a real content problem without turning the pipeline red. Point either
|
||||||
|
suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE `website_daily_reward_claims` (
|
||||||
|
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` int NOT NULL,
|
||||||
|
`claim_date` date NOT NULL,
|
||||||
|
`streak` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`reward_day` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`currency` varchar(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` int NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `website_daily_rewards` (
|
||||||
|
`id` int unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`day` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`currency` varchar(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` int NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
|
||||||
|
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1788791201804,
|
"when": 1788791201804,
|
||||||
"tag": "0000_premium_spirit",
|
"tag": "0000_premium_spirit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791828901011,
|
||||||
|
"tag": "0001_breezy_maestro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError } from "./helpers";
|
||||||
|
|
||||||
|
// Unauthenticated requests to private routes must bounce to the login form —
|
||||||
|
// never render private content and never 500. Two guard paths exist: the edge
|
||||||
|
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
|
||||||
|
// member pages and the mod console redirect from their layouts with a bare
|
||||||
|
// `/login`.
|
||||||
|
|
||||||
|
// Routes where the proxy preserves the requested destination.
|
||||||
|
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
|
||||||
|
// Routes guarded by a page/layout `redirect("/login")` with no destination.
|
||||||
|
const LAYOUT_ROUTES = [
|
||||||
|
"/settings",
|
||||||
|
"/me",
|
||||||
|
"/messages",
|
||||||
|
"/notifications",
|
||||||
|
"/friends",
|
||||||
|
"/redeem",
|
||||||
|
"/mod",
|
||||||
|
"/client",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PROXY_ROUTES) {
|
||||||
|
test(`${route} redirects to login and keeps the return path`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
const from = new URL(page.url()).searchParams.get("from");
|
||||||
|
expect(from, "login lost the return destination").toBeTruthy();
|
||||||
|
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of LAYOUT_ROUTES) {
|
||||||
|
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared assertions for the read-only public e2e suite. These specs run against
|
||||||
|
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
|
||||||
|
* guard the "everything loads, nothing is broken" contract that no unit test
|
||||||
|
* can see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fails if Next rendered a runtime/server error boundary into the page. */
|
||||||
|
export async function expectNoApplicationError(page: Page) {
|
||||||
|
await expect(page.locator("body")).not.toContainText("Application error");
|
||||||
|
await expect(page.locator("body")).not.toContainText("Internal Server Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every <img> that finished loading must have decoded pixels. A failed request
|
||||||
|
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
|
||||||
|
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
|
||||||
|
* that are still pending (`complete === false`) are ignored.
|
||||||
|
*/
|
||||||
|
export async function expectNoBrokenImages(page: Page) {
|
||||||
|
const broken = await page.evaluate(() => {
|
||||||
|
return Array.from(document.images)
|
||||||
|
.filter((img) => {
|
||||||
|
const src = img.currentSrc || img.src;
|
||||||
|
if (!src || src.startsWith("data:")) return false;
|
||||||
|
return img.complete && img.naturalWidth === 0;
|
||||||
|
})
|
||||||
|
.map((img) => img.currentSrc || img.src);
|
||||||
|
});
|
||||||
|
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No horizontal scrollbar — the classic mobile layout regression. */
|
||||||
|
export async function expectNoHorizontalOverflow(page: Page) {
|
||||||
|
const overflowing = await page.evaluate(() => {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
return doc.scrollWidth > window.innerWidth + 1;
|
||||||
|
});
|
||||||
|
expect(overflowing, "page scrolls horizontally").toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
|
||||||
|
* chance to start before the broken-image check runs.
|
||||||
|
*/
|
||||||
|
export async function scrollThroughPage(page: Page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const step = window.innerHeight;
|
||||||
|
for (let y = 0; y < document.body.scrollHeight; y += step) {
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skips a spec when the CMS answered with a maintenance redirect. */
|
||||||
|
export function isMaintenanceRedirect(url: string) {
|
||||||
|
return /\/maintenance(?:$|[/?])/.test(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
|
||||||
|
|
||||||
|
// Endpoints exercised here are identical whether the CMS is reached directly
|
||||||
|
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
|
||||||
|
// in the cross-browser job. Topology-specific checks live in
|
||||||
|
// `cms-imaging.spec.ts` (direct only) and `public-routing.spec.ts` (edge only).
|
||||||
|
|
||||||
|
const IMAGE_SOURCES = new Set([
|
||||||
|
"primary",
|
||||||
|
"fallback",
|
||||||
|
"cache",
|
||||||
|
"cache-stale",
|
||||||
|
"unavailable",
|
||||||
|
]);
|
||||||
|
|
||||||
|
test("renders an animated APNG for a figure with an effect", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
avatarPath({ img_format: "apng", effect: "14" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
// `unavailable` means the placeholder was served, i.e. no real render.
|
||||||
|
expect(response.headers()["x-imager-source"]).not.toBe("unavailable");
|
||||||
|
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.length).toBeGreaterThan(1000);
|
||||||
|
// APNG signature chunks: `acTL` announces the animation, `fcTL` marks frames.
|
||||||
|
expect(body.includes(Buffer.from("acTL"))).toBe(true);
|
||||||
|
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves a static PNG when no animation is requested", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(1, 4).toString("ascii")).toBe("PNG");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises a cacheable response and a known render source", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const source = response.headers()["x-imager-source"];
|
||||||
|
expect(source, "x-imager-source header").toBeTruthy();
|
||||||
|
expect(IMAGE_SOURCES.has(source as string)).toBe(true);
|
||||||
|
|
||||||
|
const cacheControl = response.headers()["cache-control"];
|
||||||
|
expect(cacheControl, "cache-control header").toContain("public");
|
||||||
|
expect(cacheControl).toMatch(/max-age=\d+/);
|
||||||
|
|
||||||
|
// Render sources other than the graceful placeholder are worth caching.
|
||||||
|
if (source !== "unavailable") {
|
||||||
|
expect(cacheControl).not.toContain("no-cache");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("answers conditional requests with 304", async ({ request }) => {
|
||||||
|
const first = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(first.status()).toBe(200);
|
||||||
|
const etag = first.headers()["etag"];
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await request.get(avatarPath({ img_format: "png" }), {
|
||||||
|
headers: { "if-none-match": etag as string },
|
||||||
|
});
|
||||||
|
expect(second.status()).toBe(304);
|
||||||
|
expect(second.headers()["etag"]).toBe(etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves the rendered avatar behind permissive CORS", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preflight requests advertise GET and revalidation", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.fetch(avatarPath(), { method: "OPTIONS" });
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
expect(response.headers()["access-control-allow-headers"]).toContain(
|
||||||
|
"If-None-Match",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("rejects malformed figures", () => {
|
||||||
|
const cases: Array<{ name: string; query: string }> = [
|
||||||
|
{ name: "missing figure", query: "" },
|
||||||
|
{ name: "empty figure", query: "?figure=" },
|
||||||
|
{ name: "path traversal", query: "?figure=../bad" },
|
||||||
|
{ name: "separator injection", query: "?figure=hd-180-1/../x" },
|
||||||
|
{
|
||||||
|
name: "over-long figure",
|
||||||
|
query: `?figure=hd-180-${"x".repeat(600)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many parts",
|
||||||
|
query: `?figure=hd-180${".a-1".repeat(30)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, query } of cases) {
|
||||||
|
test(name, async ({ request }) => {
|
||||||
|
const response = await request.get(`/api/imaging/avatar${query}`);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses a burst of concurrent requests for one figure", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Vary the key so this can hit a cold (or at least distinct) render while a
|
||||||
|
// page-load style burst is in flight. Every response must still be a real
|
||||||
|
// image — single-flight must not turn contention into 5xx/placeholders.
|
||||||
|
const path = avatarPath({
|
||||||
|
img_format: "png",
|
||||||
|
direction: "6",
|
||||||
|
head_direction: "1",
|
||||||
|
});
|
||||||
|
const responses = await Promise.all(
|
||||||
|
Array.from({ length: 6 }, () => request.get(path)),
|
||||||
|
);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge endpoint serves an image and revalidates", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const first = await request.get("/api/imaging/badge?code=ADM");
|
||||||
|
expect(first.status()).toBe(200);
|
||||||
|
expect(first.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(first.headers()["access-control-allow-origin"]).toBe("*");
|
||||||
|
const etag = first.headers()["etag"];
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await request.get("/api/imaging/badge?code=ADM", {
|
||||||
|
headers: { "if-none-match": etag as string },
|
||||||
|
});
|
||||||
|
expect(second.status()).toBe(304);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge endpoint can resolve from a local source", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
"/api/imaging/badge?code=ADM&source=local",
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(response.headers()["x-imager-source"]).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("rejects malformed badge codes", () => {
|
||||||
|
const cases = [
|
||||||
|
{ name: "missing code", query: "" },
|
||||||
|
{ name: "empty code", query: "?code=" },
|
||||||
|
{ name: "path traversal", query: "?code=../x" },
|
||||||
|
{ name: "too long", query: `?code=${"A".repeat(51)}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, query } of cases) {
|
||||||
|
test(name, async ({ request }) => {
|
||||||
|
const response = await request.get(`/api/imaging/badge${query}`);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge preflight advertises GET", async ({ request }) => {
|
||||||
|
const response = await request.fetch("/api/imaging/badge?code=ADM", {
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
|
||||||
|
* the avatar the imager produces and that it actually animates. The endpoint
|
||||||
|
* spec covers the HTTP contract; this one covers the client-side decode, where
|
||||||
|
* an unsupported image format would surface as a blank/broken avatar.
|
||||||
|
*
|
||||||
|
* Navigates straight to the image URL (a top-level navigation, which CSP
|
||||||
|
* `upgrade-insecure-requests` does not rewrite) rather than embedding it in a
|
||||||
|
* page: over a plain-HTTP host the upgrade directive would otherwise send the
|
||||||
|
* subresource request to https and break the load, without exercising anything
|
||||||
|
* about the image itself.
|
||||||
|
*
|
||||||
|
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
|
||||||
|
*/
|
||||||
|
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
|
||||||
|
const STATIC = avatarPath({ img_format: "png" });
|
||||||
|
|
||||||
|
test("the avatar decodes and animates in the browser", async ({ page }) => {
|
||||||
|
const response = await page.goto(ANIMATED);
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const image = page.locator("img");
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
const decoded = await image.evaluate((element: HTMLImageElement) => ({
|
||||||
|
width: element.naturalWidth,
|
||||||
|
height: element.naturalHeight,
|
||||||
|
}));
|
||||||
|
expect(decoded.width).toBeGreaterThan(0);
|
||||||
|
expect(decoded.height).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// An animated APNG keeps producing new frames; a static/undecodable one
|
||||||
|
// yields the same pixels every time. Sampling screenshots over a second is
|
||||||
|
// enough to tell them apart without depending on exact frame timing.
|
||||||
|
const frames = new Set<string>();
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
frames.add((await image.screenshot()).toString("base64"));
|
||||||
|
await page.waitForTimeout(180);
|
||||||
|
}
|
||||||
|
expect(frames.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the static render matches its committed visual baseline", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Pins the exact pixels of a non-animated render so a renderer/asset drift
|
||||||
|
// (wrong palette, missing part, changed sizing) is caught even when the
|
||||||
|
// image still decodes. Baselines are per engine and committed under
|
||||||
|
// e2e/imaging/__screenshots__/.
|
||||||
|
const response = await page.goto(STATIC);
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
|
||||||
|
|
||||||
|
// These assertions only hold when the CMS itself answers `/imaging`. In
|
||||||
|
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
|
||||||
|
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
|
||||||
|
test.skip(
|
||||||
|
IS_EDGE,
|
||||||
|
"The CMS /imaging route is shadowed by the edge proxy in production",
|
||||||
|
);
|
||||||
|
|
||||||
|
const imagerPath = (params: Record<string, string> = {}) =>
|
||||||
|
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
|
||||||
|
|
||||||
|
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
|
||||||
|
|
||||||
|
test("serves the default render as a PNG", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath());
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
|
||||||
|
expect(looksLikeImage(body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcodes to WebP on request", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath({ format: "webp" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/webp");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||||
|
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcodes to AVIF on request", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath({ format: "avif" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/avif");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves animated APNG renders untranscoded", async ({ request }) => {
|
||||||
|
// The renderer emits effected avatars as APNG; libvips here reads only the
|
||||||
|
// first frame, so the animated original must be served as-is even when a
|
||||||
|
// transcode is requested — otherwise the avatar would stop animating.
|
||||||
|
const response = await request.get(
|
||||||
|
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.includes(Buffer.from("acTL"))).toBe(true);
|
||||||
|
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises long-lived edge caching", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath());
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl).toContain("public");
|
||||||
|
expect(cacheControl).toContain("s-maxage");
|
||||||
|
expect(cacheControl).toContain("stale-while-revalidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a malformed figure with 400", async ({ request }) => {
|
||||||
|
const response = await request.get(
|
||||||
|
`/imaging?figure=${encodeURIComponent("../bad")}`,
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the imager preflight advertises GET", async ({ request }) => {
|
||||||
|
const response = await request.fetch("/imaging", { method: "OPTIONS" });
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Where the imaging specs are pointed.
|
||||||
|
*
|
||||||
|
* - Deploy gate (`playwright.config.ts`): hits the CMS directly, so the CMS's
|
||||||
|
* own `/imaging` route answers and its transcoding variants are reachable.
|
||||||
|
* - Cross-browser job (`playwright.imaging.config.ts` against the public
|
||||||
|
* origin): the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
|
||||||
|
* imager container, so the CMS route is shadowed. Only the endpoints that are
|
||||||
|
* identical in both topologies may be asserted unconditionally.
|
||||||
|
*
|
||||||
|
* Set `IMAGING_EDGE=1` when the base URL is behind the edge proxy.
|
||||||
|
*/
|
||||||
|
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
|
||||||
|
|
||||||
|
// The exact figure that regressed in production: the game client appends a
|
||||||
|
// trailing `.<n>` segment that the renderer ignores, but the previous
|
||||||
|
// validation regex rejected with a 400.
|
||||||
|
export const FIGURE =
|
||||||
|
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
|
||||||
|
|
||||||
|
export const avatarPath = (params: Record<string, string> = {}) =>
|
||||||
|
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||||
|
|
||||||
|
/** Public imager endpoint served through the edge proxy (imager container). */
|
||||||
|
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
|
||||||
|
`/imaging/avatarimage?${new URLSearchParams({ figure: FIGURE, ...params })}`;
|
||||||
|
|
||||||
|
/** Counts non-overlapping occurrences of `needle` inside `haystack`. */
|
||||||
|
export function countBytes(haystack: Buffer, needle: Buffer): number {
|
||||||
|
let count = 0;
|
||||||
|
for (
|
||||||
|
let index = haystack.indexOf(needle);
|
||||||
|
index !== -1;
|
||||||
|
index = haystack.indexOf(needle, index + needle.length)
|
||||||
|
) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG/JPEG/GIF/WebP/AVIF signatures, so a test can prove a body is an image. */
|
||||||
|
export function looksLikeImage(body: Buffer): boolean {
|
||||||
|
if (body.length < 12) return false;
|
||||||
|
if (body.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")))
|
||||||
|
return true; // PNG / APNG
|
||||||
|
if (body.subarray(0, 3).toString("ascii") === "GIF") return true;
|
||||||
|
if (body.subarray(0, 2).toString("hex") === "ffd8") return true; // JPEG
|
||||||
|
if (body.subarray(0, 4).toString("ascii") === "RIFF") return true; // WebP
|
||||||
|
if (body.subarray(4, 12).toString("ascii") === "ftypavif") return true; // AVIF
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
|
||||||
|
// correctness; this one catches a regression that keeps the image correct but
|
||||||
|
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
|
||||||
|
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
|
||||||
|
// flag a real regression, not a cold cache or a slow CI network.
|
||||||
|
const STATIC_MAX_BYTES = 64 * 1024;
|
||||||
|
const ANIMATED_MAX_BYTES = 512 * 1024;
|
||||||
|
const WARM_MAX_MS = 3000;
|
||||||
|
|
||||||
|
test("the static avatar stays inside its byte budget", async ({ request }) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the animated avatar stays inside its byte budget", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
avatarPath({ img_format: "apng", effect: "14" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(
|
||||||
|
ANIMATED_MAX_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a warm avatar request is served quickly", async ({ request }) => {
|
||||||
|
const path = avatarPath({ img_format: "png" });
|
||||||
|
await request.get(path); // warm the disk and edge caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(path);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(elapsed).toBeLessThan(WARM_MAX_MS);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
avatarPath,
|
||||||
|
IMAGER_PATH,
|
||||||
|
IS_EDGE,
|
||||||
|
looksLikeImage,
|
||||||
|
} from "./imaging-target";
|
||||||
|
|
||||||
|
// Regression guard for the public topology: the edge proxy must front the
|
||||||
|
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
|
||||||
|
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
|
||||||
|
// Only meaningful against the public origin, so it is skipped when the CMS is
|
||||||
|
// reached directly (set `IMAGING_EDGE=1` for the public origin).
|
||||||
|
test.skip(
|
||||||
|
!IS_EDGE,
|
||||||
|
"Public routing is only asserted against the edge-fronted origin",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||||
|
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
|
||||||
|
const response = await request.get("/api/imaging/badge?code=ADM");
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Caching contract for the anonymous pages. This is deterministic and stays in
|
||||||
|
// the blocking deploy gate: the perf work relies on the edge/shared cache
|
||||||
|
// (`src/proxy.ts`) — public content pages advertise a short `s-maxage`, while
|
||||||
|
// session-dependent pages (login, register, settings) must stay
|
||||||
|
// `private, no-store`. Timing budgets are advisory and live in
|
||||||
|
// `e2e/quality/performance.spec.ts`.
|
||||||
|
test("anonymous content pages advertise a shared cache", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/news", "/staff", "/shop"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session-dependent pages stay private and uncached", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/login", "/register"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
|
||||||
|
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
|
||||||
|
"s-maxage",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoApplicationError,
|
||||||
|
expectNoBrokenImages,
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
isMaintenanceRedirect,
|
||||||
|
scrollThroughPage,
|
||||||
|
} from "../helpers";
|
||||||
|
|
||||||
|
// ADVISORY suite — run with `pnpm test:e2e:quality`, not part of the deploy
|
||||||
|
// gate. These checks depend on live content/upload state and user data (a
|
||||||
|
// missing upload, a legacy malformed avatar figure, a bad theme asset), so
|
||||||
|
// they are informational: they tell you the site has a real content problem,
|
||||||
|
// but they must never block a code deploy. Every assertion the CMS can
|
||||||
|
// guarantee on its own lives in the blocking specs instead.
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
"/",
|
||||||
|
"/news",
|
||||||
|
"/staff",
|
||||||
|
"/shop",
|
||||||
|
"/search",
|
||||||
|
"/leaderboard",
|
||||||
|
"/help",
|
||||||
|
"/radio",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PUBLIC_ROUTES) {
|
||||||
|
test(`${route} loads every image and does not overflow`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the register page loads its theme/avatar images", async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const response = await page.goto("/");
|
||||||
|
expect(response?.ok()).toBe(true);
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// ADVISORY suite — run with `pnpm test:e2e:quality`. Warm-page latency depends
|
||||||
|
// on the host, the edge and the shared data caches, so a slow CI hop can trip
|
||||||
|
// these budgets without anything being wrong. The cache-control *contract*
|
||||||
|
// (deterministic) lives in the blocking `e2e/performance.spec.ts`; only the
|
||||||
|
// timing budgets are advisory.
|
||||||
|
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
|
||||||
|
|
||||||
|
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
|
||||||
|
|
||||||
|
for (const route of WARM_ROUTES) {
|
||||||
|
test(`${route} responds within the warm budget`, async ({ request }) => {
|
||||||
|
await request.get(route); // warm the render + data caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(route);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(
|
||||||
|
elapsed,
|
||||||
|
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
|
||||||
|
).toBeLessThan(BUDGET_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError, isMaintenanceRedirect } from "./helpers";
|
||||||
|
|
||||||
|
// The register form is the gate for every new player, so it gets a deeper
|
||||||
|
// read-only pass than the generic public-page spec. It also pins the captcha
|
||||||
|
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
|
||||||
|
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
|
||||||
|
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
|
||||||
|
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
|
||||||
|
|
||||||
|
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
|
||||||
|
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
|
||||||
|
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
|
||||||
|
|
||||||
|
test.describe("register", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders every field a new player has to fill in", async ({ page }) => {
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="mail"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]').first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('input[name="password_confirmation"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="terms"]')).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes exactly one captcha widget with a site key", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const widgets = page.locator(CAPTCHA_SELECTOR);
|
||||||
|
|
||||||
|
if (EXPECT === "none") {
|
||||||
|
await expect(widgets).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EXPECT === "turnstile") {
|
||||||
|
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await widgets.count();
|
||||||
|
expect(
|
||||||
|
count,
|
||||||
|
`expected at most one captcha provider, found ${count}`,
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
|
||||||
|
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
|
||||||
|
expect(siteKey?.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
|
||||||
|
const script = page.locator(
|
||||||
|
'script[src*="challenges.cloudflare.com/turnstile"]',
|
||||||
|
);
|
||||||
|
await expect(script).toHaveCount(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
-14
@@ -11,6 +11,8 @@
|
|||||||
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
|
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||||
|
"readme:versions": "node scripts/sync-readme-versions.mjs",
|
||||||
|
"readme:versions:check": "node scripts/sync-readme-versions.mjs --check",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"biome:lint": "biome check .",
|
"biome:lint": "biome check .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
@@ -40,6 +42,9 @@
|
|||||||
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
|
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
|
||||||
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
|
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
|
||||||
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
|
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||||
|
"test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts",
|
||||||
|
"test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots",
|
||||||
|
"test:e2e:quality": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.quality.config.ts",
|
||||||
"performance:report": "node scripts/performance-report.mjs",
|
"performance:report": "node scripts/performance-report.mjs",
|
||||||
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
|
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
|
||||||
},
|
},
|
||||||
@@ -50,8 +55,8 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.104.2",
|
"@tanstack/react-query": "5.104.3",
|
||||||
"@tanstack/react-virtual": "3.14.17",
|
"@tanstack/react-virtual": "3.14.19",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
@@ -63,32 +68,32 @@
|
|||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.2",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.55.0",
|
"lucide-react": "1.57.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "14.1.0",
|
"motion": "14.2.0",
|
||||||
"music-metadata": "12.1.1",
|
"music-metadata": "12.2.1",
|
||||||
"mysql2": "3.24.5",
|
"mysql2": "3.24.6",
|
||||||
"next": "16.4.0",
|
"next": "16.4.1",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.9",
|
"next-intl": "4.14.9",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.1",
|
||||||
"react-hook-form": "7.90.0",
|
"react-hook-form": "7.90.0",
|
||||||
"resend": "6.32.1",
|
"resend": "6.33.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.7.0",
|
"tailwind-merge": "3.7.0",
|
||||||
"tinymce": "8.9.3",
|
"tinymce": "8.10.0",
|
||||||
"zod": "4.6.7"
|
"zod": "4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/parser": "8.0.7",
|
"@babel/parser": "8.0.7",
|
||||||
"@biomejs/biome": "2.5.15",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "1.64.0",
|
"@playwright/test": "1.64.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
@@ -100,9 +105,9 @@
|
|||||||
"esbuild": "0.28.2",
|
"esbuild": "0.28.2",
|
||||||
"msw": "^3.0.3",
|
"msw": "^3.0.3",
|
||||||
"pino-pretty": "13.2.0",
|
"pino-pretty": "13.2.0",
|
||||||
"postcss": "8.5.29",
|
"postcss": "8.5.30",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"testcontainers": "12.2.0",
|
"testcontainers": "12.2.1",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.3.4",
|
"vite": "8.3.4",
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
testIgnore: ["**/ui/**", "**/news-real/**"],
|
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 1,
|
retries: 1,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
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",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Cross-browser imaging checks. Unlike the main e2e config (chromium only,
|
||||||
|
// used as the deploy gate), this runs the same specs across every bundled
|
||||||
|
// engine so an avatar format that only one browser can decode is caught.
|
||||||
|
//
|
||||||
|
// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local
|
||||||
|
// host port). Requires all three engines: `pnpm exec playwright install
|
||||||
|
// chromium firefox webkit`.
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/imaging",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
expect: {
|
||||||
|
timeout: 15000,
|
||||||
|
toHaveScreenshot: {
|
||||||
|
animations: "disabled",
|
||||||
|
caret: "hide",
|
||||||
|
maxDiffPixelRatio: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/imaging", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/imaging",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||||
|
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Advisory quality suite. Unlike the main e2e config (the blocking deploy
|
||||||
|
// gate), this checks things that depend on live content and data — broken
|
||||||
|
// uploads, legacy malformed avatar figures, warm-page latency. Those are real
|
||||||
|
// signals but they are not something a code change can guarantee, so they must
|
||||||
|
// never block a deploy. Point it at a running CMS with PLAYWRIGHT_BASE_URL
|
||||||
|
// (defaults to the local host port).
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/quality",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
expect: { timeout: 15000 },
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/quality", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/quality",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
Generated
+604
-624
File diff suppressed because it is too large.
Load diff
@@ -17,3 +17,8 @@ overrides:
|
|||||||
'@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
|
||||||
|
# @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
|
||||||
|
# v7, so force the patched major.
|
||||||
|
postcss-selector-parser: '^7.1.6'
|
||||||
@@ -20,6 +20,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
|||||||
| `perf-report.sh` | Runs performance profiling. |
|
| `perf-report.sh` | Runs performance profiling. |
|
||||||
| `setup-dev.sh` | Configures development environment. |
|
| `setup-dev.sh` | Configures development environment. |
|
||||||
| `check-updates.sh` | Checks for package and image updates. |
|
| `check-updates.sh` | Checks for package and image updates. |
|
||||||
|
| `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. |
|
||||||
| `maintenance.sh` | Toggles maintenance mode flag. |
|
| `maintenance.sh` | Toggles maintenance mode flag. |
|
||||||
| `doctor.sh` | Runs a system diagnostic report. |
|
| `doctor.sh` | Runs a system diagnostic report. |
|
||||||
| `setup-cron.sh` | Configures automated cron jobs. |
|
| `setup-cron.sh` | Configures automated cron jobs. |
|
||||||
@@ -30,3 +31,18 @@ Run any script directly, or use the dashboard:
|
|||||||
```bash
|
```bash
|
||||||
./scripts/dashboard.sh
|
./scripts/dashboard.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Keeping the README versions in sync
|
||||||
|
|
||||||
|
Bumping a dependency (or the pinned Node.js version) leaves `README.md` stale.
|
||||||
|
Rather than editing it by hand, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm readme:versions # rewrite README.md to match package.json / .nvmrc
|
||||||
|
pnpm readme:versions:check # verify only; exits 1 when out of date (used in CI)
|
||||||
|
```
|
||||||
|
|
||||||
|
It manages the `pnpm` and `Node.js` rows of the System Requirements table and
|
||||||
|
the "Core stack" line. The CI `check` job runs the `--check` variant, so a
|
||||||
|
version change cannot reach `main` with a stale README — the pipeline fails
|
||||||
|
until you run `pnpm readme:versions`.
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||||
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx
|
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
|
||||||
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`).
|
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
|
||||||
|
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
|
||||||
|
# stuurt).
|
||||||
#
|
#
|
||||||
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
||||||
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||||
@@ -35,12 +37,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
BASE="https://api.cloudflare.com/client/v4"
|
BASE="https://api.cloudflare.com/client/v4"
|
||||||
PHASE="http_request_cache_settings"
|
PHASE="http_request_cache_settings"
|
||||||
DESCRIPTION="EpicNabbo CMS public API + gamedata edge cache (cms-public, cms-gamedata)"
|
DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
|
||||||
|
|
||||||
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
||||||
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||||
# `starts_with()`.
|
# `starts_with()`.
|
||||||
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
#
|
||||||
|
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
|
||||||
|
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
|
||||||
|
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
|
||||||
|
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
|
||||||
|
# tussen gebruikers.
|
||||||
|
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
||||||
|
|
||||||
load_env() {
|
load_env() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
|
|||||||
+21
-1
@@ -410,7 +410,10 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm exec playwright install chromium
|
# All three engines are needed: the chromium gate below plus the cross-browser
|
||||||
|
# imaging gate just before cutover. Idempotent — already-installed engines in
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
|
||||||
|
pnpm exec playwright install chromium firefox webkit
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
# 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
|
||||||
@@ -536,6 +539,18 @@ if [ "$blue_green" -eq 1 ]; then
|
|||||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||||
|
|
||||||
|
# 4b. Cross-browser imaging gate. The chromium suite above proves the
|
||||||
|
# candidate serves the right bytes; this proves every bundled engine can
|
||||||
|
# actually decode the render. The webkit path is the one that historically
|
||||||
|
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
|
||||||
|
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
|
||||||
|
# a release out in an emergency.
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||||
cutover_started=1
|
cutover_started=1
|
||||||
switch_upstream "$new_port"
|
switch_upstream "$new_port"
|
||||||
@@ -569,6 +584,11 @@ else
|
|||||||
healthy 3002
|
healthy 3002
|
||||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
|
||||||
|
fi
|
||||||
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
||||||
fi
|
fi
|
||||||
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
|||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
die() { log "ERROR: $*"; exit 1; }
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
# A run that legitimately has nothing to do (the host is owned by CI) must not
|
||||||
|
# look like a failure to cron. `die` stays reserved for real errors.
|
||||||
|
skip() { log "SKIP: $*"; exit 0; }
|
||||||
migration_image=""
|
migration_image=""
|
||||||
remote_migration_image=""
|
remote_migration_image=""
|
||||||
previous_image=""
|
previous_image=""
|
||||||
@@ -72,11 +75,11 @@ trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were dele
|
|||||||
# the only thing that still marks the host as blue/green when a slot is idle.
|
# the only thing that still marks the host as blue/green when a slot is idle.
|
||||||
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||||
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
||||||
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
||||||
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
||||||
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||||
|
|||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Alert when the local avatar imager stops rendering, and again when it recovers.
|
||||||
|
#
|
||||||
|
# Intended to run from cron every few minutes (see `crontab -l`). Steady-state is
|
||||||
|
# silent; a state change posts to DISCORD_WEBHOOK_URL (from the environment or
|
||||||
|
# .env). While the imager stays down it re-alerts at most once per
|
||||||
|
# HEALTH_ALERT_COOLDOWN_MIN. The script always exits 0 so an outage never turns
|
||||||
|
# into a noisy cron failure; the alert itself (and this log) is the signal.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOG_FILE="${IMAGER_HEALTH_LOG:-$DIR/logs/imager-health.log}"
|
||||||
|
STATE_FILE="${IMAGER_HEALTH_STATE:-$DIR/logs/imager-health.state}"
|
||||||
|
HEALTH_URL="${IMAGER_HEALTH_URL:-http://127.0.0.1:8082/health}"
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")"
|
||||||
|
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "${!name:-}" ]; then return 0; fi
|
||||||
|
if [ -f "$DIR/.env" ]; then
|
||||||
|
local line
|
||||||
|
line="$(grep -m1 "^$name=" "$DIR/.env" | cut -d= -f2- | tr -d "'\"")" || true
|
||||||
|
if [ -n "$line" ]; then printf -v "$name" '%s' "$line"; fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env DISCORD_WEBHOOK_URL || true
|
||||||
|
COOLDOWN_MIN="${HEALTH_ALERT_COOLDOWN_MIN:-30}"
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
local msg="$1"
|
||||||
|
log "ALERT: $msg"
|
||||||
|
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||||
|
log "no DISCORD_WEBHOOK_URL configured; alert written to log only"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local payload
|
||||||
|
payload="$(python3 -c 'import json,sys;print(json.dumps({"content":sys.argv[1]}))' "$msg")"
|
||||||
|
curl -sS -m 10 -H "Content-Type: application/json" -d "$payload" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" >/dev/null 2>&1 || log "failed to post alert to Discord"
|
||||||
|
}
|
||||||
|
|
||||||
|
body="$(curl -sS -m 5 "$HEALTH_URL" 2>/dev/null || true)"
|
||||||
|
ready=0
|
||||||
|
if printf '%s' "$body" | grep -q '"ready":true'; then ready=1; fi
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
prev_state="up"
|
||||||
|
prev_at=0
|
||||||
|
if [ -f "$STATE_FILE" ]; then read -r prev_state prev_at < "$STATE_FILE" || true; fi
|
||||||
|
[ -n "${prev_state:-}" ] || prev_state=up
|
||||||
|
[ -n "${prev_at:-}" ] || prev_at=0
|
||||||
|
|
||||||
|
if [ "$ready" = 1 ]; then
|
||||||
|
if [ "$prev_state" != "up" ]; then
|
||||||
|
notify "Avatar imager RECOVERED ($HEALTH_URL reports ready)"
|
||||||
|
fi
|
||||||
|
printf 'up %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$prev_state" != "down" ]; then
|
||||||
|
notify "Avatar imager DOWN ($HEALTH_URL not ready: ${body:-unreachable})"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(( now - prev_at ))" -ge "$(( COOLDOWN_MIN * 60 ))" ]; then
|
||||||
|
notify "Avatar imager STILL DOWN after ${COOLDOWN_MIN}m ($HEALTH_URL)"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse the version facts the README advertises into a single object, read
|
||||||
|
* from the source of truth (`package.json` + `.nvmrc`). Kept as a pure function
|
||||||
|
* so the test suite can pin the extraction without touching the filesystem.
|
||||||
|
*/
|
||||||
|
export function collectVersions(pkg, nvmrc) {
|
||||||
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||||
|
const require = (name) => {
|
||||||
|
const value = deps[name];
|
||||||
|
if (typeof value !== "string" || !/^\d+\.\d+/.test(value))
|
||||||
|
throw new Error(`package.json is missing a concrete ${name} version`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pnpm = /^pnpm@([^+]+)/.exec(pkg.packageManager ?? "")?.[1];
|
||||||
|
if (!pnpm) throw new Error("package.json packageManager must pin pnpm");
|
||||||
|
|
||||||
|
const node = nvmrc.trim();
|
||||||
|
if (!/^\d+\.\d+\.\d+$/.test(node))
|
||||||
|
throw new Error(".nvmrc must pin an exact Node.js version");
|
||||||
|
|
||||||
|
return {
|
||||||
|
pnpm,
|
||||||
|
node,
|
||||||
|
next: require("next"),
|
||||||
|
react: require("react"),
|
||||||
|
typescript: require("typescript"),
|
||||||
|
drizzleOrm: require("drizzle-orm"),
|
||||||
|
zod: require("zod"),
|
||||||
|
vitest: require("vitest"),
|
||||||
|
biome: require("@biomejs/biome"),
|
||||||
|
playwright: require("@playwright/test"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single "Core stack" line, built from the live versions. */
|
||||||
|
export function coreStackLine(v) {
|
||||||
|
return `Core stack: **Next.js ${v.next}** (App Router) · **React ${v.react}** · **TypeScript ${v.typescript}** · **Drizzle ORM ${v.drizzleOrm}** · **Zod ${v.zod}** · **Vitest ${v.vitest}** · **Biome ${v.biome}** · **Playwright ${v.playwright}**.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE_STACK_RE = /^Core stack: \*\*Next\.js .*$/m;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every managed version reference in `readme` to match `versions`.
|
||||||
|
* Returns the new text plus a human-readable list of what changed. Throws if a
|
||||||
|
* managed anchor is missing, so a README restructure fails loudly instead of
|
||||||
|
* silently dropping out of sync.
|
||||||
|
*/
|
||||||
|
export function syncReadmeVersions(readme, versions) {
|
||||||
|
const changes = [];
|
||||||
|
let next = readme;
|
||||||
|
|
||||||
|
// `| Node.js | 26.10.0 | … (>=26.10.0 <27) |` — version cell + engines note.
|
||||||
|
next = replaceVersionCell(
|
||||||
|
next,
|
||||||
|
"Node\\.js",
|
||||||
|
versions.node,
|
||||||
|
"Node.js",
|
||||||
|
changes,
|
||||||
|
);
|
||||||
|
const nodeMajor = Number.parseInt(versions.node.split(".")[0], 10);
|
||||||
|
const nodeNote = /(>=)\d+\.\d+\.\d+( <)\d+/;
|
||||||
|
if (!nodeNote.test(next))
|
||||||
|
throw new Error("README is missing the Node.js engines note");
|
||||||
|
next = next.replace(nodeNote, `$1${versions.node}$2${nodeMajor + 1}`);
|
||||||
|
|
||||||
|
// `| pnpm | 12.11.2 | … |`.
|
||||||
|
next = replaceVersionCell(next, "pnpm", versions.pnpm, "pnpm", changes);
|
||||||
|
|
||||||
|
// `Core stack: **Next.js …** · …`.
|
||||||
|
const stackMatch = CORE_STACK_RE.exec(next);
|
||||||
|
if (!stackMatch) throw new Error("README is missing the core stack line");
|
||||||
|
const expectedStack = coreStackLine(versions);
|
||||||
|
if (stackMatch[0] !== expectedStack) {
|
||||||
|
changes.push("core stack line");
|
||||||
|
next = next.replace(CORE_STACK_RE, expectedStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { readme: next, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the version token of a `| name | version | … |` table row, padding
|
||||||
|
* the trailing spaces so the closing pipe stays in the same column when the
|
||||||
|
* new version is a different length.
|
||||||
|
*/
|
||||||
|
function replaceVersionCell(readme, namePattern, version, label, changes) {
|
||||||
|
const re = new RegExp(
|
||||||
|
`^(\\| ${namePattern}(?: +)\\| )(\\S+)( +)(\\|.*)$`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
const match = re.exec(readme);
|
||||||
|
if (!match)
|
||||||
|
throw new Error(`README is missing the ${label} requirements row`);
|
||||||
|
const [, pre, current, pad, post] = match;
|
||||||
|
if (current === version) return readme;
|
||||||
|
changes.push(`${label} ${current} -> ${version}`);
|
||||||
|
const padLength = Math.max(1, pad.length - (version.length - current.length));
|
||||||
|
return readme.replace(re, `${pre}${version}${" ".repeat(padLength)}${post}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const check = process.argv.includes("--check");
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(resolve(projectRoot, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const nvmrc = readFileSync(resolve(projectRoot, ".nvmrc"), "utf8");
|
||||||
|
const readmePath = resolve(projectRoot, "README.md");
|
||||||
|
const readme = readFileSync(readmePath, "utf8");
|
||||||
|
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(
|
||||||
|
readme,
|
||||||
|
collectVersions(pkg, nvmrc),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
console.log("README versions are in sync with package.json / .nvmrc");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
console.error("README versions are out of date:");
|
||||||
|
for (const change of changes) console.error(` - ${change}`);
|
||||||
|
console.error("Run `pnpm readme:versions` to fix.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(readmePath, updated);
|
||||||
|
console.log("README versions updated:");
|
||||||
|
for (const change of changes) console.log(` - ${change}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
collectVersions,
|
||||||
|
coreStackLine,
|
||||||
|
syncReadmeVersions,
|
||||||
|
} from "./sync-readme-versions.mjs";
|
||||||
|
|
||||||
|
const versions = {
|
||||||
|
pnpm: "12.11.2",
|
||||||
|
node: "26.10.0",
|
||||||
|
next: "16.4.1",
|
||||||
|
react: "19.3.1",
|
||||||
|
typescript: "7.0.2",
|
||||||
|
drizzleOrm: "0.45.4",
|
||||||
|
zod: "4.6.7",
|
||||||
|
vitest: "5.0.4",
|
||||||
|
biome: "2.5.15",
|
||||||
|
playwright: "1.64.1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pkg = {
|
||||||
|
packageManager: `pnpm@${versions.pnpm}+sha512.deadbeef`,
|
||||||
|
dependencies: {
|
||||||
|
next: versions.next,
|
||||||
|
react: versions.react,
|
||||||
|
zod: versions.zod,
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
typescript: versions.typescript,
|
||||||
|
"drizzle-orm": versions.drizzleOrm,
|
||||||
|
vitest: versions.vitest,
|
||||||
|
"@biomejs/biome": versions.biome,
|
||||||
|
"@playwright/test": versions.playwright,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const readme = [
|
||||||
|
"| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |",
|
||||||
|
"| pnpm | 12.11.2 | Pinned via `packageManager` |",
|
||||||
|
"",
|
||||||
|
coreStackLine(versions),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("collectVersions", () => {
|
||||||
|
it("pulls every managed version from package.json and .nvmrc", () => {
|
||||||
|
expect(collectVersions(pkg, `${versions.node}\n`)).toEqual(versions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a packageManager without a pinned pnpm", () => {
|
||||||
|
expect(() =>
|
||||||
|
collectVersions({ ...pkg, packageManager: "npm@10" }, "26.10.0"),
|
||||||
|
).toThrow(/pnpm/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ranged dependency instead of a pinned one", () => {
|
||||||
|
const ranged = {
|
||||||
|
...pkg,
|
||||||
|
dependencies: { ...pkg.dependencies, next: "^16" },
|
||||||
|
};
|
||||||
|
expect(() => collectVersions(ranged, "26.10.0")).toThrow(/next/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncReadmeVersions", () => {
|
||||||
|
it("reports no changes when the README already matches", () => {
|
||||||
|
const result = syncReadmeVersions(readme, versions);
|
||||||
|
expect(result.changes).toEqual([]);
|
||||||
|
expect(result.readme).toBe(readme);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the pnpm row and keeps the table aligned", () => {
|
||||||
|
const bumped = { ...versions, pnpm: "13.0.0" };
|
||||||
|
const original = readme.split("\n").find((line) => line.includes("pnpm"));
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(readme, bumped);
|
||||||
|
const line = updated.split("\n").find((l) => l.includes("pnpm"));
|
||||||
|
|
||||||
|
expect(changes).toContain("pnpm 12.11.2 -> 13.0.0");
|
||||||
|
expect(line).toContain("| 13.0.0");
|
||||||
|
expect(line?.length).toBe(original?.length);
|
||||||
|
expect(line?.endsWith("|\n") || line?.endsWith("|")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites the core stack line from the live versions", () => {
|
||||||
|
const bumped = { ...versions, next: "17.0.0", zod: "5.0.0" };
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(readme, bumped);
|
||||||
|
expect(changes).toContain("core stack line");
|
||||||
|
expect(updated).toContain(coreStackLine(bumped));
|
||||||
|
expect(updated).not.toContain("Next.js 16.4.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates both the Node version cell and the engines note", () => {
|
||||||
|
const bumped = { ...versions, node: "27.3.1" };
|
||||||
|
const { readme: updated, changes } = syncReadmeVersions(readme, bumped);
|
||||||
|
expect(changes).toContain("Node.js 26.10.0 -> 27.3.1");
|
||||||
|
expect(updated).toContain("| 27.3.1 ");
|
||||||
|
expect(updated).toContain("`>=27.3.1 <28`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails loudly when a managed anchor is missing", () => {
|
||||||
|
expect(() =>
|
||||||
|
syncReadmeVersions("no table here\nno core stack", versions),
|
||||||
|
).toThrow(/requirements row/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("coreStackLine", () => {
|
||||||
|
it("renders the documented ordering", () => {
|
||||||
|
expect(coreStackLine(versions)).toBe(
|
||||||
|
"Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
restoreDeletedCatalogItemsCommand,
|
restoreDeletedCatalogItemsCommand,
|
||||||
} from "@/features/catalog/server/item-deletes";
|
} from "@/features/catalog/server/item-deletes";
|
||||||
import {
|
import {
|
||||||
|
bulkUpdateOffersCommand,
|
||||||
createOfferCommand,
|
createOfferCommand,
|
||||||
moveBcOffersCommand,
|
moveBcOffersCommand,
|
||||||
moveOffersCommand,
|
moveOffersCommand,
|
||||||
@@ -399,6 +400,91 @@ export async function updateCatalogItem({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Save All" for the inline table editor. One transaction, one RCON refresh,
|
||||||
|
* one audit row — instead of a full per-item round-trip that took tens of
|
||||||
|
* seconds on pages with hundreds of edited rows. A failing row rolls back the
|
||||||
|
* whole batch, so the editor never ends up half-saved.
|
||||||
|
*/
|
||||||
|
export async function updateCatalogItemsBulk({
|
||||||
|
updates,
|
||||||
|
catalog = "normal",
|
||||||
|
}: {
|
||||||
|
updates: Array<{ id: number; catalogFields: Record<string, unknown> }>;
|
||||||
|
catalog?: "normal" | "bc";
|
||||||
|
}) {
|
||||||
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
|
if (!Array.isArray(updates) || updates.length === 0) {
|
||||||
|
return { ok: false as const, error: "No items to update" };
|
||||||
|
}
|
||||||
|
if (updates.length > 500) {
|
||||||
|
return { ok: false as const, error: "Max 500 items per bulk update" };
|
||||||
|
}
|
||||||
|
// BC rows go through their own command per item (no price columns, and the
|
||||||
|
// BC command keeps its narrower field set); the loop still saves the
|
||||||
|
// per-item RCON refreshes inside one export lock below.
|
||||||
|
if (catalog === "bc") {
|
||||||
|
const { updateBcItem } = await import("@/actions/catalog-bc");
|
||||||
|
return await withCatalogExport(async () => {
|
||||||
|
let savedCount = 0;
|
||||||
|
let lastError = "";
|
||||||
|
for (const update of updates) {
|
||||||
|
const result = await updateBcItem({
|
||||||
|
id: update.id,
|
||||||
|
...(update.catalogFields as {
|
||||||
|
itemIds?: string;
|
||||||
|
catalogName?: string;
|
||||||
|
orderNumber?: number;
|
||||||
|
extradata?: string;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (result.ok) savedCount++;
|
||||||
|
else lastError = result.error ?? "Unable to update item";
|
||||||
|
}
|
||||||
|
if (savedCount > 0) {
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_items_bulk_update",
|
||||||
|
description: `Updated ${savedCount} BC catalog item(s)`,
|
||||||
|
targetType: "catalog_item_bc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (savedCount === 0 && lastError) {
|
||||||
|
return { ok: false as const, error: lastError };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
data: { savedCount, failedCount: updates.length - savedCount },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await withCatalogExport(async () => {
|
||||||
|
try {
|
||||||
|
await bulkUpdateOffersCommand(updates, staff.id);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to update catalog items",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await sendCatalogUpdate();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_items_bulk_update",
|
||||||
|
description: `Updated ${updates.length} catalog item(s)`,
|
||||||
|
targetType: "catalog_item",
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
data: { savedCount: updates.length, failedCount: 0 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function translateCatalogItems(input: {
|
export async function translateCatalogItems(input: {
|
||||||
/** `id` is items_base.id (not catalog_items.id) */
|
/** `id` is items_base.id (not catalog_items.id) */
|
||||||
items: Array<{ id: number; publicName: string; description?: string }>;
|
items: Array<{ id: number; publicName: string; description?: string }>;
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ describe("register", () => {
|
|||||||
accountCreated: expect.any(Number),
|
accountCreated: expect.any(Number),
|
||||||
ipRegister: "203.0.113.9",
|
ipRegister: "203.0.113.9",
|
||||||
ipCurrent: "203.0.113.9",
|
ipCurrent: "203.0.113.9",
|
||||||
look: "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
|
look: "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
});
|
});
|
||||||
expect(state.invalidateKey).toHaveBeenCalledWith("login:user:Alice_123");
|
expect(state.invalidateKey).toHaveBeenCalledWith("login:user:Alice_123");
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
// The hair part needs a colour slot (`hr-100-1`, not `hr-100-`) or the imager
|
||||||
|
// rejects the whole figure with 400 "Invalid figure format" and every avatar
|
||||||
|
// for the user renders broken.
|
||||||
|
const DEFAULT_LOOK = "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||||
|
|
||||||
export interface RegisterState {
|
export interface RegisterState {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ async function NewsPage({
|
|||||||
fill
|
fill
|
||||||
sizes="(max-width: 1024px) 50vw, 300px"
|
sizes="(max-width: 1024px) 50vw, 300px"
|
||||||
style={{ objectFit: "cover" }}
|
style={{ objectFit: "cover" }}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+24
-13
@@ -170,12 +170,9 @@ function GlassPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getHotelData() {
|
async function getHotelData() {
|
||||||
const [hotelName, logo] = await Promise.all([
|
|
||||||
resolveHotelName(),
|
|
||||||
siteSettings.get("cms_logo", ""),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
hotelName,
|
||||||
|
logo,
|
||||||
online,
|
online,
|
||||||
users,
|
users,
|
||||||
rooms,
|
rooms,
|
||||||
@@ -185,6 +182,8 @@ async function getHotelData() {
|
|||||||
recentUsers,
|
recentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
|
resolveHotelName(),
|
||||||
|
siteSettings.get("cms_logo", ""),
|
||||||
cachedOnlineCount().catch(publicReadFailure("home.online")),
|
cachedOnlineCount().catch(publicReadFailure("home.online")),
|
||||||
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
|
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
|
||||||
publicReadFailure("home.users"),
|
publicReadFailure("home.users"),
|
||||||
@@ -243,11 +242,12 @@ export default async function Home() {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) redirect("/me");
|
if (session?.user?.id) redirect("/me");
|
||||||
|
|
||||||
const tp = await getTranslations("pages.home");
|
const [
|
||||||
const te = await getTranslations("publicData");
|
tp,
|
||||||
const th = await getTranslations("header");
|
te,
|
||||||
const tn = await getTranslations("nav");
|
th,
|
||||||
const {
|
tn,
|
||||||
|
{
|
||||||
hotelName,
|
hotelName,
|
||||||
online,
|
online,
|
||||||
users,
|
users,
|
||||||
@@ -258,7 +258,18 @@ export default async function Home() {
|
|||||||
recentUsers: rawRecentUsers,
|
recentUsers: rawRecentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
logo,
|
logo,
|
||||||
} = await getHotelData();
|
},
|
||||||
|
captcha,
|
||||||
|
nonceHeaders,
|
||||||
|
] = await Promise.all([
|
||||||
|
getTranslations("pages.home"),
|
||||||
|
getTranslations("publicData"),
|
||||||
|
getTranslations("header"),
|
||||||
|
getTranslations("nav"),
|
||||||
|
getHotelData(),
|
||||||
|
captchaConfig(),
|
||||||
|
headers(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Users who hide their online state must not surface in the "who is online"
|
// Users who hide their online state must not surface in the "who is online"
|
||||||
// rails on the homepage either.
|
// rails on the homepage either.
|
||||||
@@ -271,8 +282,7 @@ export default async function Home() {
|
|||||||
: rawRecentUsers.filter(
|
: rawRecentUsers.filter(
|
||||||
(u) => recentUserPrivacy.get(u.id)?.online !== false,
|
(u) => recentUserPrivacy.get(u.id)?.online !== false,
|
||||||
);
|
);
|
||||||
const captcha = await captchaConfig();
|
const nonce = nonceHeaders.get("x-nonce") ?? undefined;
|
||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
@@ -315,6 +325,7 @@ export default async function Home() {
|
|||||||
fill
|
fill
|
||||||
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
|
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
|
||||||
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
|
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
|
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
|
||||||
|
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
|
||||||
import {
|
import {
|
||||||
fetchAvatarImage,
|
fetchAvatarImage,
|
||||||
ImagerUnavailableError,
|
ImagerUnavailableError,
|
||||||
@@ -20,12 +21,16 @@ function cacheControlFor(source: string): string {
|
|||||||
switch (source) {
|
switch (source) {
|
||||||
case "fallback":
|
case "fallback":
|
||||||
case "cache-stale":
|
case "cache-stale":
|
||||||
// Reuse the render briefly but revalidate the upstream soon.
|
// Reuse the render briefly but revalidate the upstream soon. The
|
||||||
return "public, max-age=300";
|
// shared `s-maxage` lets the edge (Cloudflare) hold it for an hour
|
||||||
|
// without pinning a degraded fallback in the browser.
|
||||||
|
return "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600";
|
||||||
case "unavailable":
|
case "unavailable":
|
||||||
return "no-cache, max-age=0";
|
return "no-cache, max-age=0";
|
||||||
default:
|
default:
|
||||||
return "public, max-age=3600";
|
// s-maxage=86400 lets the edge cache a day off the origin, matching
|
||||||
|
// the sibling /imaging route; the URL already keys per figure.
|
||||||
|
return "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,24 +48,52 @@ export async function GET(request: NextRequest) {
|
|||||||
parsed.params,
|
parsed.params,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const etag = strongEtag(body);
|
||||||
|
const cacheControl = cacheControlFor(source);
|
||||||
|
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": cacheControl,
|
||||||
|
"X-Imager-Source": source,
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new NextResponse(body as unknown as BodyInit, {
|
return new NextResponse(body as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
"Cache-Control": cacheControlFor(source),
|
"Cache-Control": cacheControl,
|
||||||
|
ETag: etag,
|
||||||
"X-Imager-Source": source,
|
"X-Imager-Source": source,
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ImagerUnavailableError) {
|
if (error instanceof ImagerUnavailableError) {
|
||||||
|
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
|
||||||
|
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": cacheControlFor("unavailable"),
|
||||||
|
"X-Imager-Source": "unavailable",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
|
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "image/svg+xml",
|
"Content-Type": "image/svg+xml",
|
||||||
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
|
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
|
||||||
"Cache-Control": cacheControlFor("unavailable"),
|
"Cache-Control": cacheControlFor("unavailable"),
|
||||||
|
ETag: etag,
|
||||||
"X-Imager-Source": "unavailable",
|
"X-Imager-Source": "unavailable",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
readImagingCache,
|
readImagingCache,
|
||||||
writeImagingCache,
|
writeImagingCache,
|
||||||
} from "@/lib/imager-cache";
|
} from "@/lib/imager-cache";
|
||||||
|
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
|
||||||
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
|
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -33,6 +34,64 @@ const BADGE_PLACEHOLDER_SVG =
|
|||||||
|
|
||||||
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
|
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
|
||||||
|
|
||||||
|
interface ResolvedBadge {
|
||||||
|
body: Uint8Array;
|
||||||
|
contentType: string;
|
||||||
|
source: BadgeSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrent cold-cache requests for the same badge would otherwise each walk
|
||||||
|
* the whole candidate list, and when the first candidate is a slow/unreachable
|
||||||
|
* CDN that means every one of them waits out the fetch timeout before falling
|
||||||
|
* back. Sharing a single resolution keeps a cold cache from stacking identical
|
||||||
|
* upstream work. Held on globalThis because Next can evaluate this module more
|
||||||
|
* than once per process.
|
||||||
|
*/
|
||||||
|
const globalForBadgeFetches = globalThis as typeof globalThis & {
|
||||||
|
imagingBadgeFetches?: Map<string, Promise<ResolvedBadge>>;
|
||||||
|
};
|
||||||
|
const inFlight =
|
||||||
|
globalForBadgeFetches.imagingBadgeFetches ??
|
||||||
|
new Map<string, Promise<ResolvedBadge>>();
|
||||||
|
globalForBadgeFetches.imagingBadgeFetches = inFlight;
|
||||||
|
|
||||||
|
async function resolveBadge(
|
||||||
|
cacheKey: string,
|
||||||
|
code: string,
|
||||||
|
candidates: string[],
|
||||||
|
): Promise<ResolvedBadge> {
|
||||||
|
const pending = inFlight.get(cacheKey);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const work = (async (): Promise<ResolvedBadge> => {
|
||||||
|
for (const base of candidates) {
|
||||||
|
const result = await fetchCandidate(base, code);
|
||||||
|
if (result) {
|
||||||
|
await writeImagingCache(
|
||||||
|
badgeCacheDir(),
|
||||||
|
cacheKey,
|
||||||
|
result.body,
|
||||||
|
result.contentType,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
|
||||||
|
contentType: "image/svg+xml",
|
||||||
|
source: "unavailable",
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
inFlight.set(cacheKey, work);
|
||||||
|
try {
|
||||||
|
return await work;
|
||||||
|
} finally {
|
||||||
|
if (inFlight.get(cacheKey) === work) inFlight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const code = request.nextUrl.searchParams.get("code")?.trim();
|
const code = request.nextUrl.searchParams.get("code")?.trim();
|
||||||
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
|
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
|
||||||
@@ -56,25 +115,20 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
|
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
|
||||||
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
|
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
|
||||||
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
|
if (cached)
|
||||||
|
|
||||||
for (const base of candidates) {
|
|
||||||
const result = await fetchCandidate(base, code);
|
|
||||||
if (result) {
|
|
||||||
await writeImagingCache(
|
|
||||||
badgeCacheDir(),
|
|
||||||
cacheKey,
|
|
||||||
result.body,
|
|
||||||
result.contentType,
|
|
||||||
);
|
|
||||||
return badgeResponse(result.body, result.contentType, result.source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return badgeResponse(
|
return badgeResponse(
|
||||||
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
|
cached.body,
|
||||||
"image/svg+xml",
|
cached.contentType,
|
||||||
"unavailable",
|
"cache",
|
||||||
|
request.headers.get("if-none-match"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = await resolveBadge(cacheKey, code, candidates);
|
||||||
|
return badgeResponse(
|
||||||
|
resolved.body,
|
||||||
|
resolved.contentType,
|
||||||
|
resolved.source,
|
||||||
|
request.headers.get("if-none-match"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,16 +194,31 @@ function badgeResponse(
|
|||||||
body: Uint8Array,
|
body: Uint8Array,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
source: BadgeSource,
|
source: BadgeSource,
|
||||||
|
ifNoneMatch: string | null = null,
|
||||||
) {
|
) {
|
||||||
|
const etag = strongEtag(body);
|
||||||
|
const cacheControl =
|
||||||
|
source === "unavailable" ? "no-cache, max-age=0" : "public, max-age=3600";
|
||||||
|
// Same bytes the client already has: skip the body. Validators are hashed
|
||||||
|
// from the body, so a fallback→upstream swap still forces a fresh 200.
|
||||||
|
if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": cacheControl,
|
||||||
|
"X-Imager-Source": source,
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return new NextResponse(body as unknown as BodyInit, {
|
return new NextResponse(body as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
"Cache-Control":
|
"Cache-Control": cacheControl,
|
||||||
source === "unavailable"
|
ETag: etag,
|
||||||
? "no-cache, max-age=0"
|
|
||||||
: "public, max-age=3600",
|
|
||||||
"X-Imager-Source": source,
|
"X-Imager-Source": source,
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
|
|||||||
+184
-26
@@ -1,8 +1,47 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
|
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
|
||||||
|
import {
|
||||||
|
avatarCacheDir,
|
||||||
|
imagingCacheKey,
|
||||||
|
readImagingCache,
|
||||||
|
writeImagingCache,
|
||||||
|
} from "@/lib/imager-cache";
|
||||||
|
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
|
||||||
|
import { isAnimatedPng } from "@/lib/imager-png";
|
||||||
|
import {
|
||||||
|
fetchAvatarImage,
|
||||||
|
ImagerUnavailableError,
|
||||||
|
parseAvatarQuery,
|
||||||
|
} from "@/lib/imager-upstream";
|
||||||
|
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
||||||
|
|
||||||
|
// Site-wide default imager endpoint. NOTE ON TOPOLOGY: in the current
|
||||||
|
// production deployment the edge proxy (Traefik) fronts bare `/imaging` and
|
||||||
|
// `/imaging/*` directly to the local pixinode renderer container, so this CMS
|
||||||
|
// route is shadowed publicly and only answers when the CMS is reached directly
|
||||||
|
// (local/dev, self-hosted installs without that proxy rule, or the deploy gate).
|
||||||
|
// That is intentional: it keeps `habbo_imaging_url=/imaging` working without the
|
||||||
|
// CMS in the hot path, and this route remains the correct fallback for any
|
||||||
|
// install where no separate imager is proxied. Its behaviour is covered by
|
||||||
|
// `src/lib/imaging-route.test.ts` and the CMS-only e2e (`e2e/imaging/cms-imaging.spec.ts`).
|
||||||
|
//
|
||||||
|
// It differs from the sibling `/api/imaging/avatar` route in that it also
|
||||||
|
// transcodes PNG renders to WebP/AVIF (`?format=`) and is cached on disk per
|
||||||
|
// (figure, variant).
|
||||||
|
|
||||||
type AvatarFormat = "png" | "webp" | "avif";
|
type AvatarFormat = "png" | "webp" | "avif";
|
||||||
|
type TranscodeFormat = Exclude<AvatarFormat, "png">;
|
||||||
|
|
||||||
|
// Same neutral silhouette the sibling /api/imaging/avatar route serves, so a
|
||||||
|
// figure no upstream can render still shows a graceful image instead of the
|
||||||
|
// browser's broken-image glyph. This is the site-wide default imager endpoint,
|
||||||
|
// so a 502 here would blank avatars across every server-rendered page.
|
||||||
|
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
|
||||||
|
decodeURIComponent(
|
||||||
|
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
function resolveFormat(raw: string | null): AvatarFormat {
|
function resolveFormat(raw: string | null): AvatarFormat {
|
||||||
switch (raw?.toLowerCase()) {
|
switch (raw?.toLowerCase()) {
|
||||||
@@ -15,6 +54,89 @@ function resolveFormat(raw: string | null): AvatarFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrent cold-cache requests for the same figure all arrive with the same
|
||||||
|
* decoded PNG and the same target format, so they would each run the same
|
||||||
|
* sharp transcode — the one operation on this route that is CPU-bound. Sharing
|
||||||
|
* a single transcode across those requests keeps a cold cache from stacking
|
||||||
|
* identical encoder work. Held on globalThis because Next can evaluate this
|
||||||
|
* module more than once per process; separate copies would each start their own
|
||||||
|
* transcode and defeat the point.
|
||||||
|
*/
|
||||||
|
const globalForTranscodes = globalThis as typeof globalThis & {
|
||||||
|
imagingTranscodes?: Map<string, Promise<TranscodeResult | null>>;
|
||||||
|
};
|
||||||
|
const inFlight =
|
||||||
|
globalForTranscodes.imagingTranscodes ??
|
||||||
|
new Map<string, Promise<TranscodeResult | null>>();
|
||||||
|
globalForTranscodes.imagingTranscodes = inFlight;
|
||||||
|
|
||||||
|
interface TranscodeResult {
|
||||||
|
body: Uint8Array;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a WebP/AVIF variant of the decoded render, cached on disk so sharp only
|
||||||
|
* runs once per (figure, variant) rather than on every avatar request. This
|
||||||
|
* endpoint is the default imager, so re-encoding on each hit would burn CPU for
|
||||||
|
* an image that never changes.
|
||||||
|
*
|
||||||
|
* Returns `null` when a conversion cannot be produced (e.g. sharp cannot decode
|
||||||
|
* the input) so the caller falls back to serving the original render unchanged.
|
||||||
|
*/
|
||||||
|
async function transcodeToVariant(
|
||||||
|
body: Uint8Array,
|
||||||
|
format: TranscodeFormat,
|
||||||
|
variantKey: string,
|
||||||
|
): Promise<TranscodeResult | null> {
|
||||||
|
if (isAnimatedPng(body)) return null;
|
||||||
|
|
||||||
|
const cached = await readImagingCache(avatarCacheDir(), variantKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const pending = inFlight.get(variantKey);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const work = runTranscode(body, format, variantKey);
|
||||||
|
inFlight.set(variantKey, work);
|
||||||
|
try {
|
||||||
|
return await work;
|
||||||
|
} finally {
|
||||||
|
if (inFlight.get(variantKey) === work) inFlight.delete(variantKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTranscode(
|
||||||
|
body: Uint8Array,
|
||||||
|
format: TranscodeFormat,
|
||||||
|
variantKey: string,
|
||||||
|
): Promise<TranscodeResult | null> {
|
||||||
|
try {
|
||||||
|
// `animated: true` lets sharp carry animation through for inputs it can
|
||||||
|
// decode frame-by-frame (animated GIF/WebP). APNG is handled before this
|
||||||
|
// point: libvips here reads only its first frame, so those are left as-is.
|
||||||
|
const pipeline = sharp(Buffer.from(body), { animated: true });
|
||||||
|
const out =
|
||||||
|
format === "webp"
|
||||||
|
? await pipeline.webp().toBuffer()
|
||||||
|
: await pipeline.avif().toBuffer();
|
||||||
|
const contentType = format === "webp" ? "image/webp" : "image/avif";
|
||||||
|
const result = { body: new Uint8Array(out), contentType };
|
||||||
|
// Best-effort: a failed write must never turn a good render into an
|
||||||
|
// error; the next request simply re-runs the transcode.
|
||||||
|
await writeImagingCache(
|
||||||
|
avatarCacheDir(),
|
||||||
|
variantKey,
|
||||||
|
result.body,
|
||||||
|
contentType,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
@@ -31,27 +153,42 @@ export async function GET(request: NextRequest) {
|
|||||||
parsed.params,
|
parsed.params,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
|
|
||||||
// Falls back to the original PNG if conversion fails.
|
|
||||||
let respBody: Uint8Array = body;
|
let respBody: Uint8Array = body;
|
||||||
let respContentType = contentType;
|
let respContentType = contentType;
|
||||||
if (format !== "png") {
|
if (format !== "png") {
|
||||||
try {
|
// The variant is keyed off the exact render that produced it (the
|
||||||
if (format === "webp") {
|
// resolved upstream + the normalized query) plus the target format,
|
||||||
respBody = new Uint8Array(
|
// mirroring how the underlying render itself is cached.
|
||||||
await sharp(Buffer.from(body)).webp().toBuffer(),
|
const variantKey = imagingCacheKey(
|
||||||
|
`${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
|
||||||
);
|
);
|
||||||
respContentType = "image/webp";
|
const converted = await transcodeToVariant(body, format, variantKey);
|
||||||
} else {
|
if (converted) {
|
||||||
respBody = new Uint8Array(
|
respBody = converted.body;
|
||||||
await sharp(Buffer.from(body)).avif().toBuffer(),
|
respContentType = converted.contentType;
|
||||||
);
|
|
||||||
respContentType = "image/avif";
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
respBody = body;
|
|
||||||
respContentType = contentType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const etag = strongEtag(respBody);
|
||||||
|
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
||||||
|
// stale-while-revalidate keeps them fresh without blocking requests.
|
||||||
|
// The ?format= param is part of the URL, so variants cache separately.
|
||||||
|
// Fallback renders use a shorter TTL so the configured imager is
|
||||||
|
// retried soon instead of being masked for hours.
|
||||||
|
const cacheControl =
|
||||||
|
source === "fallback"
|
||||||
|
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
|
||||||
|
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
|
||||||
|
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": cacheControl,
|
||||||
|
"X-Imager-Source": source,
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(respBody as unknown as BodyInit, {
|
return new NextResponse(respBody as unknown as BodyInit, {
|
||||||
@@ -59,20 +196,41 @@ export async function GET(request: NextRequest) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": respContentType,
|
"Content-Type": respContentType,
|
||||||
"Content-Length": String(respBody.length),
|
"Content-Length": String(respBody.length),
|
||||||
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
"Cache-Control": cacheControl,
|
||||||
// stale-while-revalidate keeps them fresh without blocking requests.
|
ETag: etag,
|
||||||
// The ?format= param is part of the URL, so variants cache separately.
|
|
||||||
// Fallback renders use a shorter TTL so the configured imager is
|
|
||||||
// retried soon instead of being masked for hours.
|
|
||||||
"Cache-Control":
|
|
||||||
source === "fallback"
|
|
||||||
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
|
|
||||||
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
|
||||||
"X-Imager-Source": source,
|
"X-Imager-Source": source,
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (error instanceof ImagerUnavailableError) {
|
||||||
|
// Every upstream is down and nothing was cached: serve the graceful
|
||||||
|
// silhouette as a 200 so <img> tags never break, and keep it
|
||||||
|
// uncached so a real render is picked up as soon as one recovers.
|
||||||
|
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
|
||||||
|
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: etag,
|
||||||
|
"Cache-Control": "no-cache, max-age=0",
|
||||||
|
"X-Imager-Source": "unavailable",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
|
||||||
|
"Cache-Control": "no-cache, max-age=0",
|
||||||
|
ETag: etag,
|
||||||
|
"X-Imager-Source": "unavailable",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch avatar from upstream" },
|
{ error: "Failed to fetch avatar from upstream" },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import {
|
import {
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createCatalogItem,
|
createCatalogItem,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
reorderCatalogItems,
|
reorderCatalogItems,
|
||||||
restoreDeletedCatalogItems,
|
restoreDeletedCatalogItems,
|
||||||
updateCatalogItem,
|
updateCatalogItem,
|
||||||
|
updateCatalogItemsBulk,
|
||||||
} from "@/actions/catalog-items";
|
} from "@/actions/catalog-items";
|
||||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||||
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
||||||
@@ -115,10 +117,15 @@ export type { BaseItemData, CatalogItemData };
|
|||||||
function SortableRow({
|
function SortableRow({
|
||||||
id,
|
id,
|
||||||
className,
|
className,
|
||||||
|
dataIndex,
|
||||||
|
measureRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Virtualizer index; forwarded as data-index so measureElement can map it. */
|
||||||
|
dataIndex?: number;
|
||||||
|
measureRef?: (el: HTMLTableRowElement | null) => void;
|
||||||
children: (props: {
|
children: (props: {
|
||||||
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
|
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
@@ -133,6 +140,13 @@ function SortableRow({
|
|||||||
} = useSortable({
|
} = useSortable({
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node: HTMLTableRowElement | null) => {
|
||||||
|
setNodeRef(node);
|
||||||
|
measureRef?.(node);
|
||||||
|
},
|
||||||
|
[setNodeRef, measureRef],
|
||||||
|
);
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -144,7 +158,8 @@ function SortableRow({
|
|||||||
} as unknown as React.HTMLAttributes<HTMLDivElement>;
|
} as unknown as React.HTMLAttributes<HTMLDivElement>;
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
ref={setNodeRef as unknown as React.Ref<HTMLTableRowElement>}
|
ref={setRefs}
|
||||||
|
data-index={dataIndex}
|
||||||
style={style}
|
style={style}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
@@ -280,6 +295,23 @@ export function CatalogItemsTable({
|
|||||||
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||||
}, [filteredItems, itemOrder, searchQuery, filters]);
|
}, [filteredItems, itemOrder, searchQuery, filters]);
|
||||||
|
|
||||||
|
// ── Virtualization (table view) ─────────────────────────────────
|
||||||
|
// A page with hundreds of offers used to mount a full <tr> per row, each
|
||||||
|
// with several inputs and icons — the single biggest source of lag in the
|
||||||
|
// catalog editor. Above the threshold only the visible window (plus a small
|
||||||
|
// overscan) is mounted; spacer rows keep the scrollbar honest.
|
||||||
|
const VIRTUALIZE_THRESHOLD = 150;
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isVirtualized = orderedFilteredItems.length > VIRTUALIZE_THRESHOLD;
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: orderedFilteredItems.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 52,
|
||||||
|
overscan: 15,
|
||||||
|
enabled: isVirtualized,
|
||||||
|
});
|
||||||
|
const virtualRows = isVirtualized ? virtualizer.getVirtualItems() : [];
|
||||||
|
|
||||||
// ── Selection ────────────────────────────────────────────────────
|
// ── Selection ────────────────────────────────────────────────────
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
@@ -504,18 +536,12 @@ export function CatalogItemsTable({
|
|||||||
async function handleSaveAllModified() {
|
async function handleSaveAllModified() {
|
||||||
if (modifiedItems.length === 0) return;
|
if (modifiedItems.length === 0) return;
|
||||||
|
|
||||||
run(
|
const updates = modifiedItems.flatMap((item) => {
|
||||||
async () => {
|
|
||||||
let savedCount = 0;
|
|
||||||
let failedCount = 0;
|
|
||||||
|
|
||||||
for (const item of modifiedItems) {
|
|
||||||
const edit = inlineEdits[item.id];
|
const edit = inlineEdits[item.id];
|
||||||
if (!edit) continue;
|
if (!edit) return [];
|
||||||
|
return [
|
||||||
const result = await updateCatalogItem({
|
{
|
||||||
id: item.id,
|
id: item.id,
|
||||||
catalog,
|
|
||||||
catalogFields: {
|
catalogFields: {
|
||||||
catalogName: item.catalogName,
|
catalogName: item.catalogName,
|
||||||
itemIds: item.itemIds,
|
itemIds: item.itemIds,
|
||||||
@@ -531,28 +557,15 @@ export function CatalogItemsTable({
|
|||||||
clubOnly: item.clubOnly,
|
clubOnly: item.clubOnly,
|
||||||
extradata: item.extradata,
|
extradata: item.extradata,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.ok) savedCount++;
|
run(() => updateCatalogItemsBulk({ updates, catalog }), {
|
||||||
else failedCount++;
|
successMessage: `Saved ${updates.length} item(s).`,
|
||||||
}
|
|
||||||
|
|
||||||
if (savedCount > 0) {
|
|
||||||
onRefresh();
|
|
||||||
}
|
|
||||||
if (failedCount > 0) {
|
|
||||||
return {
|
|
||||||
ok: false as const,
|
|
||||||
error: `Saved ${savedCount}, failed ${failedCount}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true as const, data: { savedCount } };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
successMessage: `Saved ${modifiedItems.length} item(s).`,
|
|
||||||
errorMessage: "Failed to save items.",
|
errorMessage: "Failed to save items.",
|
||||||
},
|
onSuccess: () => onRefresh(),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk delete selected ─────────────────────────────────────────
|
// ── Bulk delete selected ─────────────────────────────────────────
|
||||||
@@ -1243,7 +1256,10 @@ export function CatalogItemsTable({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="rounded-md border overflow-auto max-h-[600px]"
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -1278,7 +1294,29 @@ export function CatalogItemsTable({
|
|||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orderedFilteredItems.map((item) => {
|
{isVirtualized && virtualRows.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={10}
|
||||||
|
style={{
|
||||||
|
height: virtualRows[0]?.start ?? 0,
|
||||||
|
padding: 0,
|
||||||
|
border: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{(isVirtualized
|
||||||
|
? virtualRows.map((vr) => ({
|
||||||
|
item: orderedFilteredItems[vr.index],
|
||||||
|
index: vr.index,
|
||||||
|
}))
|
||||||
|
: orderedFilteredItems.map((item, index) => ({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
).map(({ item, index }) => {
|
||||||
|
if (!item) return null;
|
||||||
const pts = getPointsLabel(item.pointsType);
|
const pts = getPointsLabel(item.pointsType);
|
||||||
const edit = inlineEdits[item.id];
|
const edit = inlineEdits[item.id];
|
||||||
const modified = isItemModified(item);
|
const modified = isItemModified(item);
|
||||||
@@ -1289,6 +1327,14 @@ export function CatalogItemsTable({
|
|||||||
<SortableRow
|
<SortableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
|
dataIndex={index}
|
||||||
|
measureRef={
|
||||||
|
isVirtualized
|
||||||
|
? (el) => {
|
||||||
|
if (el) virtualizer.measureElement(el);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
modified &&
|
modified &&
|
||||||
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
|
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
|
||||||
@@ -1561,6 +1607,25 @@ export function CatalogItemsTable({
|
|||||||
</SortableRow>
|
</SortableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{isVirtualized &&
|
||||||
|
virtualRows.length > 0 &&
|
||||||
|
(() => {
|
||||||
|
const last = virtualRows[virtualRows.length - 1];
|
||||||
|
const total = virtualizer.getTotalSize();
|
||||||
|
if (!last || last.end >= total) return null;
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={10}
|
||||||
|
style={{
|
||||||
|
height: total - last.end,
|
||||||
|
padding: 0,
|
||||||
|
border: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ interface ItemsShopPreviewProps {
|
|||||||
onItemClick?: (item: CatalogItemData) => void;
|
onItemClick?: (item: CatalogItemData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview is a visual check, not an editor. Mounting a tile (image, badges,
|
||||||
|
* click handler) per offer made pages with hundreds of items crawl, so the grid
|
||||||
|
* shows a window and states how many are hidden. The table view is the place to
|
||||||
|
* work through the full list.
|
||||||
|
*/
|
||||||
|
const PREVIEW_TILE_LIMIT = 200;
|
||||||
|
|
||||||
export function ItemsShopPreview({
|
export function ItemsShopPreview({
|
||||||
items,
|
items,
|
||||||
furniRevisionMap,
|
furniRevisionMap,
|
||||||
@@ -38,15 +46,23 @@ export function ItemsShopPreview({
|
|||||||
|
|
||||||
const gridClass = getShopGridClass(layout);
|
const gridClass = getShopGridClass(layout);
|
||||||
const tileSize = getShopTileSize(layout);
|
const tileSize = getShopTileSize(layout);
|
||||||
|
const visibleItems =
|
||||||
|
items.length > PREVIEW_TILE_LIMIT
|
||||||
|
? items.slice(0, PREVIEW_TILE_LIMIT)
|
||||||
|
: items;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
||||||
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
|
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
|
||||||
<span className="font-medium">Shop preview · {layout}</span>
|
<span className="font-medium">Shop preview · {layout}</span>
|
||||||
<span>{items.length} items</span>
|
<span>
|
||||||
|
{items.length} items
|
||||||
|
{visibleItems.length !== items.length &&
|
||||||
|
` · showing first ${visibleItems.length}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("grid gap-2", gridClass)}>
|
<div className={cn("grid gap-2", gridClass)}>
|
||||||
{items.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
const firstId = parseFirstItemId(item.itemIds);
|
const firstId = parseFirstItemId(item.itemIds);
|
||||||
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
||||||
|
|
||||||
@@ -62,6 +78,12 @@ export function ItemsShopPreview({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{visibleItems.length !== items.length && (
|
||||||
|
<p className="mt-3 text-center text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Preview capped at {PREVIEW_TILE_LIMIT} items — use the table view for
|
||||||
|
the rest.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+37
@@ -75,6 +75,9 @@ const schema = z
|
|||||||
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||||
// when unset.
|
// when unset.
|
||||||
BADGE_UPLOAD_DIR: z.string().optional(),
|
BADGE_UPLOAD_DIR: z.string().optional(),
|
||||||
|
// Extension local furniture bundles are written with and the client
|
||||||
|
// requests: ".hab" (default) or ".nitro". Accepts the bare form too.
|
||||||
|
FURNI_BUNDLE_EXT: z.string().optional(),
|
||||||
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
|
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
|
||||||
EMULATOR_JAR_PATH: z.string().optional(),
|
EMULATOR_JAR_PATH: z.string().optional(),
|
||||||
EMULATOR_BACKUP_DIR: z.string().optional(),
|
EMULATOR_BACKUP_DIR: z.string().optional(),
|
||||||
@@ -100,6 +103,27 @@ const schema = z
|
|||||||
// Redis — strongly recommended in production (required for multi-instance).
|
// Redis — strongly recommended in production (required for multi-instance).
|
||||||
// Without it, rate limits / shared caches are in-process only.
|
// Without it, rate limits / shared caches are in-process only.
|
||||||
REDIS_URL: z.string().optional(),
|
REDIS_URL: z.string().optional(),
|
||||||
|
// ── Cache TTL tuning (seconds; see src/lib/perf-ttls.ts) ──────────────
|
||||||
|
// Defaults match the previous hard-coded values; set only to tune.
|
||||||
|
CMS_HOME_PAYLOAD_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_NEWS_LIST_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_NEWS_ARCHIVE_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_SEARCH_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_ONLINE_COUNT_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_COUNTERS_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_SITE_SETTINGS_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||||
|
CMS_SITE_SETTINGS_MEMORY_TTL_SEC: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional(),
|
||||||
|
// Boot-time cache warm-up (priming hot keys after each deploy). Set to
|
||||||
|
// "false"/"0" to skip — useful on hosts where the boot DB burst is
|
||||||
|
// undesirable (e.g. tiny containers right after a restore).
|
||||||
|
CMS_WARMUP_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value !== "false" && value !== "0"),
|
||||||
// App-layer anti-DDoS gate (proxy rate limiter). On by default in
|
// App-layer anti-DDoS gate (proxy rate limiter). On by default in
|
||||||
// production; set to "false" or "0" to disable without removing it.
|
// production; set to "false" or "0" to disable without removing it.
|
||||||
ANTI_DDOS_ENABLED: z
|
ANTI_DDOS_ENABLED: z
|
||||||
@@ -148,6 +172,19 @@ const schema = z
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value !== "false" && value !== "0"),
|
.transform((value) => value !== "false" && value !== "0"),
|
||||||
|
// ── CAPTCHA (Turnstile) — env fallback for the DB-backed settings ─────
|
||||||
|
// The admin panel (housekeeping → settings) wins when it has values; these
|
||||||
|
// let a deploy enable Turnstile without touching the DB. An explicit DB
|
||||||
|
// value — including "none" — always overrides the env fallback.
|
||||||
|
CAPTCHA_PROVIDER: z.preprocess(
|
||||||
|
(value) => (value === "" ? undefined : value),
|
||||||
|
z.enum(["none", "turnstile", "recaptcha", "hcaptcha"]).optional(),
|
||||||
|
),
|
||||||
|
// Public site key (rendered in the widget via server → client props).
|
||||||
|
TURNSTILE_SITE_KEY: z.string().optional(),
|
||||||
|
// Server-only secret used against Cloudflare's siteverify endpoint.
|
||||||
|
// Never NEXT_PUBLIC_: verification would become client-trusting.
|
||||||
|
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.NODE_ENV !== "production") return;
|
if (data.NODE_ENV !== "production") return;
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ export type UpdateOfferInput = {
|
|||||||
catalogFields: Record<string, unknown>;
|
catalogFields: Record<string, unknown>;
|
||||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
type OfferRow = { id: number; pageId: number | string; itemIds: string };
|
type OfferRow = {
|
||||||
|
id: number;
|
||||||
|
pageId: number | string;
|
||||||
|
itemIds: string;
|
||||||
|
limitedStack?: number | string;
|
||||||
|
limitedSells?: number | string;
|
||||||
|
};
|
||||||
async function lockedOffers(
|
async function lockedOffers(
|
||||||
tx: Transaction,
|
tx: Transaction,
|
||||||
ids: number[],
|
ids: number[],
|
||||||
@@ -39,7 +45,7 @@ async function lockedOffers(
|
|||||||
): Promise<OfferRow[]> {
|
): Promise<OfferRow[]> {
|
||||||
const offers = rowsFrom<OfferRow>(
|
const offers = rowsFrom<OfferRow>(
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
|
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, limited_stack AS limitedStack, limited_sells AS limitedSells FROM ${offerTable(kind)} WHERE id IN (${sql.join(
|
||||||
[...ids].sort((a, b) => a - b),
|
[...ids].sort((a, b) => a - b),
|
||||||
sql`, `,
|
sql`, `,
|
||||||
)}) ORDER BY id FOR UPDATE`,
|
)}) ORDER BY id FOR UPDATE`,
|
||||||
@@ -193,6 +199,118 @@ export async function updateOfferCommand(
|
|||||||
await recordHistory(tx, "prices", input.id, userId, before);
|
await recordHistory(tx, "prices", input.id, userId, before);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Many offer patches in ONE transaction: the inline table editor's "Save All"
|
||||||
|
* used to issue a transaction + lock + RCON refresh + audit row per item, which
|
||||||
|
* took tens of seconds on a page with hundreds of edited rows. Lock ordering
|
||||||
|
* matches {@link updateOfferCommand}: pages → offers → furniture. A failing row
|
||||||
|
* aborts the whole batch (full rollback) rather than leaving a half-applied save.
|
||||||
|
*/
|
||||||
|
export async function bulkUpdateOffersCommand(
|
||||||
|
updates: UpdateOfferInput[],
|
||||||
|
userId?: number,
|
||||||
|
) {
|
||||||
|
if (!updates.length) throw Error("No valid fields to update");
|
||||||
|
if (updates.length > 500) throw Error("Max 500 items per bulk update");
|
||||||
|
const parsed = updates.map((input) => {
|
||||||
|
offerIdSchema.parse(input.id);
|
||||||
|
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||||
|
const baseFields = input.baseItem
|
||||||
|
? furniturePatchSchema.parse(input.baseItem.fields)
|
||||||
|
: {};
|
||||||
|
if (!Object.values(fields).some((value) => value !== undefined))
|
||||||
|
throw Error("No valid fields to update");
|
||||||
|
return { input, fields, baseFields };
|
||||||
|
});
|
||||||
|
|
||||||
|
const offerIds = distinctOfferIds(parsed.map((row) => row.input.id));
|
||||||
|
const furnitureIds: number[] = [];
|
||||||
|
for (const row of parsed) {
|
||||||
|
if (row.fields.itemIds !== undefined)
|
||||||
|
furnitureIds.push(...parseFurnitureIds(row.fields.itemIds));
|
||||||
|
if (row.input.baseItem) furnitureIds.push(row.input.baseItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
for (const pageId of new Set(
|
||||||
|
parsed.map((row) => row.fields.pageId).filter((id) => id !== undefined),
|
||||||
|
))
|
||||||
|
await lockPage(tx, pageId as number);
|
||||||
|
const offers = await lockedOffers(tx, offerIds);
|
||||||
|
const offerById = new Map(offers.map((offer) => [Number(offer.id), offer]));
|
||||||
|
if (furnitureIds.length) await lockFurniture(tx, furnitureIds);
|
||||||
|
|
||||||
|
const beforeById = new Map<
|
||||||
|
number,
|
||||||
|
Awaited<ReturnType<typeof historySnapshot>>
|
||||||
|
>();
|
||||||
|
if (userId)
|
||||||
|
for (const id of offerIds)
|
||||||
|
beforeById.set(id, await historySnapshot(tx, "prices", id));
|
||||||
|
|
||||||
|
for (const row of parsed) {
|
||||||
|
const offer = offerById.get(row.input.id);
|
||||||
|
if (!offer) throw Error("Catalog item not found");
|
||||||
|
if (row.input.baseItem) {
|
||||||
|
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||||
|
const nextIds =
|
||||||
|
row.fields.itemIds === undefined
|
||||||
|
? currentIds
|
||||||
|
: parseFurnitureIds(row.fields.itemIds);
|
||||||
|
if (
|
||||||
|
!currentIds.includes(row.input.baseItem.id) ||
|
||||||
|
!nextIds.includes(row.input.baseItem.id)
|
||||||
|
)
|
||||||
|
throw Error("Shared furniture must belong to the catalog offer");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.fields.orderNumber !== undefined ||
|
||||||
|
row.fields.pageId !== undefined
|
||||||
|
) {
|
||||||
|
const targetPageId = row.fields.pageId ?? offer.pageId;
|
||||||
|
if (row.fields.orderNumber !== undefined)
|
||||||
|
await assertOrderUnique(
|
||||||
|
tx,
|
||||||
|
targetPageId,
|
||||||
|
row.fields.orderNumber,
|
||||||
|
row.input.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.fields.limitedStack !== undefined ||
|
||||||
|
row.fields.limitedSells !== undefined
|
||||||
|
) {
|
||||||
|
const stack = row.fields.limitedStack ?? 0;
|
||||||
|
const sells = row.fields.limitedSells ?? 0;
|
||||||
|
const resolvedStack =
|
||||||
|
row.fields.limitedStack !== undefined
|
||||||
|
? stack
|
||||||
|
: Number(offer.limitedStack ?? 0);
|
||||||
|
const resolvedSells =
|
||||||
|
row.fields.limitedSells !== undefined
|
||||||
|
? sells
|
||||||
|
: Number(offer.limitedSells ?? 0);
|
||||||
|
assertLimitedStackOk(resolvedStack, resolvedSells);
|
||||||
|
}
|
||||||
|
const offerAssignments = assignments(CatalogItems, row.fields);
|
||||||
|
if (offerAssignments.length)
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${row.input.id}`,
|
||||||
|
);
|
||||||
|
const baseAssignments = assignments(ItemsBase, row.baseFields);
|
||||||
|
if (row.input.baseItem && baseAssignments.length)
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${row.input.baseItem.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (userId)
|
||||||
|
for (const id of offerIds) {
|
||||||
|
const before = beforeById.get(id);
|
||||||
|
if (before) await recordHistory(tx, "prices", id, userId, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function reorderOffersCommand(
|
export async function reorderOffersCommand(
|
||||||
orders: Array<{ id: number; orderNumber: number }>,
|
orders: Array<{ id: number; orderNumber: number }>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { GET } from "../app/api/imaging/badge/route";
|
||||||
|
|
||||||
|
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-badge-route-${process.pid}`;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Cold disk cache for every test so the candidate walk actually runs.
|
||||||
|
await rm(SCRATCH_ROOT, { recursive: true, force: true });
|
||||||
|
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
|
||||||
|
// A configured CDN short-circuits the database lookup for badge_base_url.
|
||||||
|
vi.stubEnv("BADGE_URL", "https://badges.test/album1584");
|
||||||
|
globalThis.imagingBadgeFetches?.clear?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(SCRATCH_ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function gifResponse() {
|
||||||
|
return new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { "content-type": "image/gif" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shares a single upstream fetch across concurrent cold-cache requests", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
return gifResponse();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const request = () =>
|
||||||
|
new NextRequest("https://hotel.test/api/imaging/badge?code=ADM");
|
||||||
|
const [a, b] = await Promise.all([GET(request()), GET(request())]);
|
||||||
|
expect(a.status).toBe(200);
|
||||||
|
expect(b.status).toBe(200);
|
||||||
|
expect(a.headers.get("content-type")).toBe("image/gif");
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a previously fetched badge from disk without re-fetching", async () => {
|
||||||
|
const fetcher = vi.fn(async () => gifResponse());
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
const request = new NextRequest(
|
||||||
|
"https://hotel.test/api/imaging/badge?code=HABBO",
|
||||||
|
);
|
||||||
|
const first = await GET(request);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
const callsAfterFirst = fetcher.mock.calls.length;
|
||||||
|
expect(callsAfterFirst).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const second = await GET(request);
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(second.headers.get("x-imager-source")).toBe("cache");
|
||||||
|
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates a cached badge with a 304 when the client already has it", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => gifResponse()),
|
||||||
|
);
|
||||||
|
const url = "https://hotel.test/api/imaging/badge?code=ETAG";
|
||||||
|
const first = await GET(new NextRequest(url));
|
||||||
|
const etag = first.headers.get("etag");
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await GET(
|
||||||
|
new NextRequest(url, { headers: { "if-none-match": etag } }),
|
||||||
|
);
|
||||||
|
expect(second.status).toBe(304);
|
||||||
|
expect(second.headers.get("etag")).toBe(etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a placeholder when every candidate is unavailable", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("nope", { status: 404 })),
|
||||||
|
);
|
||||||
|
const result = await GET(
|
||||||
|
new NextRequest("https://hotel.test/api/imaging/badge?code=GONE"),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.headers.get("content-type")).toBe("image/svg+xml");
|
||||||
|
expect(result.headers.get("x-imager-source")).toBe("unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed badge codes without contacting any candidate", async () => {
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
const result = await GET(
|
||||||
|
new NextRequest("https://hotel.test/api/imaging/badge?code=../bad"),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
+17
-1
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
|
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
|
||||||
|
|
||||||
describe("csp", () => {
|
describe("csp", () => {
|
||||||
@@ -19,4 +19,20 @@ describe("csp", () => {
|
|||||||
expect(csp).toContain("https://challenges.cloudflare.com");
|
expect(csp).toContain("https://challenges.cloudflare.com");
|
||||||
expect(csp).not.toContain("https://cdn.jsdelivr.net");
|
expect(csp).not.toContain("https://cdn.jsdelivr.net");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks framing and upgrades insecure requests outside development", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
const csp = buildContentSecurityPolicy("n");
|
||||||
|
expect(csp).toContain("frame-ancestors 'none'");
|
||||||
|
expect(csp).toContain("upgrade-insecure-requests");
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits upgrade-insecure-requests in development", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
const csp = buildContentSecurityPolicy("n");
|
||||||
|
expect(csp).toContain("frame-ancestors 'none'");
|
||||||
|
expect(csp).not.toContain("upgrade-insecure-requests");
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -28,11 +28,17 @@ export function buildContentSecurityPolicy(nonce: string): string {
|
|||||||
"style-src-attr 'unsafe-inline'",
|
"style-src-attr 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob: https:",
|
"img-src 'self' data: blob: https:",
|
||||||
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
||||||
|
// Clickjacking defence that also covers the `frame-ancestors`-aware
|
||||||
|
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
|
||||||
|
// production-only: over plain-HTTP localhost it would rewrite same-origin
|
||||||
|
// asset URLs to https and break the dev server.
|
||||||
|
"frame-ancestors 'none'",
|
||||||
"connect-src 'self' https: wss:",
|
"connect-src 'self' https: wss:",
|
||||||
"font-src 'self' data: https://fonts.gstatic.com",
|
"font-src 'self' data: https://fonts.gstatic.com",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
|
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ describe("classifyDdos", () => {
|
|||||||
["/api/auth/callback/credentials", "auth"],
|
["/api/auth/callback/credentials", "auth"],
|
||||||
["/api/articles/hello", "api"],
|
["/api/articles/hello", "api"],
|
||||||
["/api/radio/stream", "api"],
|
["/api/radio/stream", "api"],
|
||||||
|
// Imaging is deliberately in the `api` bucket: it is high-volume and
|
||||||
|
// cacheable, so it gets the generous api budget, not the page budget.
|
||||||
|
["/api/imaging/avatar", "api"],
|
||||||
|
["/api/imaging/badge", "api"],
|
||||||
])(`classifies %s as %s`, (pathname, expected) => {
|
])(`classifies %s as %s`, (pathname, expected) => {
|
||||||
expect(classifyDdos(pathname)).toBe(expected);
|
expect(classifyDdos(pathname)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
export type DdosCategory = "pages" | "api" | "auth";
|
export type DdosCategory = "pages" | "api" | "auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket a request path for rate limiting. Everything under `/api/` that is not
|
||||||
|
* an auth route counts as `api` — including `/api/imaging/*` (avatars, badges).
|
||||||
|
* That is intentional: imaging is a deliberate high-volume, cacheable surface,
|
||||||
|
* so it gets the wider api budget rather than the page budget. The bare
|
||||||
|
* `/imaging` renderer path never reaches this gate (it is excluded by the proxy
|
||||||
|
* matcher and, in production, fronted by the edge proxy directly).
|
||||||
|
*/
|
||||||
export function classifyDdos(pathname: string): DdosCategory {
|
export function classifyDdos(pathname: string): DdosCategory {
|
||||||
if (
|
if (
|
||||||
pathname === "/api/auth" ||
|
pathname === "/api/auth" ||
|
||||||
|
|||||||
@@ -188,29 +188,27 @@ describe("Docker clone updates", () => {
|
|||||||
expect(r.calls).not.toContain("docker volume prune");
|
expect(r.calls).not.toContain("docker volume prune");
|
||||||
expect(r.calls).not.toContain("docker system prune");
|
expect(r.calls).not.toContain("docker system prune");
|
||||||
});
|
});
|
||||||
it.each([
|
it.each(["dirty", "pull-failure", "build-failure", "migration-failure"])(
|
||||||
"dirty",
|
"does not replace the container after %s",
|
||||||
"ci-active",
|
(scenario) => {
|
||||||
"ci-green",
|
|
||||||
"ci-upstream",
|
|
||||||
"pull-failure",
|
|
||||||
"build-failure",
|
|
||||||
"migration-failure",
|
|
||||||
])("does not replace the container after %s", (scenario) => {
|
|
||||||
const r = simulate(scenario);
|
const r = simulate(scenario);
|
||||||
expect(r.status, r.output).not.toBe(0);
|
expect(r.status, r.output).not.toBe(0);
|
||||||
expect(r.calls).not.toContain("compose up");
|
expect(r.calls).not.toContain("compose up");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
// Regressie: na een cutover naar het groene slot bestaat epicnext-cms-app
|
// Regressie: na een cutover naar het groene slot bestaat epicnext-cms-app
|
||||||
// niet meer en zag deze controle alleen een vrijgekomen blauwe container. De
|
// niet meer en zag deze controle alleen een vrijgekomen blauwe container. De
|
||||||
// dagelijkse `docker-update.sh` startte toen een compose-replica op poort
|
// dagelijkse `docker-update.sh` startte toen een compose-replica op poort
|
||||||
// 3002 en blokkeerde elke volgende release. De blauwe container alleen
|
// 3002 en blokkeerde elke volgende release. De blauwe container alleen
|
||||||
// controleren is dus geen bewijs dat de host niet door CI beheerd wordt.
|
// controleren is dus geen bewijs dat de host niet door CI beheerd wordt.
|
||||||
it.each(["ci-green", "ci-upstream"])(
|
//
|
||||||
"refuses a compose deployment while CI owns the host (%s)",
|
// A host owned by CI is a legitimate no-op, not an error: the cron run must
|
||||||
|
// exit 0 so it does not look like a failed deployment.
|
||||||
|
it.each(["ci-active", "ci-green", "ci-upstream"])(
|
||||||
|
"skips the compose deployment while CI owns the host (%s)",
|
||||||
(scenario) => {
|
(scenario) => {
|
||||||
const r = simulate(scenario);
|
const r = simulate(scenario);
|
||||||
expect(r.status, r.output).not.toBe(0);
|
expect(r.status, r.output).toBe(0);
|
||||||
expect(r.output).toContain("This host is managed by CI");
|
expect(r.output).toContain("This host is managed by CI");
|
||||||
expect(r.calls).not.toContain("compose up");
|
expect(r.calls).not.toContain("compose up");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
|
|||||||
/**
|
/**
|
||||||
* Local furniture-bundle lookups.
|
* Local furniture-bundle lookups.
|
||||||
*
|
*
|
||||||
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
|
* New bundles are written with the configured primary extension
|
||||||
* assets is still `.nitro`, so every existence check has to consider both. These
|
* (`FURNI_BUNDLE_EXT`, `.hab` by default), but a large body of pre-existing
|
||||||
|
* assets may be `.nitro`, so every existence check has to consider both. These
|
||||||
* helpers are the single place that knows that, which keeps "is this item's
|
* helpers are the single place that knows that, which keeps "is this item's
|
||||||
* asset present?" from silently flipping to `false` for legacy files and the
|
* asset present?" from silently flipping to `false` for legacy files and the
|
||||||
* nitro-cleanup scan from classifying them as fake leftovers.
|
* nitro-cleanup scan from classifying them as fake leftovers.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
getBaseClassname,
|
getBaseClassname,
|
||||||
getBundleFileName,
|
getBundleFileName,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getSwfFileName,
|
getSwfFileName,
|
||||||
isValidClassname,
|
isValidClassname,
|
||||||
normalizeClassname,
|
normalizeClassname,
|
||||||
|
resolveLocalBundleExt,
|
||||||
SOURCE_BUNDLE_EXT,
|
SOURCE_BUNDLE_EXT,
|
||||||
} from "./classname";
|
} from "./classname";
|
||||||
|
|
||||||
@@ -61,6 +62,44 @@ describe("normalizeClassname", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveLocalBundleExt", () => {
|
||||||
|
it("defaults to .hab", () => {
|
||||||
|
expect(resolveLocalBundleExt(undefined)).toBe(".hab");
|
||||||
|
expect(resolveLocalBundleExt("")).toBe(".hab");
|
||||||
|
});
|
||||||
|
it("accepts .nitro and the bare form", () => {
|
||||||
|
expect(resolveLocalBundleExt(".nitro")).toBe(".nitro");
|
||||||
|
expect(resolveLocalBundleExt("nitro")).toBe(".nitro");
|
||||||
|
expect(resolveLocalBundleExt("NITRO")).toBe(".nitro");
|
||||||
|
});
|
||||||
|
it("accepts .hab and the bare form", () => {
|
||||||
|
expect(resolveLocalBundleExt(".hab")).toBe(".hab");
|
||||||
|
expect(resolveLocalBundleExt("HAB")).toBe(".hab");
|
||||||
|
});
|
||||||
|
it("falls back to .hab for unknown values", () => {
|
||||||
|
expect(resolveLocalBundleExt("swf")).toBe(".hab");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FURNI_BUNDLE_EXT wiring", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured extension and keeps the other as fallback", async () => {
|
||||||
|
vi.stubEnv("FURNI_BUNDLE_EXT", ".nitro");
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import("./classname");
|
||||||
|
expect(mod.LOCAL_BUNDLE_EXT).toBe(".nitro");
|
||||||
|
expect(mod.getNitroFileName("sofa_blue")).toBe("sofa_blue.nitro");
|
||||||
|
expect(mod.getLocalBundleFileNames("sofa_blue")).toEqual([
|
||||||
|
"sofa_blue.nitro",
|
||||||
|
"sofa_blue.hab",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isValidClassname", () => {
|
describe("isValidClassname", () => {
|
||||||
it("accepts lowercase letters, digits and allowed symbols", () => {
|
it("accepts lowercase letters, digits and allowed symbols", () => {
|
||||||
expect(isValidClassname("chair_basic")).toBe(true);
|
expect(isValidClassname("chair_basic")).toBe(true);
|
||||||
|
|||||||
@@ -17,23 +17,65 @@ export function getSwfFileName(classname: string): string {
|
|||||||
return `${getBaseClassname(classname)}.swf`;
|
return `${getBaseClassname(classname)}.swf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The two extensions a furniture bundle may carry. */
|
||||||
|
export const BUNDLE_EXT_CHOICES = [".hab", ".nitro"] as const;
|
||||||
|
export type FurnitureBundleExt = (typeof BUNDLE_EXT_CHOICES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an env value via a computed key so Next does not inline it at build time
|
||||||
|
* — changing it should take effect on restart, not require a rebuild (same
|
||||||
|
* pattern as `runtime-asset-config.ts`).
|
||||||
|
*/
|
||||||
|
function readBundleExtEnv(): string | undefined {
|
||||||
|
const name = "FURNI_BUNDLE_EXT";
|
||||||
|
// `classname.ts` is also imported by client components for its pure string
|
||||||
|
// helpers, where `process` does not exist at runtime. Reading env is a
|
||||||
|
// server concern; on the client fall back to the default extension.
|
||||||
|
if (typeof process === "undefined") return undefined;
|
||||||
|
return process.env[name]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the local furniture-bundle extension this deployment's client asks
|
||||||
|
* for. Set `FURNI_BUNDLE_EXT` in `.env` to `hab`/`.hab` or `nitro`/`.nitro`;
|
||||||
|
* anything missing or unrecognised falls back to `.hab`.
|
||||||
|
*/
|
||||||
|
export function resolveLocalBundleExt(
|
||||||
|
raw: string | undefined = readBundleExtEnv(),
|
||||||
|
): FurnitureBundleExt {
|
||||||
|
const value = raw?.trim().toLowerCase();
|
||||||
|
if (!value) return ".hab";
|
||||||
|
const normalized = value.startsWith(".") ? value : `.${value}`;
|
||||||
|
return (BUNDLE_EXT_CHOICES as readonly string[]).includes(normalized)
|
||||||
|
? (normalized as FurnitureBundleExt)
|
||||||
|
: ".hab";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension locally stored furniture bundles are written with. This deployment's
|
* Extension locally stored furniture bundles are written with. This deployment's
|
||||||
* Nitro client requests `<classname>.hab`, so that is what new imports produce.
|
* Nitro client requests `<classname>.hab` by default, but a stock Nitro client
|
||||||
|
* asks for `.nitro` — choose with `FURNI_BUNDLE_EXT`. New imports and repairs
|
||||||
|
* produce this extension.
|
||||||
*/
|
*/
|
||||||
export const LOCAL_BUNDLE_EXT = ".hab";
|
export const LOCAL_BUNDLE_EXT: FurnitureBundleExt = resolveLocalBundleExt();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
|
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
|
||||||
* configured clone source) keeps shipping `.nitro`, so download URLs are built
|
* configured clone source) keeps shipping `.nitro`, so download URLs are built
|
||||||
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
|
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
|
||||||
*/
|
*/
|
||||||
export const SOURCE_BUNDLE_EXT = ".nitro";
|
export const SOURCE_BUNDLE_EXT = ".nitro" as const;
|
||||||
|
|
||||||
/** Both extensions a local bundle may carry, newest convention first. */
|
/**
|
||||||
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const;
|
* Every extension a local bundle may be stored under, the configured one first
|
||||||
|
* so it always wins over a same-stem legacy file.
|
||||||
|
*/
|
||||||
|
export const LOCAL_BUNDLE_EXTS: readonly FurnitureBundleExt[] = [
|
||||||
|
LOCAL_BUNDLE_EXT,
|
||||||
|
...BUNDLE_EXT_CHOICES.filter((ext) => ext !== LOCAL_BUNDLE_EXT),
|
||||||
|
];
|
||||||
|
|
||||||
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */
|
/** Primary local bundle name — the configured extension wins over the legacy one. */
|
||||||
export function getNitroFileName(classname: string): string {
|
export function getNitroFileName(classname: string): string {
|
||||||
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
|
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ifNoneMatchSatisfied, strongEtag } from "./imager-http";
|
||||||
|
|
||||||
|
describe("strongEtag", () => {
|
||||||
|
it("is deterministic and quote-wrapped", () => {
|
||||||
|
const body = new Uint8Array([1, 2, 3]);
|
||||||
|
expect(strongEtag(body)).toBe(strongEtag(new Uint8Array([1, 2, 3])));
|
||||||
|
expect(strongEtag(body)).toMatch(/^"[A-Za-z0-9_-]+"$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when the body changes", () => {
|
||||||
|
expect(strongEtag(new Uint8Array([1, 2, 3]))).not.toBe(
|
||||||
|
strongEtag(new Uint8Array([1, 2, 4])),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ifNoneMatchSatisfied", () => {
|
||||||
|
const etag = strongEtag(new Uint8Array([9]));
|
||||||
|
|
||||||
|
it("returns false without a header", () => {
|
||||||
|
expect(ifNoneMatchSatisfied(null, etag)).toBe(false);
|
||||||
|
expect(ifNoneMatchSatisfied("", etag)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches an exact, weak or wildcard validator", () => {
|
||||||
|
expect(ifNoneMatchSatisfied(etag, etag)).toBe(true);
|
||||||
|
expect(ifNoneMatchSatisfied(`W/${etag}`, etag)).toBe(true);
|
||||||
|
expect(ifNoneMatchSatisfied("*", etag)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches any entry in a comma-separated list", () => {
|
||||||
|
expect(ifNoneMatchSatisfied(`"other", ${etag}`, etag)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match a different validator", () => {
|
||||||
|
expect(ifNoneMatchSatisfied('"other"', etag)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strong HTTP validator for an imaging response body.
|
||||||
|
*
|
||||||
|
* Derived from the bytes rather than the request URL on purpose: the same URL
|
||||||
|
* can legitimately return different bytes over its lifetime — a degraded
|
||||||
|
* fallback render before the configured imager recovers, or a different
|
||||||
|
* transcode variant — and a URL-based validator would let a browser keep the
|
||||||
|
* worse image via a stale `304`. Hashing the body means the ETag changes
|
||||||
|
* exactly when the bytes do.
|
||||||
|
*/
|
||||||
|
export function strongEtag(body: Uint8Array): string {
|
||||||
|
return `"${createHash("sha1").update(body).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an `If-None-Match` request header matches `etag`. Handles the
|
||||||
|
* comma-separated list form, weak validators (`W/"…"`) and the `*` wildcard, so
|
||||||
|
* a revalidating browser gets a `304` whenever its cached copy still matches.
|
||||||
|
*/
|
||||||
|
export function ifNoneMatchSatisfied(
|
||||||
|
header: string | null,
|
||||||
|
etag: string,
|
||||||
|
): boolean {
|
||||||
|
if (!header) return false;
|
||||||
|
return header.split(",").some((raw) => {
|
||||||
|
const tag = raw.trim();
|
||||||
|
return tag === "*" || tag === etag || tag === `W/${etag}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isAnimatedPng } from "./imager-png";
|
||||||
|
|
||||||
|
function png(chunks: Array<{ type: string; data?: number[] }>): Uint8Array {
|
||||||
|
const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const data = chunk.data ?? [];
|
||||||
|
const len = data.length;
|
||||||
|
bytes.push(
|
||||||
|
(len >>> 24) & 0xff,
|
||||||
|
(len >>> 16) & 0xff,
|
||||||
|
(len >>> 8) & 0xff,
|
||||||
|
len & 0xff,
|
||||||
|
...chunk.type.split("").map((c) => c.charCodeAt(0)),
|
||||||
|
...data,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isAnimatedPng", () => {
|
||||||
|
it("returns false for non-PNG input", () => {
|
||||||
|
expect(isAnimatedPng(new Uint8Array([1, 2, 3]))).toBe(false);
|
||||||
|
expect(isAnimatedPng(new Uint8Array())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a plain PNG", () => {
|
||||||
|
const plain = png([{ type: "IHDR" }, { type: "IDAT" }, { type: "IEND" }]);
|
||||||
|
expect(isAnimatedPng(plain)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when an acTL chunk precedes IDAT", () => {
|
||||||
|
const anim = png([
|
||||||
|
{ type: "IHDR" },
|
||||||
|
{ type: "acTL", data: [0, 16, 0, 0] },
|
||||||
|
{ type: "IDAT" },
|
||||||
|
{ type: "IEND" },
|
||||||
|
]);
|
||||||
|
expect(isAnimatedPng(anim)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an acTL chunk that appears after IDAT", () => {
|
||||||
|
const late = png([
|
||||||
|
{ type: "IHDR" },
|
||||||
|
{ type: "IDAT" },
|
||||||
|
{ type: "acTL", data: [0, 16, 0, 0] },
|
||||||
|
]);
|
||||||
|
expect(isAnimatedPng(late)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not loop forever on a corrupt chunk length", () => {
|
||||||
|
const corrupt = new Uint8Array([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0x49, 0x48, 0x44, 0x52,
|
||||||
|
]);
|
||||||
|
expect(isAnimatedPng(corrupt)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a buffer is an animated PNG (APNG).
|
||||||
|
*
|
||||||
|
* APNG is a PNG carrying an `acTL` control chunk before `IDAT`; a plain PNG has
|
||||||
|
* none. `sharp`/libvips in this build cannot decode APNG *animation* — it reads
|
||||||
|
* only the first frame and re-encodes that — so a WebP/AVIF transcode of an
|
||||||
|
* APNG silently turns a moving avatar into a still one. Callers that care about
|
||||||
|
* quality use this to leave animated renders untouched and only re-encode the
|
||||||
|
* static ones. Chunk walking stops at `IDAT`/`IEND` because the spec requires
|
||||||
|
* `acTL` to precede them.
|
||||||
|
*/
|
||||||
|
export function isAnimatedPng(bytes: Uint8Array): boolean {
|
||||||
|
if (bytes.length < 8) return false;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (bytes[i] !== PNG_SIGNATURE[i]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 8;
|
||||||
|
while (offset + 8 <= bytes.length) {
|
||||||
|
const length =
|
||||||
|
((bytes[offset] << 24) |
|
||||||
|
(bytes[offset + 1] << 16) |
|
||||||
|
(bytes[offset + 2] << 8) |
|
||||||
|
bytes[offset + 3]) >>>
|
||||||
|
0;
|
||||||
|
const type = String.fromCharCode(
|
||||||
|
bytes[offset + 4],
|
||||||
|
bytes[offset + 5],
|
||||||
|
bytes[offset + 6],
|
||||||
|
bytes[offset + 7],
|
||||||
|
);
|
||||||
|
if (type === "acTL") return true;
|
||||||
|
if (type === "IDAT" || type === "IEND") return false;
|
||||||
|
// A corrupt length would loop forever; bail instead.
|
||||||
|
if (length > bytes.length) return false;
|
||||||
|
offset += 12 + length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -15,7 +15,11 @@ vi.mock("@/lib/runtime-asset-config", () => ({
|
|||||||
resolveImagerBase: (origin: string) => `${origin}/imager`,
|
resolveImagerBase: (origin: string) => `${origin}/imager`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream";
|
import {
|
||||||
|
fetchAvatarImage,
|
||||||
|
ImagerUnavailableError,
|
||||||
|
parseAvatarQuery,
|
||||||
|
} from "./imager-upstream";
|
||||||
|
|
||||||
const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
|
const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
|
||||||
|
|
||||||
@@ -136,3 +140,19 @@ it("does not queue a new caller behind a render that is already too old", async
|
|||||||
expect([...shared[0].body]).toEqual([9]);
|
expect([...shared[0].body]).toEqual([9]);
|
||||||
expect(shared[1].body).toBe(shared[0].body);
|
expect(shared[1].body).toBe(shared[0].body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts figures the renderer understands even with a trailing segment", () => {
|
||||||
|
// The client appends extra segments the imager still draws (e.g. the
|
||||||
|
// trailing `.0` here). Rejecting them turned valid avatars into 400s.
|
||||||
|
const figure =
|
||||||
|
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
|
||||||
|
const parsed = parseAvatarQuery(new URLSearchParams({ figure }));
|
||||||
|
expect(parsed.ok).toBe(true);
|
||||||
|
if (parsed.ok) expect(parsed.params.get("figure")).toBe(figure);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects a figure that is not a figure", () => {
|
||||||
|
for (const figure of ["../bad", "hd-180-1/../x", "", "0"]) {
|
||||||
|
expect(parseAvatarQuery(new URLSearchParams({ figure })).ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
} from "@/lib/imager-cache";
|
} from "@/lib/imager-cache";
|
||||||
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
||||||
|
|
||||||
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
|
// The client's figure strings are passed straight through to the imager, which
|
||||||
|
// is the authority on what it can draw. Only the first segment is required to
|
||||||
|
// look like a real part (`hd-180`); the rest is deliberately loose because some
|
||||||
|
// clients append extra segments (e.g. a trailing `.0`) that the renderer still
|
||||||
|
// accepts. Over-strict validation here turned valid avatars into 400s.
|
||||||
|
export const FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
|
||||||
export const FIGURE_MAX_LEN = 512;
|
export const FIGURE_MAX_LEN = 512;
|
||||||
export const FIGURE_MAX_PARTS = 24;
|
export const FIGURE_MAX_PARTS = 24;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { GET } from "../app/imaging/route";
|
||||||
|
|
||||||
|
// sharp is the one CPU-bound operation on this default-imager route, so the
|
||||||
|
// tests instrument it directly to prove transcodes are cached rather than
|
||||||
|
// re-run on every request.
|
||||||
|
const { sharpMock, counters } = vi.hoisted(() => {
|
||||||
|
const counters = {
|
||||||
|
calls: 0,
|
||||||
|
options: [] as unknown[],
|
||||||
|
behavior: "ok" as "ok" | "throw",
|
||||||
|
};
|
||||||
|
const sharpMock = (_input: unknown, options?: unknown) => {
|
||||||
|
counters.calls += 1;
|
||||||
|
counters.options.push(options);
|
||||||
|
if (counters.behavior === "throw") throw new Error("decode failed");
|
||||||
|
return {
|
||||||
|
webp: () => ({ toBuffer: async () => Buffer.from([1, 2, 3]) }),
|
||||||
|
avif: () => ({ toBuffer: async () => Buffer.from([4, 5, 6]) }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return { sharpMock, counters };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("sharp", () => ({ default: sharpMock }));
|
||||||
|
|
||||||
|
// One scratch root for the whole file, removed afterwards, so the disk cache
|
||||||
|
// never leaks state between tests or into the runtime cache tree.
|
||||||
|
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-imaging-route-${process.pid}`;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Cold disk cache for every test: a warm variant would short-circuit the
|
||||||
|
// transcode being asserted.
|
||||||
|
await rm(SCRATCH_ROOT, { recursive: true, force: true });
|
||||||
|
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
|
||||||
|
vi.stubEnv("IMAGER_URL", "https://imager.test/avatarimage");
|
||||||
|
counters.calls = 0;
|
||||||
|
counters.options = [];
|
||||||
|
counters.behavior = "ok";
|
||||||
|
// Drop any single-flight entries a prior test may have left behind.
|
||||||
|
globalThis.imagingTranscodes?.clear?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(SCRATCH_ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubPngUpstream() {
|
||||||
|
const fetcher = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(new Uint8Array([9, 8, 7]), {
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
return fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A minimal APNG: the PNG signature followed by an `acTL` chunk, which is all
|
||||||
|
// the animated-format detector needs. sharp cannot re-encode APNG animation in
|
||||||
|
// this build, so these must be passed through untouched.
|
||||||
|
function apngBytes(): Uint8Array {
|
||||||
|
return new Uint8Array([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0x61, 0x63,
|
||||||
|
0x54, 0x4c, 0, 0, 0, 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("re-encodes an avatar to WebP and caches the variant across requests", async () => {
|
||||||
|
const fetcher = stubPngUpstream();
|
||||||
|
const request = new NextRequest(
|
||||||
|
"https://hotel.test/imaging?figure=hd-180-1&format=webp",
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await GET(request);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.headers.get("content-type")).toBe("image/webp");
|
||||||
|
expect(counters.calls).toBe(1);
|
||||||
|
|
||||||
|
const fetchesAfterFirst = fetcher.mock.calls.length;
|
||||||
|
const second = await GET(request);
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(second.headers.get("content-type")).toBe("image/webp");
|
||||||
|
// Served entirely from disk cache: neither the upstream nor sharp re-ran.
|
||||||
|
expect(counters.calls).toBe(1);
|
||||||
|
expect(fetcher.mock.calls.length).toBe(fetchesAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes with animation awareness for transcodeable inputs", async () => {
|
||||||
|
stubPngUpstream();
|
||||||
|
const result = await GET(
|
||||||
|
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(counters.options.at(-1)).toMatchObject({ animated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an animated PNG untouched instead of dropping its frames", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(apngBytes(), {
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await GET(
|
||||||
|
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
// Original bytes/content-type preserved and sharp never ran.
|
||||||
|
expect(result.headers.get("content-type")).toBe("image/png");
|
||||||
|
expect(counters.calls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares a single transcode across concurrent cold-cache requests", async () => {
|
||||||
|
stubPngUpstream();
|
||||||
|
const request = () =>
|
||||||
|
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=avif");
|
||||||
|
const [a, b] = await Promise.all([GET(request()), GET(request())]);
|
||||||
|
expect(a.status).toBe(200);
|
||||||
|
expect(b.status).toBe(200);
|
||||||
|
expect(counters.calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the original render when the transcode fails", async () => {
|
||||||
|
stubPngUpstream();
|
||||||
|
counters.behavior = "throw";
|
||||||
|
const result = await GET(
|
||||||
|
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.headers.get("content-type")).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
|
||||||
|
stubPngUpstream();
|
||||||
|
const url = "https://hotel.test/imaging?figure=hd-55-1";
|
||||||
|
const first = await GET(new NextRequest(url));
|
||||||
|
const etag = first.headers.get("etag");
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await GET(
|
||||||
|
new NextRequest(url, { headers: { "if-none-match": etag } }),
|
||||||
|
);
|
||||||
|
expect(second.status).toBe(304);
|
||||||
|
expect(second.headers.get("etag")).toBe(etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the graceful silhouette instead of a broken image when every upstream is down", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("down", { status: 503 })),
|
||||||
|
);
|
||||||
|
const result = await GET(
|
||||||
|
new NextRequest("https://hotel.test/imaging?figure=xd-900-1&format=webp"),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.headers.get("content-type")).toBe("image/svg+xml");
|
||||||
|
expect(result.headers.get("x-imager-source")).toBe("unavailable");
|
||||||
|
expect(result.headers.get("cache-control")).toContain("no-cache");
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* perf-ttls reads the CMS_* env vars at module load, so each case re-imports
|
||||||
|
* with a freshly stubbed process.env (vitest sets SKIP_ENV_VALIDATION=1, so
|
||||||
|
* @/env is the raw process.env object and vi.stubEnv is visible to it).
|
||||||
|
*/
|
||||||
|
async function loadTtls() {
|
||||||
|
return await import("@/lib/perf-ttls");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perf-ttls", () => {
|
||||||
|
it("uses the documented defaults when the env is unset", async () => {
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.HOME_PAYLOAD_TTL_MS).toBe(15_000);
|
||||||
|
expect(mod.NEWS_LIST_TTL_MS).toBe(300_000);
|
||||||
|
expect(mod.NEWS_ARCHIVE_TTL_MS).toBe(60_000);
|
||||||
|
expect(mod.PUBLIC_SEARCH_TTL_MS).toBe(30_000);
|
||||||
|
expect(mod.ONLINE_COUNT_TTL_MS).toBe(10_000);
|
||||||
|
expect(mod.COUNTERS_TTL_MS).toBe(300_000);
|
||||||
|
expect(mod.SITE_SETTINGS_TTL_MS).toBe(300_000);
|
||||||
|
expect(mod.SITE_SETTINGS_MEMORY_TTL_MS).toBe(60_000);
|
||||||
|
expect(mod.CACHE_WARMUP_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours env overrides (seconds → ms)", async () => {
|
||||||
|
vi.stubEnv("CMS_HOME_PAYLOAD_TTL_SEC", "99");
|
||||||
|
vi.stubEnv("CMS_ONLINE_COUNT_TTL_SEC", "3");
|
||||||
|
vi.stubEnv("CMS_WARMUP_ENABLED", "false");
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.HOME_PAYLOAD_TTL_MS).toBe(99_000);
|
||||||
|
expect(mod.ONLINE_COUNT_TTL_MS).toBe(3_000);
|
||||||
|
expect(mod.CACHE_WARMUP_ENABLED).toBe(false);
|
||||||
|
// Untouched keys keep their defaults.
|
||||||
|
expect(mod.NEWS_LIST_TTL_MS).toBe(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid numbers and keeps the default", async () => {
|
||||||
|
vi.stubEnv("CMS_NEWS_ARCHIVE_TTL_SEC", "not-a-number");
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.NEWS_ARCHIVE_TTL_MS).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats zero and negative values as unset", async () => {
|
||||||
|
vi.stubEnv("CMS_SEARCH_TTL_SEC", "0");
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.PUBLIC_SEARCH_TTL_MS).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env-tuned cache TTLs for the hot public/admin read paths. Every value falls
|
||||||
|
* back to the previous hard-coded default when the variable is unset (and under
|
||||||
|
* SKIP_ENV_VALIDATION, where env is the raw process.env), so existing installs
|
||||||
|
* behave identically until they opt into tuning.
|
||||||
|
*
|
||||||
|
* Keep these in seconds on the wire (env) and milliseconds in code, matching
|
||||||
|
* the REDIS_CACHE_TTL_DEFAULT convention.
|
||||||
|
*/
|
||||||
|
function seconds(
|
||||||
|
value: string | number | undefined,
|
||||||
|
fallbackSec: number,
|
||||||
|
): number {
|
||||||
|
const parsed = typeof value === "number" ? value : Number(value);
|
||||||
|
return (Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackSec) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/api/home` landing payload (articles + online count + hotel name). */
|
||||||
|
export const HOME_PAYLOAD_TTL_MS = seconds(env.CMS_HOME_PAYLOAD_TTL_SEC, 15);
|
||||||
|
|
||||||
|
/** Shared news preview list used by the homepage and widgets. */
|
||||||
|
export const NEWS_LIST_TTL_MS = seconds(env.CMS_NEWS_LIST_TTL_SEC, 300);
|
||||||
|
|
||||||
|
/** News archive / search landing — includes a COUNT(*) over a LIKE scan. */
|
||||||
|
export const NEWS_ARCHIVE_TTL_MS = seconds(env.CMS_NEWS_ARCHIVE_TTL_SEC, 60);
|
||||||
|
|
||||||
|
/** Public directory search sections (users/rooms/news/events). */
|
||||||
|
export const PUBLIC_SEARCH_TTL_MS = seconds(env.CMS_SEARCH_TTL_SEC, 30);
|
||||||
|
|
||||||
|
/** Online player counter — the hottest COUNT(*) on the site. */
|
||||||
|
export const ONLINE_COUNT_TTL_MS = seconds(env.CMS_ONLINE_COUNT_TTL_SEC, 10);
|
||||||
|
|
||||||
|
/** Public counter keys (totals) and the warm-up's stale window for them. */
|
||||||
|
export const COUNTERS_TTL_MS = seconds(env.CMS_COUNTERS_TTL_SEC, 300);
|
||||||
|
|
||||||
|
/** Site settings: Redis/shared TTL and the per-process memory mirror. */
|
||||||
|
export const SITE_SETTINGS_TTL_MS = seconds(env.CMS_SITE_SETTINGS_TTL_SEC, 300);
|
||||||
|
export const SITE_SETTINGS_MEMORY_TTL_MS = seconds(
|
||||||
|
env.CMS_SITE_SETTINGS_MEMORY_TTL_SEC,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Boot-time cache warm-up toggle (instrumentation.ts). */
|
||||||
|
export const CACHE_WARMUP_ENABLED = (() => {
|
||||||
|
// Validated mode exposes the zod-transformed boolean; SKIP_ENV_VALIDATION
|
||||||
|
// (tooling/tests) hands the raw string through, so accept both spellings.
|
||||||
|
const raw = env.CMS_WARMUP_ENABLED as unknown;
|
||||||
|
if (typeof raw === "boolean") return raw;
|
||||||
|
if (typeof raw === "string") return raw !== "false" && raw !== "0";
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
@@ -108,6 +108,28 @@ it("serves a previously rendered avatar from the disk cache without re-fetching"
|
|||||||
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
|
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
|
||||||
|
const fetcher = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
vi.stubEnv("IMAGER_URL", "https://etag.test/avatarimage");
|
||||||
|
const url = "https://hotel.test/api/imaging/avatar?figure=hd-77-1";
|
||||||
|
const first = await GET(new NextRequest(url));
|
||||||
|
const etag = first.headers.get("etag");
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await GET(
|
||||||
|
new NextRequest(url, { headers: { "if-none-match": etag } }),
|
||||||
|
);
|
||||||
|
expect(second.status).toBe(304);
|
||||||
|
expect(second.headers.get("etag")).toBe(etag);
|
||||||
|
expect(second.headers.get("x-imager-source")).toBe("cache");
|
||||||
|
});
|
||||||
|
|
||||||
it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
|
it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
|
||||||
const fetcher = vi.fn(
|
const fetcher = vi.fn(
|
||||||
async (_input: string | URL) =>
|
async (_input: string | URL) =>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { asc, desc, eq, gte } from "drizzle-orm";
|
|||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { CameraWeb, db, User, WebsiteTeams } from "@/lib/db";
|
import { CameraWeb, db, User, WebsiteTeams } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
CACHE_WARMUP_ENABLED,
|
||||||
|
COUNTERS_TTL_MS,
|
||||||
|
ONLINE_COUNT_TTL_MS,
|
||||||
|
} from "@/lib/perf-ttls";
|
||||||
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
||||||
import { cachedHomePayload } from "@/lib/services/home-payload";
|
import { cachedHomePayload } from "@/lib/services/home-payload";
|
||||||
import { getNewsList } from "@/lib/services/news-list";
|
import { getNewsList } from "@/lib/services/news-list";
|
||||||
@@ -30,8 +35,8 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
* stop the server from serving.
|
* stop the server from serving.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const COUNTER_TTL_MS = 300_000;
|
const COUNTER_TTL_MS = COUNTERS_TTL_MS;
|
||||||
const ONLINE_TTL_MS = 10_000;
|
const ONLINE_TTL_MS = ONLINE_COUNT_TTL_MS;
|
||||||
|
|
||||||
async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -45,6 +50,10 @@ async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function warmPublicCaches(): Promise<void> {
|
export async function warmPublicCaches(): Promise<void> {
|
||||||
|
// Boot warm-up can be turned off per deploy (CMS_WARMUP_ENABLED=false): on
|
||||||
|
// small hosts a burst of COUNT(*) queries right after boot competes with the
|
||||||
|
// emulator for the same database.
|
||||||
|
if (!CACHE_WARMUP_ENABLED) return;
|
||||||
// Elke `warm` wordt afgewacht voordat de volgende begint, dus de COUNT(*)
|
// Elke `warm` wordt afgewacht voordat de volgende begint, dus de COUNT(*)
|
||||||
// queries gaan één voor één in plaats van als een burst op een database die
|
// queries gaan één voor één in plaats van als een burst op een database die
|
||||||
// nog aan het opstarten is. Dat is goed genoeg; kunstmatige sleeps zouden
|
// nog aan het opstarten is. Dat is goed genoeg; kunstmatige sleeps zouden
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { captchaConfig, verifyCaptcha } from "./captcha";
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("verifyCaptcha", () => {
|
describe("verifyCaptcha", () => {
|
||||||
@@ -74,3 +75,63 @@ describe("captchaConfig", () => {
|
|||||||
expect(cfg.provider).toBe("none");
|
expect(cfg.provider).toBe("none");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("env fallback", () => {
|
||||||
|
it("enables Turnstile from env when the DB is unconfigured", async () => {
|
||||||
|
mockGet.mockResolvedValue("");
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
const cfg = await captchaConfig();
|
||||||
|
expect(cfg.provider).toBe("turnstile");
|
||||||
|
expect(cfg.siteKey).toBe("env-site-key");
|
||||||
|
expect(cfg.field).toBe("cf-turnstile-response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an explicit DB value — including none — win over env", async () => {
|
||||||
|
mockGet.mockImplementation(async (key: string) => {
|
||||||
|
if (key === "captcha_provider") return "none";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
const cfg = await captchaConfig();
|
||||||
|
expect(cfg.provider).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the DB site key over the env one", async () => {
|
||||||
|
mockGet.mockImplementation(async (key: string) => {
|
||||||
|
if (key === "captcha_provider") return "turnstile";
|
||||||
|
if (key === "turnstile_site_key") return "db-site-key";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
const cfg = await captchaConfig();
|
||||||
|
expect(cfg.siteKey).toBe("db-site-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies against the env secret when the DB secret is unset", async () => {
|
||||||
|
mockGet.mockResolvedValue("");
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
vi.stubEnv("TURNSTILE_SECRET_KEY", "env-secret");
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
expect(await verifyCaptcha("tok", "1.2.3.4")).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
expect((init.body as URLSearchParams).get("secret")).toBe("env-secret");
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the provider comes from env but no secret exists", async () => {
|
||||||
|
mockGet.mockResolvedValue("");
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
expect(await verifyCaptcha("tok")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-16
@@ -1,20 +1,28 @@
|
|||||||
|
import { env } from "@/env";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
* Server-side CAPTCHA verification. The provider is driven by website_settings
|
||||||
* provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
|
* (staff pick it in housekeeping) with an ENV FALLBACK so a deploy can enable
|
||||||
* and hCaptcha (three AtomCMS offers, mutually exclusive).
|
* Turnstile without touching the DB: unset DB keys fall back to
|
||||||
|
* CAPTCHA_PROVIDER / TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY. An explicit DB
|
||||||
|
* value — including "none" — always wins over env. Supports Cloudflare
|
||||||
|
* Turnstile, Google reCAPTCHA, and hCaptcha (mutually exclusive).
|
||||||
*
|
*
|
||||||
* Behaviour:
|
* Behaviour:
|
||||||
* - provider "none" (or unset) → fail-open (allow)
|
* - provider "none" (or unset everywhere) → fail-open (allow)
|
||||||
* - provider configured but site key / secret missing, token absent, provider
|
* - provider configured but site key / secret missing, token absent, provider
|
||||||
* API error, or network failure → fail-closed (deny)
|
* API error, or network failure → fail-closed (deny)
|
||||||
*
|
*
|
||||||
* Settings keys:
|
* Settings keys (DB) / env fallbacks:
|
||||||
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
|
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none"
|
||||||
* turnstile_secret / turnstile_site_key
|
* CAPTCHA_PROVIDER (env, used when the DB key is unset)
|
||||||
* recaptcha_secret / recaptcha_site_key
|
* turnstile_site_key / TURNSTILE_SITE_KEY
|
||||||
* hcaptcha_secret / hcaptcha_site_key
|
* turnstile_secret / TURNSTILE_SECRET_KEY
|
||||||
|
* recaptcha_site_key (DB only)
|
||||||
|
* recaptcha_secret (DB only)
|
||||||
|
* hcaptcha_site_key (DB only)
|
||||||
|
* hcaptcha_secret (DB only)
|
||||||
*/
|
*/
|
||||||
export interface CaptchaConfig {
|
export interface CaptchaConfig {
|
||||||
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
||||||
@@ -28,15 +36,32 @@ const TURNSTILE_URL =
|
|||||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||||
const HCAPTCHA_URL = "https://hcaptcha.com/siteverify";
|
const HCAPTCHA_URL = "https://hcaptcha.com/siteverify";
|
||||||
|
|
||||||
|
/** DB first (staff can override live), env fallback for unconfigured deploys. */
|
||||||
|
async function settingOrEnv(
|
||||||
|
settingKey: string,
|
||||||
|
settingDefault: string,
|
||||||
|
envValue: string | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const dbValue = (await siteSettings.get(settingKey, settingDefault)) ?? "";
|
||||||
|
if (dbValue.trim() !== "") return dbValue.trim();
|
||||||
|
return envValue?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
/** Public config the register/login pages need to render the widget (no secrets). */
|
/** Public config the register/login pages need to render the widget (no secrets). */
|
||||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||||
|
// Empty DB fallback distinguishes "unset" from an explicit "none": only an
|
||||||
|
// explicit DB value short-circuits the env fallback.
|
||||||
const provider = (
|
const provider = (
|
||||||
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
await settingOrEnv("captcha_provider", "", env.CAPTCHA_PROVIDER)
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
if (provider === "turnstile") {
|
if (provider === "turnstile") {
|
||||||
return {
|
return {
|
||||||
provider: "turnstile",
|
provider: "turnstile",
|
||||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
siteKey: await settingOrEnv(
|
||||||
|
"turnstile_site_key",
|
||||||
|
"",
|
||||||
|
env.TURNSTILE_SITE_KEY,
|
||||||
|
),
|
||||||
field: "cf-turnstile-response",
|
field: "cf-turnstile-response",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,13 +92,12 @@ export async function verifyCaptcha(
|
|||||||
|
|
||||||
if (!cfg.siteKey) return false;
|
if (!cfg.siteKey) return false;
|
||||||
|
|
||||||
const secretKey: string =
|
const secret =
|
||||||
cfg.provider === "turnstile"
|
cfg.provider === "turnstile"
|
||||||
? "turnstile_secret"
|
? await settingOrEnv("turnstile_secret", "", env.TURNSTILE_SECRET_KEY)
|
||||||
: cfg.provider === "recaptcha"
|
: cfg.provider === "recaptcha"
|
||||||
? "recaptcha_secret"
|
? ((await siteSettings.get("recaptcha_secret", "")) ?? "")
|
||||||
: "hcaptcha_secret";
|
: ((await siteSettings.get("hcaptcha_secret", "")) ?? "");
|
||||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
|
||||||
if (!secret) return false;
|
if (!secret) return false;
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "server-only";
|
|||||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { HOME_PAYLOAD_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
import { cacheNews } from "@/lib/services/news-cache";
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
@@ -47,5 +48,5 @@ export async function loadHomePayload() {
|
|||||||
|
|
||||||
/** Cached read of {@link loadHomePayload} under the route's own key. */
|
/** Cached read of {@link loadHomePayload} under the route's own key. */
|
||||||
export async function cachedHomePayload() {
|
export async function cachedHomePayload() {
|
||||||
return cacheNews(apiCacheKey("home"), 15_000, loadHomePayload);
|
return cacheNews(apiCacheKey("home"), HOME_PAYLOAD_TTL_MS, loadHomePayload);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
|
import { NEWS_ARCHIVE_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { cacheSafe } from "@/lib/redis-cache";
|
import { cacheSafe } from "@/lib/redis-cache";
|
||||||
import { cacheNews } from "@/lib/services/news-cache";
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ import { cacheNews } from "@/lib/services/news-cache";
|
|||||||
* entries immediately, so the window only applies when pub/sub cannot be
|
* entries immediately, so the window only applies when pub/sub cannot be
|
||||||
* delivered.
|
* delivered.
|
||||||
*/
|
*/
|
||||||
const ARCHIVE_TTL_MS = 60_000;
|
const ARCHIVE_TTL_MS = NEWS_ARCHIVE_TTL_MS;
|
||||||
|
|
||||||
async function queryNewsArchive(
|
async function queryNewsArchive(
|
||||||
options: { search?: string; page?: number; order?: string } = {},
|
options: { search?: string; page?: number; order?: string } = {},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { NEWS_LIST_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { cacheNews } from "@/lib/services/news-cache";
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ interface NewsListRow {
|
|||||||
createdAt: Date | null;
|
createdAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTL_MS = 300_000;
|
const TTL_MS = NEWS_LIST_TTL_MS;
|
||||||
const CACHE_KEY = "news_list";
|
const CACHE_KEY = "news_list";
|
||||||
const FETCH_LIMIT = 30;
|
const FETCH_LIMIT = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import "server-only";
|
|||||||
import { and, count, eq, or, sql } from "drizzle-orm";
|
import { and, count, eq, or, sql } from "drizzle-orm";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
||||||
|
import { ONLINE_COUNT_TTL_MS } from "@/lib/perf-ttls";
|
||||||
|
|
||||||
|
export { ONLINE_COUNT_TTL_MS };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Row counters for the public homepage.
|
* Row counters for the public homepage.
|
||||||
@@ -80,9 +83,8 @@ export async function countOnline(): Promise<number> {
|
|||||||
// One function for every caller (homepage, login, register, headers, the client
|
// One function for every caller (homepage, login, register, headers, the client
|
||||||
// page and the warm-up). Each call site used to inline this, and the ones that
|
// page and the warm-up). Each call site used to inline this, and the ones that
|
||||||
// omitted `staleMs` wrote an entry without a grace window, which made the key
|
// omitted `staleMs` wrote an entry without a grace window, which made the key
|
||||||
// block on COUNT(*) at every TTL boundary.
|
// block on COUNT(*) at every TTL boundary. The TTL itself is env-tuned via
|
||||||
export const ONLINE_COUNT_TTL_MS = 10_000;
|
// perf-ttls (CMS_ONLINE_COUNT_TTL_SEC).
|
||||||
|
|
||||||
export async function cachedOnlineCount(): Promise<number> {
|
export async function cachedOnlineCount(): Promise<number> {
|
||||||
return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, {
|
return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, {
|
||||||
staleMs: 15_000,
|
staleMs: 15_000,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
|
import { PUBLIC_SEARCH_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { publicReadFailure } from "@/lib/services/public-read";
|
import { publicReadFailure } from "@/lib/services/public-read";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
|
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
|
||||||
@@ -14,7 +15,7 @@ import { loadNewsArchive } from "@/lib/services/news-archive";
|
|||||||
* directory, and a user who registers or a room that opens should show up
|
* directory, and a user who registers or a room that opens should show up
|
||||||
* without waiting.
|
* without waiting.
|
||||||
*/
|
*/
|
||||||
const SEARCH_TTL_MS = 30_000;
|
const SEARCH_TTL_MS = PUBLIC_SEARCH_TTL_MS;
|
||||||
|
|
||||||
function pageNumber(value: number | undefined, total: number) {
|
function pageNumber(value: number | undefined, total: number) {
|
||||||
return Math.min(
|
return Math.min(
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import "server-only";
|
|||||||
|
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
SITE_SETTINGS_MEMORY_TTL_MS,
|
||||||
|
SITE_SETTINGS_TTL_MS,
|
||||||
|
} from "@/lib/perf-ttls";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
const DEFAULTS: Record<string, string> = {
|
const DEFAULTS: Record<string, string> = {
|
||||||
@@ -11,12 +15,12 @@ const DEFAULTS: Record<string, string> = {
|
|||||||
habbo_gamedata_hotel: "it",
|
habbo_gamedata_hotel: "it",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_TTL_MS = 300_000;
|
const CACHE_TTL_MS = SITE_SETTINGS_TTL_MS;
|
||||||
const REDIS_CACHE_KEY = "site_settings";
|
const REDIS_CACHE_KEY = "site_settings";
|
||||||
// Short in-process window so repeated getters in one request (header, nav,
|
// Short in-process window so repeated getters in one request (header, nav,
|
||||||
// footer all read logo and other settings) don't each pay a Redis round-trip.
|
// footer all read logo and other settings) don't each pay a Redis round-trip.
|
||||||
// Redis stays the source of truth across instances.
|
// Redis stays the source of truth across instances.
|
||||||
const MEMORY_TTL_MS = 60_000;
|
const MEMORY_TTL_MS = SITE_SETTINGS_MEMORY_TTL_MS;
|
||||||
|
|
||||||
// During `next build`, pages are prerendered and `Date.now()` is treated as an
|
// During `next build`, pages are prerendered and `Date.now()` is treated as an
|
||||||
// unstable prerender value — always serve the in-process cache then (settings
|
// unstable prerender value — always serve the in-process cache then (settings
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
// `/imaging` and `/images` are excluded: they are raw image responses that
|
||||||
|
// carry their own long-lived Cache-Control and must not be rewritten by the
|
||||||
|
// HTML/asset cache logic. In production the edge proxy also fronts
|
||||||
|
// `/imaging` straight to the renderer, so the CMS route never sees that
|
||||||
|
// traffic; `/api/imaging/*` (avatars, badges) is NOT excluded and therefore
|
||||||
|
// still passes through the anti-DDoS gate and keeps its asset headers.
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
|
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
test-ci-runner
|
|
||||||
# final test
|
|
||||||
# CI test via Gitea runner
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# clean v5 docker install test
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# CI test na complete herinstallatie
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test nieuwe label aanpak
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test ubuntu-latest alleen
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// test: runner actief
|
|
||||||
// second test
|
|
||||||
// third test - token sync fix
|
|
||||||
// fourth test - token config fix
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test met ubuntu-latest label
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# test na volledige schone installatie v5
|
|
||||||
Reference in new issue
Block a user