1 Commits
Author SHA1 Message Date
remco 6282fd8958 Add renovate.json
CI / check (pull_request) Failing after 23s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-13 05:00:08 +00:00
121 changed files with 1497 additions and 20076 deletions

No files matched your search

-34
View File
@@ -18,21 +18,6 @@ CACHE_MEMORY_MAX_ENTRIES=2000
# every 5 minutes brings an over-budget directory back to 90% of this (default 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 ---
NODE_ENV=production
PORT=3002
@@ -93,18 +78,6 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
# Override for tests/staging (production uses the public endpoint by default).
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 ---
BADGE_UPLOAD_DIR=./public/assets/images/badges
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
@@ -116,13 +89,6 @@ DB_BACKUP_KEEP=7
# Minutes between repeat health-fail alerts from jobs-worker (default 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_HOST=127.0.0.1
RCON_PORT=3003
-72
View File
@@ -24,9 +24,6 @@ jobs:
- name: Toolchain check
run: node scripts/check-node-toolchain.mjs
- name: README version sync
run: node scripts/sync-readme-versions.mjs --check
- name: Deploy port-selection tests
run: bash scripts/ci-deploy-ports.test.sh
@@ -119,75 +116,6 @@ jobs:
test-results/ui/
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:
needs: [tests-unit, tests-integration, tests-ui]
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
-2
View File
@@ -15,8 +15,6 @@ public/cache/
prod.log
*.log
# Imager health-alert state (last-known up/down + timestamp)
logs/imager-health.state
# Database backups
db_backup_*.sql
+8 -123
View File
@@ -11,14 +11,14 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- |
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
| pnpm | 12.11.2 | Pinned via `packageManager` |
| pnpm | 12.10.1 | Pinned via `packageManager` |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
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**.
Core stack: **Next.js 16.4.0** (App Router) · **React 19.3.0** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.3** · **Zod 4.6.5** · **Vitest 5.0.3** · **Biome 2.5.15** · **Playwright 1.63.0**.
> Builds must run through the `pnpm` scripts. This host has no swap and
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
@@ -191,9 +191,7 @@ 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
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.
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.
The updater refuses an active `epicnext-cms-app` CI-managed container.
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
@@ -596,7 +594,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
### Requirements (host)
- 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` 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`.
- 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`.
### Configuration — `/docker/Polaris-imager/.env`
@@ -639,20 +637,9 @@ A production issue that this section documents: **the `avatar-imaging-pixinode`
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
### Figure string validation
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
The URL builder (`src/lib/imager.ts`) and the proxy routes share one rule set
(`src/lib/avatar-figure.ts`): a figure must look like `part-id` segments
separated by dots (`hd-180-1.ch-210-66`). Some clients append extra segments the
renderer still accepts (e.g. a trailing `.0`), so the pattern is deliberately
loose; over-strict validation once turned valid avatars into 400s.
Before a URL is built, the stored look is normalised: anything the imager would
reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a
path-traversal attempt, an over-long value — is replaced with
`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken
avatars without weakening the route, which still answers `400` to a malformed
figure (`src/app/api/imaging/avatar/route.ts`).
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens.
---
@@ -862,78 +849,6 @@ 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)
@@ -983,8 +898,7 @@ that:
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream, and
reports the skip with exit 0 instead of failing the cron.
CI-managed. It now checks both slot containers and the nginx upstream.
- `ci-deploy.sh` retires a compose replica of *this* checkout
(`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx
@@ -1076,7 +990,6 @@ fewer than 1 GB would remain, the script refuses to start.
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
| `pnpm test:ui` | 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 typecheck` | 6 GB | `tsc --noEmit` |
@@ -1144,8 +1057,7 @@ 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:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
| `pnpm test:e2e` | Playwright end-to-end tests |
| `pnpm test:integration` | Integration tests (own Vitest config) |
| `pnpm test:housekeeping` | Housekeeping feature tests |
| `pnpm lint` | Lint and format check (Biome) |
@@ -1161,7 +1073,6 @@ the headroom left for everything else on the machine; the effective ceiling is
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + report per-route bundle sizes |
@@ -1267,13 +1178,6 @@ pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
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)
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
@@ -1309,25 +1213,6 @@ pnpm analyze # Build and report per-route bundle sizes
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
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
-25
View File
@@ -1,25 +0,0 @@
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
-7
View File
@@ -8,13 +8,6 @@
"when": 1788791201804,
"tag": "0000_premium_spirit",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1791828901011,
"tag": "0001_breezy_maestro",
"breakpoints": true
}
]
}
-48
View File
@@ -1,48 +0,0 @@
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);
});
}
-62
View File
@@ -1,62 +0,0 @@
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);
}
-192
View File
@@ -1,192 +0,0 @@
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");
});
-57
View File
@@ -1,57 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

-78
View File
@@ -1,78 +0,0 @@
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");
});
-51
View File
@@ -1,51 +0,0 @@
/**
* 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;
}
-39
View File
@@ -1,39 +0,0 @@
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);
});
-39
View File
@@ -1,39 +0,0 @@
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/");
});
-30
View File
@@ -1,30 +0,0 @@
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",
);
}
});
-58
View File
@@ -1,58 +0,0 @@
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);
});
-24
View File
@@ -1,24 +0,0 @@
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);
});
}
-69
View File
@@ -1,69 +0,0 @@
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);
}
});
});
+14 -21
View File
@@ -11,8 +11,6 @@
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
"start": "next start",
"toolchain:check": "node scripts/check-node-toolchain.mjs",
"readme:versions": "node scripts/sync-readme-versions.mjs",
"readme:versions:check": "node scripts/sync-readme-versions.mjs --check",
"lint": "biome check .",
"biome:lint": "biome check .",
"format": "biome format --write .",
@@ -29,8 +27,6 @@
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"catalog:sync": "bash scripts/catalog-sync.sh",
"catalog:sync:cli": "node --conditions=react-server --import tsx scripts/catalog-sync.ts",
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
@@ -44,9 +40,6 @@
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
"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",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
},
@@ -57,8 +50,8 @@
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.21",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "3.14.19",
"@tanstack/react-query": "5.104.2",
"@tanstack/react-virtual": "3.14.17",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -70,32 +63,32 @@
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.57.0",
"lucide-react": "1.55.0",
"lzma-wasm": "1.0.7",
"motion": "14.2.0",
"music-metadata": "12.2.1",
"mysql2": "3.24.6",
"next": "16.4.1",
"motion": "14.1.0",
"music-metadata": "12.1.1",
"mysql2": "3.24.5",
"next": "16.4.0",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"otplib": "13.5.0",
"pino": "10.4.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.90.0",
"resend": "6.33.0",
"resend": "6.32.1",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
"tailwind-merge": "3.7.0",
"tinymce": "8.10.0",
"tinymce": "8.9.3",
"zod": "4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "1.64.1",
"@playwright/test": "1.64.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/typography": "0.5.20",
@@ -107,9 +100,9 @@
"esbuild": "0.28.2",
"msw": "^3.0.3",
"pino-pretty": "13.2.0",
"postcss": "8.5.30",
"postcss": "8.5.29",
"tailwindcss": "4.3.3",
"testcontainers": "12.2.1",
"testcontainers": "12.2.0",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4",
+1 -8
View File
@@ -1,19 +1,12 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
testIgnore: ["**/ui/**", "**/news-real/**"],
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 30000,
reporter: [["list"], ["html", { open: "never" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure",
-41
View File
@@ -1,41 +0,0 @@
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"] } },
],
});
-29
View File
@@ -1,29 +0,0 @@
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"] } }],
});
+624 -604
View File
File diff suppressed because it is too large. Load diff
-5
View File
@@ -17,8 +17,3 @@ overrides:
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
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'
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
-46
View File
@@ -20,59 +20,13 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
| `perf-report.sh` | Runs performance profiling. |
| `setup-dev.sh` | Configures development environment. |
| `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. |
| `doctor.sh` | Runs a system diagnostic report. |
| `setup-cron.sh` | Configures automated cron jobs. |
| `alert.sh` | Sends notifications to a configured webhook. |
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
## Usage
Run any script directly, or use the dashboard:
```bash
./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`.
### Updating the catalog (`catalog-sync.sh`)
Compares this hotel's catalog against a clone source (Habbo and the retros) and
fixes what is broken: missing furniture icons, missing `.nitro` bundles,
`FurnitureData.json` drift, orphaned/duplicate catalog rows, and items that
exist in `items_base` but have no catalog offer.
```bash
pnpm catalog:sync # interactive menu
./scripts/catalog-sync.sh --list-sources # which sites can be compared
./scripts/catalog-sync.sh --all --sources=default-habbo-com
./scripts/catalog-sync.sh --all --sql --apply-sql # catalogs changes are written
```
The menu asks which sources to compare (Habbo (COM) is the preset) and what may
be written. With no arguments it needs a terminal; with any flag it skips the
menu, so it is usable from cron.
Safety notes:
- **An audit writes nothing** — no repair flag means read-only.
- `--apply-sql` is the only flag that changes the catalog, and the generated SQL
is always written to `storage/catalog-sync/*.sql` (gitignored) so it can be
reviewed or replayed by hand.
- The engine is the same one the admin *Catalog audit* page streams
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
error-severity issues remain, which is what a scheduled check should use.
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env bash
# Update the hotel catalog from a clone source (Habbo, retros, …).
#
# Two ways to use it:
# ./scripts/catalog-sync.sh interactive menu
# ./scripts/catalog-sync.sh --all --sql same flags as the CLI, no menu
#
# The menu asks which sites to compare (Habbo is the preset) and what may be
# written. An audit is read-only; only a repair option touches the catalog, and
# applying the generated catalog SQL always needs its own choice.
cd "$(dirname "$0")/.." || exit 1
CLI="scripts/catalog-sync.ts"
# The react-server export condition resolves `server-only` to its no-op build,
# exactly like `pnpm jobs:worker` — the audit engine imports server modules.
RUN=(node --conditions=react-server --import tsx "$CLI")
# Any flag passed straight through means "no menu", so the same entry point
# works for cron/CI where only the flag path is safe.
if [ $# -gt 0 ]; then
exec "${RUN[@]}" "$@"
fi
if [ ! -t 0 ]; then
echo "Not a terminal. Use the non-interactive form, e.g.:" >&2
echo " $0 --all --sources=default-habbo-com" >&2
exit 1
fi
echo "=== Catalog sync ==="
echo
# ── Step 1: which sites to compare ───────────────────────────────────────────
# Ask the CLI for the configured sources so the menu always matches the live
# `clone_sources` setting (and its defaults) instead of a hardcoded list.
mapfile -t SOURCE_ROWS < <("${RUN[@]}" --list-sources --tsv)
if [ "${#SOURCE_ROWS[@]}" -eq 0 ]; then
echo "No clone sources are configured." >&2
exit 1
fi
IDS=()
NAMES=()
echo "Compare this catalog against:"
for i in "${!SOURCE_ROWS[@]}"; do
IFS=$'\t' read -r SOURCE_ID SOURCE_NAME _SOURCE_URL <<<"${SOURCE_ROWS[$i]}"
IDS+=("$SOURCE_ID")
NAMES+=("$SOURCE_NAME")
printf " %2d) %-24s %s\n" "$((i + 1))" "$SOURCE_NAME" "$SOURCE_ID"
done
echo
echo " a) all sources above"
echo " Enter) preset: ${NAMES[0]}"
read -r -p "Sources [a, 1-${#IDS[@]}, Enter]: " SOURCE_CHOICE
SELECTED_FLAG=""
case "$SOURCE_CHOICE" in
"" | "Habbo" | "habbo" | "habbo.com")
# Habbo is the first default preset (Habbo (COM)).
SELECTED_FLAG="--sources=${IDS[0]}"
SOURCE_LABEL="${NAMES[0]}"
;;
a | A | all)
SOURCE_LABEL="all sources"
;;
*)
SELECTED_IDS=()
IFS=', ' read -r -a CHOICES <<<"$SOURCE_CHOICE"
for CHOICE in "${CHOICES[@]}"; do
[ -z "$CHOICE" ] && continue
case "$CHOICE" in
*[!0-9]* | "")
echo "Invalid choice: '$CHOICE'" >&2
exit 1
;;
*)
if [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#IDS[@]}" ]; then
echo "Out of range: $CHOICE" >&2
exit 1
fi
SELECTED_IDS+=("${IDS[$((CHOICE - 1))]}")
;;
esac
done
if [ "${#SELECTED_IDS[@]}" -eq 0 ]; then
echo "No sources selected." >&2
exit 1
fi
SELECTED_FLAG="--sources=$(IFS=,; echo "${SELECTED_IDS[*]}")"
SOURCE_LABEL="${#SELECTED_IDS[@]} source(s)"
;;
esac
# ── Step 2: what may be changed ──────────────────────────────────────────────
cat <<EOF
What should be done? (comparing with: $SOURCE_LABEL)
1) Audit only — nothing is written
2) Repair furniture icons + nitro bundles
3) 2 + FurnitureData.json + catalog structure
4) 3 + generate the catalog SQL (saved, not applied)
5) 4 + APPLY the catalog SQL to the catalog
q) Cancel
EOF
read -r -p "Action [1]: " ACTION_CHOICE
ACTION_CHOICE="${ACTION_CHOICE:-1}"
ACTIONS=(audit)
CONFIRM=""
# Empty for the audit-only branch on purpose: no repair flags are passed.
ACTION_FLAGS=()
case "$ACTION_CHOICE" in
1)
ACTIONS=(audit "[nothing written]")
;;
2)
ACTIONS=(audit "icons + nitro bundles downloaded")
ACTION_FLAGS=(--icons --nitros)
;;
3)
ACTIONS=(audit "icons + nitro bundles + FurnitureData.json + structure")
ACTION_FLAGS=(--all)
;;
4)
ACTIONS=(audit "everything + catalog SQL generated")
ACTION_FLAGS=(--all --sql)
;;
5)
ACTIONS=(audit "everything + catalog SQL APPLIED")
ACTION_FLAGS=(--all --sql --apply-sql)
CONFIRM="This writes to the catalog. Continue? [y/N]: "
;;
q | Q)
echo "Cancelled."
exit 0
;;
*)
echo "Invalid action: '$ACTION_CHOICE'" >&2
exit 1
;;
esac
echo
printf 'Running: %s against %s\n' "${ACTIONS[1]}" "$SOURCE_LABEL"
if [ -n "$CONFIRM" ]; then
read -r -p "$CONFIRM" REPLY
if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then
echo "Cancelled."
exit 0
fi
fi
echo
# shellcheck disable=SC2086
"${RUN[@]}" $SELECTED_FLAG "${ACTION_FLAGS[@]}"
-114
View File
@@ -1,114 +0,0 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_CATALOG_SYNC_FLAGS,
describeActions,
parseCatalogSyncArgs,
writeGeneratedSql,
} from "./catalog-sync";
describe("catalog-sync argument parsing", () => {
it("defaults to a read-only audit with no filters", () => {
expect(parseCatalogSyncArgs([])).toEqual({
...DEFAULT_CATALOG_SYNC_FLAGS,
sourceIds: [],
});
expect(describeActions(parseCatalogSyncArgs([]))).toBe("audit");
});
it("accepts comma-separated source ids", () => {
const flags = parseCatalogSyncArgs([
"--sources=default-habbo-com,default-wibbo",
]);
expect(flags.sourceIds).toEqual(["default-habbo-com", "default-wibbo"]);
});
it("accepts display names, including ones with spaces", () => {
const flags = parseCatalogSyncArgs(["--sources=Habbo (COM), Wibbo"]);
expect(flags.sourceIds).toEqual(["Habbo (COM)", "Wibbo"]);
});
it("trims and drops empty source entries", () => {
expect(parseCatalogSyncArgs(["--sources= a , ,b,"]).sourceIds).toEqual([
"a",
"b",
]);
});
it("turns --all into every repair step", () => {
const flags = parseCatalogSyncArgs(["--all"]);
expect(flags.repairIcons).toBe(true);
expect(flags.repairNitros).toBe(true);
expect(flags.repairFurniData).toBe(true);
expect(flags.repairStructure).toBe(true);
expect(flags.checkFurniDataIds).toBe(true);
// Generating/applying SQL is never implied by a repair.
expect(flags.generateSql).toBe(false);
expect(flags.applySql).toBe(false);
});
it("treats applying SQL as implying generation", () => {
expect(parseCatalogSyncArgs(["--apply-sql"]).generateSql).toBe(true);
expect(parseCatalogSyncArgs(["--organize-sql"]).generateSql).toBe(true);
expect(describeActions(parseCatalogSyncArgs(["--apply-sql"]))).toBe(
"audit + apply-sql",
);
});
it("accepts the alias spellings of the repair flags", () => {
const flags = parseCatalogSyncArgs([
"--repair-icons",
"--repair-nitros",
"--repair-furnidata",
"--repair-structure",
]);
expect(flags.repairIcons).toBe(true);
expect(flags.repairNitros).toBe(true);
expect(flags.repairFurniData).toBe(true);
expect(flags.repairStructure).toBe(true);
});
it("ignores a bare -- separator", () => {
expect(parseCatalogSyncArgs(["--", "--icons"]).repairIcons).toBe(true);
});
it("rejects an unknown flag", () => {
expect(() => parseCatalogSyncArgs(["--nope"])).toThrow(/Unknown flag/);
});
it("rejects --sources without a value", () => {
expect(() => parseCatalogSyncArgs(["--sources"])).toThrow(/needs a value/);
});
});
describe("generated catalog SQL output", () => {
let cwd = "";
beforeEach(() => {
cwd = process.cwd();
});
afterEach(() => {
vi.restoreAllMocks();
process.chdir(cwd);
});
it("writes the SQL under storage/catalog-sync with a trailing newline", async () => {
let target = "";
try {
const dir = mkdtempSync(join(tmpdir(), "catalog-sync-sql-"));
target = dir;
process.chdir(dir);
const file = await writeGeneratedSql("INSERT INTO `catalog_items` (1);");
expect(file.startsWith("storage/catalog-sync/")).toBe(true);
expect(file.endsWith(".sql")).toBe(true);
expect(readFileSync(join(dir, file), "utf8")).toBe(
"INSERT INTO `catalog_items` (1);\n",
);
} finally {
if (target) rmSync(target, { recursive: true, force: true });
}
});
});
-423
View File
@@ -1,423 +0,0 @@
// Command-line driver for the catalog audit/repair pipeline.
//
// The same engine the admin "Catalog audit" UI streams from (`runCatalogAudit`),
// for the terminal: audit the hotel catalog against one or more clone sources
// (Habbo and the retros), fix the furniture that is broken, and bring missing
// items into the catalog — optionally writing and applying the catalog SQL.
//
// Usage:
// pnpm catalog:sync # interactive menu (scripts/catalog-sync.sh)
// pnpm exec tsx scripts/catalog-sync.ts --list-sources
// pnpm exec tsx scripts/catalog-sync.ts --sources="Habbo (COM)" # audit only
// pnpm exec tsx scripts/catalog-sync.ts --all --sources=default-habbo-com
// pnpm exec tsx scripts/catalog-sync.ts --all --sql --apply-sql # + write/apply SQL
//
// Flags:
// --list-sources print the configured clone sources and exit
// --tsv with the above, print `id\tname\tfurnidataUrl` rows
// --sources=<a,b> compare against these sources (id or name, comma-separated)
// --icons re-download missing furniture icons from the sources
// --nitros re-download missing .nitro / swf bundles
// --furnidata repair FurnitureData.json (add missing, drop duplicates)
// --structure repair orphaned / duplicate catalog rows
// --check-ids check FurnitureData.json for spriteId conflicts
// --sql generate the catalog SQL for items missing an offer
// --apply-sql apply the generated SQL (implies --sql)
// --organize-sql order the generated SQL by catalog page (implies --sql)
// --all --icons --nitros --furnidata --structure --check-ids
// --json print one machine-readable JSON report
// --fail-on-errors exit 3 when error-severity issues remain
//
// Audit is read-only by default: nothing is written until one of the repair
// flags is passed, and `--apply-sql` is the only flag that changes the catalog.
import "./load-env";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { AuditEvent, AuditSummary } from "@/lib/services/catalog-audit";
/** Where generated catalog SQL is written (`storage/` is gitignored). */
function sqlOutputDir(): string {
return join(process.cwd(), "storage", "catalog-sync");
}
export type SourceRef = { id: string; name: string; furnidataUrl: string };
export type AuditIssue = NonNullable<AuditEvent["issues"]>[number];
export type MissingFromSource = NonNullable<
AuditEvent["missingFromSources"]
>[number];
export interface AuditReport {
sources: SourceRef[];
summary?: AuditSummary;
issues: AuditIssue[];
missingFromSources: MissingFromSource[];
sqlFile?: string;
}
export interface CatalogSyncFlags {
listSources: boolean;
tsv: boolean;
sourceIds: string[];
repairIcons: boolean;
repairNitros: boolean;
repairFurniData: boolean;
repairStructure: boolean;
checkFurniDataIds: boolean;
generateSql: boolean;
applySql: boolean;
organizeSql: boolean;
json: boolean;
failOnErrors: boolean;
}
export const DEFAULT_CATALOG_SYNC_FLAGS: Omit<CatalogSyncFlags, "sourceIds"> = {
listSources: false,
tsv: false,
repairIcons: false,
repairNitros: false,
repairFurniData: false,
repairStructure: false,
checkFurniDataIds: false,
generateSql: false,
applySql: false,
organizeSql: false,
json: false,
failOnErrors: false,
};
const BOOLEAN_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--list-sources": "listSources",
"--tsv": "tsv",
"--icons": "repairIcons",
"--repair-icons": "repairIcons",
"--nitros": "repairNitros",
"--repair-nitros": "repairNitros",
"--furnidata": "repairFurniData",
"--repair-furnidata": "repairFurniData",
"--structure": "repairStructure",
"--repair-structure": "repairStructure",
"--check-ids": "checkFurniDataIds",
"--sql": "generateSql",
"--apply-sql": "applySql",
"--organize-sql": "organizeSql",
"--json": "json",
"--fail-on-errors": "failOnErrors",
};
export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
const flags: CatalogSyncFlags = {
...DEFAULT_CATALOG_SYNC_FLAGS,
sourceIds: [],
};
for (const arg of raw) {
if (arg === "--") continue;
if (arg === "--all") {
flags.repairIcons = true;
flags.repairNitros = true;
flags.repairFurniData = true;
flags.repairStructure = true;
flags.checkFurniDataIds = true;
continue;
}
const separator = arg.indexOf("=");
const name = separator >= 0 ? arg.slice(0, separator) : arg;
const booleanFlag = BOOLEAN_FLAGS[name];
if (booleanFlag) {
flags[booleanFlag] = true as never;
continue;
}
if (name === "--sources" || name === "--source") {
if (separator < 0)
throw new Error(
`${name} needs a value, e.g. --sources=default-habbo-com`,
);
const value = arg.slice(separator + 1);
for (const part of value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean))
flags.sourceIds.push(part);
continue;
}
throw new Error(
`Unknown flag: ${arg} (run with --help for the supported flags)`,
);
}
// Applying SQL implies generating it, and organising it implies generating.
if (flags.applySql) flags.generateSql = true;
if (flags.organizeSql) flags.generateSql = true;
return flags;
}
/** Human-readable list of what the run will do. */
export function describeActions(flags: CatalogSyncFlags): string {
const actions = ["audit"];
if (flags.repairIcons) actions.push("icons");
if (flags.repairNitros) actions.push("nitros");
if (flags.repairFurniData) actions.push("furnidata");
if (flags.repairStructure) actions.push("structure");
if (flags.checkFurniDataIds) actions.push("id-check");
if (flags.applySql) actions.push("apply-sql");
else if (flags.generateSql) actions.push("generate-sql");
if (flags.organizeSql) actions.push("organised");
return actions.join(" + ");
}
/** True when the file is being executed, not imported by a test. */
function isEntryPoint(): boolean {
const entry = process.argv[1];
return (
Boolean(entry) && import.meta.url === pathToFileURL(String(entry)).href
);
}
async function loadSourceCatalogue(): Promise<SourceRef[]> {
const { listSources } = await import("@/lib/services/clone-sources");
const sources = await listSources();
return sources
.filter((source) => source.furnidataUrl)
.map((source) => ({
id: source.id,
name: source.name,
furnidataUrl: source.furnidataUrl,
}));
}
async function commandListSources(flags: CatalogSyncFlags): Promise<number> {
const sources = await loadSourceCatalogue();
if (flags.tsv) {
for (const source of sources)
console.log([source.id, source.name, source.furnidataUrl].join("\t"));
return 0;
}
console.log(`Configured clone sources (${sources.length}):`);
for (const source of sources)
console.log(` ${source.id}\t${source.name}\t${source.furnidataUrl}`);
console.log(
"\nCompare with: --sources=<id> (comma-separated; display names work too)",
);
return 0;
}
/** Persist the generated SQL so it can be reviewed or replayed by hand. */
export async function writeGeneratedSql(sql: string): Promise<string> {
await mkdir(sqlOutputDir(), { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const file = join(sqlOutputDir(), `catalog-${stamp}.sql`);
await writeFile(file, sql.endsWith("\n") ? sql : `${sql}\n`, "utf8");
return file.replace(`${process.cwd()}/`, "");
}
export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
const catalogue = await loadSourceCatalogue();
// Resolve the selection up-front so a typo fails before anything is written.
const known = new Set<string>();
for (const source of catalogue) {
known.add(source.id);
known.add(source.name);
}
const unknown = flags.sourceIds.filter((id) => !known.has(id));
if (unknown.length > 0) {
console.error(
`Unknown source: ${unknown.join(", ")}\nRun --list-sources to see the configured sites.`,
);
return 1;
}
const selected = flags.sourceIds.length
? catalogue.filter(
(source) =>
flags.sourceIds.includes(source.id) ||
flags.sourceIds.includes(source.name),
)
: catalogue;
const { runCatalogAudit } = await import("@/lib/services/catalog-audit");
const report: AuditReport = {
sources: selected,
issues: [],
missingFromSources: [],
};
const quiet = flags.json;
const log = (line: string) => {
if (!quiet) console.log(line);
};
const started = Date.now();
let pendingSql: string | undefined;
log(
`Catalog sync — ${selected.length} source(s), ${describeActions(flags)}` +
(flags.applySql ? " [SQL WILL BE APPLIED]" : ""),
);
await runCatalogAudit(
(event) => {
switch (event.type) {
case "progress":
log(`· ${event.message ?? ""}`);
break;
case "items_loaded":
log(`· items_base: ${event.total} rows`);
break;
case "catalog_loaded":
log(`· catalog_items: ${event.total} rows`);
break;
case "sql_ready":
log(`· catalog SQL generated: ${event.sqlCount ?? 0} statement(s)`);
pendingSql = event.sql;
break;
case "sql_applied":
log(
`· catalog SQL applied: ${event.applied ?? 0} (failed ${event.sqlFailed ?? 0})`,
);
break;
case "furnidata_repair_complete":
log(
`· FurnitureData.json: +${event.furniData?.added ?? 0} entries, ${event.furniData?.removedDuplicates ?? 0} duplicate(s) removed`,
);
break;
case "structure_repair_complete":
log(
`· structure: ${event.structure?.orphanedRemoved ?? 0} orphaned removed, ${event.structure?.duplicatesMerged ?? 0} duplicate(s) merged, ${event.structure?.remappedReferences ?? 0} reference(s) remapped`,
);
break;
case "repair_complete":
log(
`· asset repair: ${event.repair?.succeeded ?? 0} ok, ${event.repair?.failed ?? 0} failed, ${event.repair?.skipped ?? 0} skipped`,
);
break;
case "batch_complete":
report.issues = event.issues ?? [];
report.missingFromSources = event.missingFromSources ?? [];
report.summary = event.summary;
break;
case "error":
console.error(`! ${event.message ?? "audit error"}`);
break;
default:
break;
}
},
{
sourceIds: flags.sourceIds,
repair: flags.repairIcons,
repairNitros: flags.repairNitros,
sql: flags.generateSql,
applySql: flags.applySql,
organizeSql: flags.organizeSql,
repairFurniData: flags.repairFurniData,
repairStructure: flags.repairStructure,
checkFurniDataIds: flags.checkFurniDataIds,
},
);
if (pendingSql?.trim()) report.sqlFile = await writeGeneratedSql(pendingSql);
if (flags.json) {
console.log(
JSON.stringify(
{ flags, elapsedMs: Date.now() - started, ...report },
null,
2,
),
);
} else {
printSummary(report, started);
if (report.sqlFile) console.log(`Catalog SQL: ${report.sqlFile}`);
}
const errors = report.issues.filter((issue) => issue.severity === "error");
if (flags.failOnErrors && errors.length > 0) {
if (!quiet) console.error(`${errors.length} error(s) remain unresolved.`);
return 3;
}
return 0;
}
function printSummary(report: AuditReport, started: number): void {
const summary = report.summary;
const seconds = ((Date.now() - started) / 1000).toFixed(1);
console.log(`\nDone in ${seconds}s`);
if (!summary) {
console.log("No summary was produced (the audit stopped early).");
return;
}
console.log(` items_base ${summary.totalItemsBase}`);
console.log(` catalog_items ${summary.totalCatalogItems}`);
console.log(` missing catalog entry ${summary.missingCatalog}`);
console.log(` orphaned catalog rows ${summary.orphanedCatalog}`);
console.log(` duplicate classnames ${summary.duplicates}`);
console.log(
` missing icons ${summary.missingIcon} (repaired ${summary.iconsRepaired}, still missing ${summary.iconsStillMissing})`,
);
console.log(
` missing nitro bundles ${summary.missingNitro} (repaired ${summary.nitrosRepaired}, still missing ${summary.nitrosStillMissing})`,
);
console.log(
` catalog SQL ${summary.catalogSqlCount} stmt, applied ${summary.catalogSqlApplied}`,
);
console.log(` furnidata added ${summary.furniDataAdded}`);
console.log(
` not restorable ${summary.unrepairableCount} (no icon/bundle on any compared source)`,
);
if (report.missingFromSources.length > 0) {
const bySource = new Map<string, number>();
for (const entry of report.missingFromSources)
bySource.set(entry.sourceName, (bySource.get(entry.sourceName) ?? 0) + 1);
console.log(
`\n on compared sources but not in this hotel: ${report.missingFromSources.length}`,
);
for (const [name, count] of bySource)
console.log(` ${name.padEnd(24)} ${count}`);
}
const errors = report.issues.filter((issue) => issue.severity === "error");
if (errors.length > 0) {
console.log(`\n unresolved errors: ${errors.length}`);
for (const issue of errors.slice(0, 20))
console.log(` ${issue.classname ?? issue.type}: ${issue.message}`);
if (errors.length > 20) console.log(` … and ${errors.length - 20} more`);
}
}
async function main(): Promise<number> {
const flags = parseCatalogSyncArgs(process.argv.slice(2));
try {
if (flags.listSources) return await commandListSources(flags);
return await commandRun(flags);
} catch (error) {
console.error(
`Catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
if (isEntryPoint()) {
main().then(
(code) => {
process.exit(code);
},
(error) => {
console.error(error);
process.exit(1);
},
);
}
+4 -12
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# Create/update the Cloudflare Cache Rule that stores the CMS public API
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
# 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).
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`).
#
# Why a rule is required: Cloudflare only caches a handful of file extensions
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
@@ -37,18 +35,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
BASE="https://api.cloudflare.com/client/v4"
PHASE="http_request_cache_settings"
DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
DESCRIPTION="EpicNabbo CMS public API + gamedata edge cache (cms-public, cms-gamedata)"
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
# `starts_with()`.
#
# `/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/"))'
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/"))'
load_env() {
local name="$1"
+1 -21
View File
@@ -410,10 +410,7 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
fi
pnpm install --frozen-lockfile
# 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
pnpm exec playwright install chromium
echo "Building $image"
# No --network=host: BuildKit only grants it via --allow=network.host, and the
@@ -539,18 +536,6 @@ if [ "$blue_green" -eq 1 ]; then
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
# 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.
cutover_started=1
switch_upstream "$new_port"
@@ -584,11 +569,6 @@ else
healthy 3002
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
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)"
fi
docker tag "$verified_image" "epicnext-cms:verified-$sha"
+2 -5
View File
@@ -11,9 +11,6 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
mkdir -p "$(dirname "$LOG_FILE")"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
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=""
remote_migration_image=""
previous_image=""
@@ -75,11 +72,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.
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
skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
fi
for slot_container in epicnext-cms-app epicnext-cms-green; do
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
fi
done
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
-72
View File
@@ -1,72 +0,0 @@
#!/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
-141
View File
@@ -1,141 +0,0 @@
#!/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();
-113
View File
@@ -1,113 +0,0 @@
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**.",
);
});
});
-86
View File
@@ -10,7 +10,6 @@ import {
restoreDeletedCatalogItemsCommand,
} from "@/features/catalog/server/item-deletes";
import {
bulkUpdateOffersCommand,
createOfferCommand,
moveBcOffersCommand,
moveOffersCommand,
@@ -400,91 +399,6 @@ 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: {
/** `id` is items_base.id (not catalog_items.id) */
items: Array<{ id: number; publicName: string; description?: string }>;
+1 -1
View File
@@ -146,7 +146,7 @@ describe("register", () => {
accountCreated: expect.any(Number),
ipRegister: "203.0.113.9",
ipCurrent: "203.0.113.9",
look: "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
look: "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
termsAccepted: true,
});
expect(state.invalidateKey).toHaveBeenCalledWith("login:user:Alice_123");
+1 -5
View File
@@ -5,7 +5,6 @@ import { after } from "next/server";
import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger";
@@ -170,10 +169,7 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
};
// A valid starter Habbo figure so the avatar renders in-client immediately.
// Shared with the imager URL builder, which normalises malformed stored looks
// to the same figure (the hair part needs a colour slot: `hr-100-1`, not
// `hr-100-`).
const DEFAULT_LOOK = DEFAULT_AVATAR_FIGURE;
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export interface RegisterState {
error: string | null;
-1
View File
@@ -99,7 +99,6 @@ async function NewsPage({
fill
sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }}
unoptimized
/>
</div>
) : (
+23 -34
View File
@@ -170,9 +170,12 @@ function GlassPanel({
}
async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [
hotelName,
logo,
online,
users,
rooms,
@@ -182,8 +185,6 @@ async function getHotelData() {
recentUsers,
recentPhotos,
] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"),
@@ -242,34 +243,22 @@ export default async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
const [
tp,
te,
th,
tn,
{
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
},
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
const tp = await getTranslations("pages.home");
const te = await getTranslations("publicData");
const th = await getTranslations("header");
const tn = await getTranslations("nav");
const {
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
} = await getHotelData();
// Users who hide their online state must not surface in the "who is online"
// rails on the homepage either.
@@ -282,7 +271,8 @@ export default async function Home() {
: rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false,
);
const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const captcha = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
const features = [
{
@@ -325,7 +315,6 @@ export default async function Home() {
fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/>
<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"
+9 -10
View File
@@ -1,8 +1,5 @@
import { ClipboardCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioAuditPage(_props: {
@@ -12,14 +9,16 @@ export default async function StudioAuditPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={ClipboardCheck}
title={t("nav.catalogAudit")}
subtitle={t("pages.auditSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
<p className="mt-1 text-muted-foreground">
Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources.
</p>
</div>
<AuditClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { BadgeCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioBadgesPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioBadgesPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={BadgeCheck}
title={t("nav.badges")}
subtitle={t("pages.badgesSubtitle")}
>
<div className="p-4">
<ImportBadgesClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Copy } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClonePage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioClonePage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Copy}
title={t("nav.importFromSource")}
subtitle={t("pages.cloneSubtitle")}
>
<div className="p-4">
<ImportCloneClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Shirt } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioClothingPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Shirt}
title={t("nav.clothing")}
subtitle={t("pages.clothingSubtitle")}
>
<div className="p-4">
<ImportClothingClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Dumbbell } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioEffectsPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioEffectsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Dumbbell}
title={t("nav.effects")}
subtitle={t("pages.effectsSubtitle")}
>
<div className="p-4">
<ImportEffectsClient />
</StudioPage>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state for any Catalog Studio sub-page. Mirrors the StudioPage
* header (icon tile, title, subtitle) and a body block so navigation between
* studio tools shows a stable skeleton instead of a blank pause.
*/
export default function StudioLoading() {
return (
<div className="p-4">
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-xl bg-[var(--admin-accent)]/10" />
<div className="min-w-0">
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-72 max-w-full" />
</div>
</div>
</div>
<div className="space-y-3 rounded-xl border border-[var(--admin-border)] p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-9 w-32" />
</div>
</div>
</div>
);
}
+14 -23
View File
@@ -1,9 +1,7 @@
import { Wrench } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() {
@@ -18,27 +16,20 @@ export default async function StudioCatalogMaintenancePage() {
session.user.rank,
);
const t = await getTranslations("pages.admin.catalog.integrity");
const studioT = await getTranslations("admin.studio");
return (
<StudioPage
icon={Wrench}
title={studioT("nav.catalogMaintenance")}
subtitle={studioT("pages.maintenanceSubtitle")}
>
<div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
</StudioPage>
<div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
);
}
+9 -10
View File
@@ -1,8 +1,5 @@
import { FileX2 } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioNitroCleanupPage() {
@@ -10,14 +7,16 @@ export default async function StudioNitroCleanupPage() {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={FileX2}
title={t("nav.nitroCleanup")}
subtitle={t("pages.nitroCleanupSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nitro cleanup</h1>
<p className="mt-1 text-muted-foreground">
Scan the nitro directories for fake bundles (no matching furniture)
and broken bundles (unparseable) and remove them.
</p>
</div>
<NitroCleanupPanel />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Cat } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioPetsPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioPetsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Cat}
title={t("nav.pets")}
subtitle={t("pages.petsSubtitle")}
>
<div className="p-4">
<ImportPetsClient />
</StudioPage>
</div>
);
}
+12 -10
View File
@@ -1,8 +1,5 @@
import { Image as ImageIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioRepairIconsPage(_props: {
@@ -12,14 +9,19 @@ export default async function StudioRepairIconsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={ImageIcon}
title={t("pages.repairIconsTitle")}
subtitle={t("pages.repairIconsSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="mt-1 text-muted-foreground">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<RepairIconsClient />
</StudioPage>
</div>
);
}
+28 -35
View File
@@ -17,7 +17,6 @@ import {
Wrench,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
@@ -29,38 +28,34 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type StudioLink = { href: string; labelKey: string; icon: LucideIcon };
type StudioLink = { href: string; label: string; icon: LucideIcon };
const assets: StudioLink[] = [
{ href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle },
{ href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt },
{ href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell },
{ href: "/admin/studio/pets", labelKey: "pets", icon: Cat },
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
];
const imports: StudioLink[] = [
{ href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload },
{ href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy },
{ href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw },
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw },
];
const maintenance: StudioLink[] = [
{
href: "/admin/studio/audit",
labelKey: "catalogAudit",
icon: ClipboardCheck,
},
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck },
{
href: "/admin/studio/repair-icons",
labelKey: "repairIcons",
label: "Repair icons",
icon: ImageIcon,
},
{
href: "/admin/studio/maintenance",
labelKey: "catalogMaintenance",
label: "Catalog maintenance",
icon: Wrench,
},
{
href: "/admin/studio/nitro-cleanup",
labelKey: "nitroCleanup",
label: "Nitro cleanup",
icon: FileX2,
},
];
@@ -76,7 +71,6 @@ function StudioMenu({
links: StudioLink[];
pathname: string;
}) {
const t = useTranslations("admin.studio");
const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href));
return (
@@ -96,7 +90,7 @@ function StudioMenu({
align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
>
{links.map(({ href, labelKey, icon: Icon }) => (
{links.map(({ href, label: title, icon: Icon }) => (
<Link
key={href}
href={href}
@@ -109,8 +103,8 @@ function StudioMenu({
aria-current={activePath(pathname, href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15"
>
<Icon size={16} aria-hidden />
{t(`nav.${labelKey}`)}
<Icon size={16} />
{title}
</Link>
))}
</PopoverContent>
@@ -126,15 +120,14 @@ export function StudioLayoutClient({
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const t = useTranslations("admin.studio");
return (
<div className="flex h-[calc(100dvh-7rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] sm:min-h-[600px]">
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<nav
aria-label={t("nav.ariaLabel")}
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
aria-label="Catalog Studio"
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
>
<div className="flex shrink-0 gap-1">
{assets.map(({ href, labelKey, icon: Icon }) => (
<div className="flex flex-wrap gap-1">
{assets.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
@@ -145,35 +138,35 @@ export function StudioLayoutClient({
}}
aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn(
"flex min-h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm font-medium transition-colors",
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Icon size={16} aria-hidden />
{t(`nav.${labelKey}`)}
<Icon size={16} />
{label}
</Link>
))}
</div>
<div className="ml-auto flex shrink-0 gap-1 pl-1">
<div className="flex flex-wrap gap-1">
{canEditCatalog && (
<>
<CatalogManagerDialog
catalogType="normal"
canEdit
triggerLabel={t("nav.catalogManager")}
triggerLabel="Catalog manager"
/>
<OrganizeImportsLauncher />
</>
)}
<StudioMenu
label={t("nav.importSync")}
label="Import & sync"
links={imports}
pathname={pathname}
/>
<StudioMenu
label={t("nav.maintenance")}
label="Maintenance"
links={maintenance}
pathname={pathname}
/>
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -14,7 +13,6 @@ interface Configuration {
hasToken: boolean;
}
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const t = useTranslations("admin.studio.sync");
const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""),
[busy, setBusy] = useState(false),
@@ -28,7 +26,10 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
signal: abort.signal,
cache: "no-store",
});
if (!res.ok) throw new Error(t("loadFailed"));
if (!res.ok)
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json();
if (!abort.signal.aborted) setConfig(data);
} catch (error) {
@@ -36,7 +37,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
}
})();
return () => abort.abort();
}, [t]);
}, []);
async function submit(action: "save" | "test") {
if (!config || busy) return;
setBusy(true);
@@ -49,11 +50,12 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
body: JSON.stringify({ ...config, token, action }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || t("requestFailed"));
if (!res.ok)
throw new Error(data.error || "Git configuration request failed");
if (action === "save") {
setConfig(data);
setToken("");
setMessage(t("saved"));
setMessage("Gitea configuration saved.");
onSaved();
} else setMessage(data.message);
} catch (error) {
@@ -68,18 +70,19 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
open
>
<summary className="cursor-pointer font-semibold">
{t("gitTitle")}
Gitea connection settings
</summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]">
{t("gitIntro")}
Connect your self-hosted Gitea repository over HTTPS. The CMS manages
its own checkout; no Git-specific .env settings are needed.
</p>
{config && (
<fieldset disabled={busy} className="space-y-3">
<p className="text-sm">
{t("providerLabel")} <strong>{t("providerValue")}</strong>
Provider: <strong>Gitea self-hosted</strong>
</p>
<label htmlFor="catalog-git-1" className="block text-sm">
{t("repositoryUrl")}
Repository URL
<Input
id="catalog-git-1"
type="url"
@@ -91,7 +94,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm">
{t("branch")}
Branch
<Input
id="catalog-git-2"
value={config.branch}
@@ -101,7 +104,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
/>
</label>
<label htmlFor="catalog-git-3" className="block text-sm">
{t("username")}
Gitea username
<Input
id="catalog-git-3"
autoComplete="off"
@@ -113,18 +116,23 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label>
</div>
<label htmlFor="catalog-git-4" className="block text-sm">
{t("accessToken")}
Gitea access token
<Input
id="catalog-git-4"
type="password"
autoComplete="new-password"
value={token}
placeholder={config.hasToken ? t("tokenSaved") : t("tokenEnter")}
placeholder={
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<p className="text-xs text-[var(--admin-text-muted)]">
{t("tokenNote")}
The token is stored encrypted and is never returned to the browser.
Changing repository or username requires entering the token again.
</p>
<label className="flex items-center gap-2 text-sm">
<input
@@ -134,14 +142,14 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
setConfig({ ...config, enabled: event.target.checked })
}
/>
{t("autoExport")}
Automatically export catalog changes
</label>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}>
{t("testConnection")}
Test connection
</Button>
<Button onClick={() => void submit("save")}>
{busy ? t("working") : t("saveSettings")}
{busy ? "Working…" : "Save Gitea settings"}
</Button>
</div>
</fieldset>
+29 -30
View File
@@ -1,9 +1,6 @@
import { RefreshCw } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client";
@@ -15,42 +12,44 @@ export default async function StudioSyncPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={RefreshCw}
title={t("nav.syncRepository")}
subtitle={t("sync.pageSubtitle")}
>
<div className="space-y-6">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<div className="space-y-8">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-2">
Scan configured sources and automatically import any furniture that is
not yet present in your hotel.
</p>
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">
{t("sync.cloneSourcesTitle")}
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
Clone Sources
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("sync.cloneSourcesBody")}
<p className="text-sm text-muted-foreground">
Scan all configured clone sources and automatically import any
furniture that is not yet present in your hotel.
</p>
<SyncAllClient />
</section>
</div>
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">
{t("sync.officialFurnidataTitle")}
<div>
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
Official Furnidata
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("sync.officialFurnidataBody")}
<p className="text-sm text-muted-foreground">
Scan the official Habbo furnidata and automatically import any
furniture that your hotel doesn't have yet.
</p>
<OfficialSyncClient />
</section>
</div>
</div>
</StudioPage>
</div>
);
}
+12 -10
View File
@@ -1,8 +1,5 @@
import { Upload } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioUploadPage(_props: {
@@ -12,14 +9,19 @@ export default async function StudioUploadPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Upload}
title={t("pages.uploadTitle")}
subtitle={t("pages.uploadSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .hab Furniture
</h1>
<p className="mt-1 text-muted-foreground">
Upload a .hab bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<UploadFurniClient />
</StudioPage>
</div>
);
}
+4 -37
View File
@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import {
fetchAvatarImage,
ImagerUnavailableError,
@@ -21,16 +20,12 @@ function cacheControlFor(source: string): string {
switch (source) {
case "fallback":
case "cache-stale":
// Reuse the render briefly but revalidate the upstream soon. The
// 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";
// Reuse the render briefly but revalidate the upstream soon.
return "public, max-age=300";
case "unavailable":
return "no-cache, max-age=0";
default:
// 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";
return "public, max-age=3600";
}
}
@@ -48,52 +43,24 @@ export async function GET(request: NextRequest) {
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, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControl,
ETag: etag,
"Cache-Control": cacheControlFor(source),
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
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, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
+21 -90
View File
@@ -7,7 +7,6 @@ import {
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings";
@@ -34,64 +33,6 @@ const BADGE_PLACEHOLDER_SVG =
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) {
const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
@@ -115,20 +56,25 @@ export async function GET(request: NextRequest) {
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached)
return badgeResponse(
cached.body,
cached.contentType,
"cache",
request.headers.get("if-none-match"),
);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
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);
}
}
const resolved = await resolveBadge(cacheKey, code, candidates);
return badgeResponse(
resolved.body,
resolved.contentType,
resolved.source,
request.headers.get("if-none-match"),
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
"image/svg+xml",
"unavailable",
);
}
@@ -194,31 +140,16 @@ function badgeResponse(
body: Uint8Array,
contentType: string,
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, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControl,
ETag: etag,
"Cache-Control":
source === "unavailable"
? "no-cache, max-age=0"
: "public, max-age=3600",
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
+28 -186
View File
@@ -1,47 +1,8 @@
import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
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).
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
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 {
switch (raw?.toLowerCase()) {
@@ -54,89 +15,6 @@ 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) {
const { searchParams } = new URL(request.url);
@@ -153,84 +31,48 @@ export async function GET(request: NextRequest) {
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 respContentType = contentType;
if (format !== "png") {
// The variant is keyed off the exact render that produced it (the
// resolved upstream + the normalized query) plus the target format,
// mirroring how the underlying render itself is cached.
const variantKey = imagingCacheKey(
`${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
);
const converted = await transcodeToVariant(body, format, variantKey);
if (converted) {
respBody = converted.body;
respContentType = converted.contentType;
try {
if (format === "webp") {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).webp().toBuffer(),
);
respContentType = "image/webp";
} else {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).avif().toBuffer(),
);
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, {
status: 200,
headers: {
"Content-Type": respContentType,
"Content-Length": String(respBody.length),
"Cache-Control": cacheControl,
ETag: etag,
// 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.
"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,
"Access-Control-Allow-Origin": "*",
},
});
} 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": "*",
},
});
}
} catch {
return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" },
{ status: 502 },
@@ -17,7 +17,6 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
ArrowRightLeft,
Copy,
@@ -35,7 +34,7 @@ import {
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createCatalogItem,
@@ -44,7 +43,6 @@ import {
reorderCatalogItems,
restoreDeletedCatalogItems,
updateCatalogItem,
updateCatalogItemsBulk,
} from "@/actions/catalog-items";
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
@@ -117,15 +115,10 @@ export type { BaseItemData, CatalogItemData };
function SortableRow({
id,
className,
dataIndex,
measureRef,
children,
}: {
id: number;
className?: string;
/** Virtualizer index; forwarded as data-index so measureElement can map it. */
dataIndex?: number;
measureRef?: (el: HTMLTableRowElement | null) => void;
children: (props: {
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
}) => React.ReactNode;
@@ -140,13 +133,6 @@ function SortableRow({
} = useSortable({
id,
});
const setRefs = useCallback(
(node: HTMLTableRowElement | null) => {
setNodeRef(node);
measureRef?.(node);
},
[setNodeRef, measureRef],
);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
@@ -158,8 +144,7 @@ function SortableRow({
} as unknown as React.HTMLAttributes<HTMLDivElement>;
return (
<TableRow
ref={setRefs}
data-index={dataIndex}
ref={setNodeRef as unknown as React.Ref<HTMLTableRowElement>}
style={style}
className={className}
>
@@ -295,23 +280,6 @@ export function CatalogItemsTable({
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
}, [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 ────────────────────────────────────────────────────
const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -536,36 +504,55 @@ export function CatalogItemsTable({
async function handleSaveAllModified() {
if (modifiedItems.length === 0) return;
const updates = modifiedItems.flatMap((item) => {
const edit = inlineEdits[item.id];
if (!edit) return [];
return [
{
id: item.id,
catalogFields: {
catalogName: item.catalogName,
itemIds: item.itemIds,
costCredits: edit.costCredits,
costPoints: edit.costPoints,
pointsType: edit.pointsType,
amount: edit.amount,
limitedSells: item.limitedSells,
limitedStack: item.limitedStack,
orderNumber: item.orderNumber,
offerId: item.offerId,
haveOffer: item.haveOffer,
clubOnly: item.clubOnly,
extradata: item.extradata,
},
},
];
});
run(
async () => {
let savedCount = 0;
let failedCount = 0;
run(() => updateCatalogItemsBulk({ updates, catalog }), {
successMessage: `Saved ${updates.length} item(s).`,
errorMessage: "Failed to save items.",
onSuccess: () => onRefresh(),
});
for (const item of modifiedItems) {
const edit = inlineEdits[item.id];
if (!edit) continue;
const result = await updateCatalogItem({
id: item.id,
catalog,
catalogFields: {
catalogName: item.catalogName,
itemIds: item.itemIds,
costCredits: edit.costCredits,
costPoints: edit.costPoints,
pointsType: edit.pointsType,
amount: edit.amount,
limitedSells: item.limitedSells,
limitedStack: item.limitedStack,
orderNumber: item.orderNumber,
offerId: item.offerId,
haveOffer: item.haveOffer,
clubOnly: item.clubOnly,
extradata: item.extradata,
},
});
if (result.ok) savedCount++;
else failedCount++;
}
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.",
},
);
}
// ── Bulk delete selected ─────────────────────────────────────────
@@ -1256,10 +1243,7 @@ export function CatalogItemsTable({
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div
ref={scrollRef}
className="rounded-md border overflow-auto max-h-[600px]"
>
<div className="rounded-md border overflow-auto max-h-[600px]">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
@@ -1294,29 +1278,7 @@ export function CatalogItemsTable({
strategy={verticalListSortingStrategy}
>
<TableBody>
{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;
{orderedFilteredItems.map((item) => {
const pts = getPointsLabel(item.pointsType);
const edit = inlineEdits[item.id];
const modified = isItemModified(item);
@@ -1327,14 +1289,6 @@ export function CatalogItemsTable({
<SortableRow
key={item.id}
id={item.id}
dataIndex={index}
measureRef={
isVirtualized
? (el) => {
if (el) virtualizer.measureElement(el);
}
: undefined
}
className={cn(
modified &&
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
@@ -1607,25 +1561,6 @@ export function CatalogItemsTable({
</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>
</SortableContext>
</Table>
@@ -18,14 +18,6 @@ interface ItemsShopPreviewProps {
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({
items,
furniRevisionMap,
@@ -46,23 +38,15 @@ export function ItemsShopPreview({
const gridClass = getShopGridClass(layout);
const tileSize = getShopTileSize(layout);
const visibleItems =
items.length > PREVIEW_TILE_LIMIT
? items.slice(0, PREVIEW_TILE_LIMIT)
: items;
return (
<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)]">
<span className="font-medium">Shop preview · {layout}</span>
<span>
{items.length} items
{visibleItems.length !== items.length &&
` · showing first ${visibleItems.length}`}
</span>
<span>{items.length} items</span>
</div>
<div className={cn("grid gap-2", gridClass)}>
{visibleItems.map((item) => {
{items.map((item) => {
const firstId = parseFirstItemId(item.itemIds);
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
@@ -78,12 +62,6 @@ export function ItemsShopPreview({
);
})}
</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>
);
}
-4
View File
@@ -2,10 +2,6 @@
`studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree.
## Shared chrome
- `studio-page.tsx`: consistent header (icon tile, title, subtitle, actions) and padding for every studio sub-page; wraps `AdminPageShell`.
## Client responsibilities
- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array.
+32 -37
View File
@@ -9,20 +9,19 @@ import {
RefreshCw,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
const STATUS_KEYS: Record<BatchItemStatus["status"], string> = {
pending: "status.pending",
started: "status.started",
downloading: "status.downloading",
converting: "status.converting",
writing_db: "status.writingDb",
done: "status.done",
failed: "status.failed",
const statusLabels: Record<BatchItemStatus["status"], string> = {
pending: "Waiting",
started: "Starting",
downloading: "Downloading assets",
converting: "Preparing assets",
writing_db: "Saving",
done: "Imported",
failed: "Failed",
};
function formatDuration(ms: number): string {
@@ -66,7 +65,6 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void;
onRetry?: (classnames: string[]) => void;
}) {
const t = useTranslations("admin.studio.batch");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@@ -101,20 +99,20 @@ export const BatchProgress = memo(function BatchProgress({
const rate = formatRate(completed, elapsed);
const heading = !done
? cancelling
? t("headingStopping")
: t("headingImporting")
? "Stopping import…"
: "Importing furniture…"
: unfinished
? t("headingStopped")
? "Import stopped"
: errors
? t("headingCompletedErrors")
: t("headingCompleted");
? "Import completed with errors"
: "Import completed";
const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts
: 0;
return (
<section
aria-label={t("ariaProgress")}
aria-label="Import progress"
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -130,7 +128,7 @@ export const BatchProgress = memo(function BatchProgress({
<span className="font-semibold text-[var(--admin-success)]">
{imported}
</span>{" "}
{t("imported")}
imported
</span>
<span className="text-[var(--admin-border)]">·</span>
<span>
@@ -142,13 +140,13 @@ export const BatchProgress = memo(function BatchProgress({
>
{errors}
</span>{" "}
{t("failed")}
failed
</span>
{unfinished > 0 && (
<>
<span className="text-[var(--admin-border)]">·</span>
<span>
{unfinished} {done ? t("notCompleted") : t("remaining")}
{unfinished} {done ? "not completed" : "remaining"}
</span>
</>
)}
@@ -175,12 +173,12 @@ export const BatchProgress = memo(function BatchProgress({
onClick={() => onRetry(failedClassnames)}
>
<RefreshCw size={14} />
{t("retryFailed", { count: failedClassnames.length })}
Retry failed ({failedClassnames.length})
</Button>
)}
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
{t("closeSummary")}
Close summary
</Button>
</>
) : (
@@ -195,14 +193,14 @@ export const BatchProgress = memo(function BatchProgress({
) : (
<Ban size={14} />
)}
{t("stopImport")}
Stop import
</Button>
)}
</div>
</div>
<div
role="progressbar"
aria-label={t("ariaProcessed")}
aria-label="Furniture processed"
aria-valuemin={0}
aria-valuemax={total || 1}
aria-valuenow={completed}
@@ -217,42 +215,39 @@ export const BatchProgress = memo(function BatchProgress({
<div className="mb-3 flex flex-wrap items-center gap-1.5">
<p className="mr-1 text-sm text-[var(--admin-text-muted)]">
{issues
? t("verificationIssues", { count: issues })
: t("verificationDone")}
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.`
: "Catalog data verification completed."}
</p>
<NodeChip
count={verification.furniDataFixedIds}
label={t("chipFurnidataIds")}
label="furnidata ids fixed"
/>
<NodeChip
count={verification.offerIdsFixed}
label={t("chipOfferIds")}
label="offer ids reconciled"
/>
<NodeChip
count={verification.ownershipFixed.length}
label={t("chipOwnerships")}
label="ownerships fixed"
/>
<NodeChip
count={verification.nitrosSynced.length}
label={t("chipNitros")}
label="nitros synced"
/>
<NodeChip
count={verification.iconsSynced.length}
label={t("chipIcons")}
label="icons synced"
/>
</div>
)}
<details>
<summary className="cursor-pointer text-xs font-medium">
{t("viewItemDetails", { count: total })}
View item details ({total})
</summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.length > VISIBLE_ITEMS && (
<p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]">
{t("showingItems", {
shown: VISIBLE_ITEMS,
total: entries.length,
})}
Showing {VISIBLE_ITEMS} of {entries.length} items
</p>
)}
{entries.slice(0, VISIBLE_ITEMS).map((item) => (
@@ -285,8 +280,8 @@ export const BatchProgress = memo(function BatchProgress({
</span>
<span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed"
? t("statusNotCompleted")
: t(STATUS_KEYS[item.status])}
? "Not completed"
: statusLabels[item.status]}
</span>
</span>
{item.message && (
+11 -18
View File
@@ -10,7 +10,6 @@ import {
Search,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useMemo, useState, useTransition } from "react";
import {
refreshCatalogTree,
@@ -26,7 +25,6 @@ import type { TreeNode } from "@/types/catalog";
* member has no other signal that the import they just ran is not on screen.
*/
function LiveCatalogNotice() {
const t = useTranslations("admin.studio");
const { refreshError, refreshing } = useLiveCatalog();
const [retrying, startRetry] = useTransition();
if (!refreshError && !refreshing) return null;
@@ -43,7 +41,7 @@ function LiveCatalogNotice() {
{refreshError ? (
<>
<AlertTriangle size={14} className="shrink-0" />
<span className="min-w-0 flex-1">{t("rail.refreshFailed")}</span>
<span className="min-w-0 flex-1">Catalog could not refresh.</span>
<button
type="button"
disabled={retrying}
@@ -55,13 +53,13 @@ function LiveCatalogNotice() {
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50"
>
<RefreshCw size={12} className={cn(retrying && "animate-spin")} />
{t("rail.retry")}
Retry
</button>
</>
) : (
<>
<Loader2 size={14} className="shrink-0 animate-spin" />
<span className="min-w-0 flex-1">{t("rail.updating")}</span>
<span className="min-w-0 flex-1">Updating catalog…</span>
</>
)}
</p>
@@ -79,7 +77,6 @@ export const CatalogRail = memo(function CatalogRail({
onSelect: (id: number | null) => void;
onClose?: () => void;
}) {
const t = useTranslations("admin.studio");
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const visible = useMemo(() => {
@@ -103,7 +100,7 @@ export const CatalogRail = memo(function CatalogRail({
}
return (
<aside
aria-label={t("aria.catalogCategories")}
aria-label="Catalog categories"
className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none"
>
<div className="flex items-center gap-2 px-3 pt-3">
@@ -112,12 +109,12 @@ export const CatalogRail = memo(function CatalogRail({
style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold"
>
{t("rail.title")}
Categories
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("aria.closeCategories")}
aria-label="Close categories"
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
>
<X size={16} />
@@ -128,8 +125,8 @@ export const CatalogRail = memo(function CatalogRail({
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("aria.findCategory")}
aria-label={t("aria.findCategory")}
placeholder="Find a category…"
aria-label="Find a category"
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/>
</label>
@@ -139,13 +136,13 @@ export const CatalogRail = memo(function CatalogRail({
aria-pressed={selectedId === null}
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
>
{t("rail.all")}
All categories
</button>
<LiveCatalogNotice />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
{t("rail.noMatches")}
No matching categories.
</p>
)}
{visible.map((node) => (
@@ -162,11 +159,7 @@ export const CatalogRail = memo(function CatalogRail({
{node.childCount > 0 && !search.trim() ? (
<button
type="button"
aria-label={
collapsed.has(node.id)
? t("aria.expandCategory", { name: node.caption })
: t("aria.collapseCategory", { name: node.caption })
}
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`}
aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(node.id)}
className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
@@ -15,7 +15,11 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector";
import { getFurniImageUrl, handleImgError } from "./studio-furni-icons";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps {
@@ -45,10 +49,6 @@ export function FurnitureDetailDrawer({
onRepair,
}: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness");
const studioT = useTranslations("admin.studio");
const drawerT = useTranslations("admin.studio.drawer");
const typeLabel =
detail.type === "wallitem" ? studioT("types.wall") : studioT("types.floor");
const detailPreview = previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
@@ -57,11 +57,11 @@ export function FurnitureDetailDrawer({
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{drawerT("title")}
Furni detail
</span>
<button
type="button"
aria-label={studioT("aria.closeDetails")}
aria-label="Close furniture details"
onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
@@ -101,17 +101,17 @@ export function FurnitureDetailDrawer({
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">{typeLabel}</Badge>
<Badge variant="secondary">
{drawerT("revision", { revision: detail.revision })}
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> {drawerT("autoImportReady")}
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
@@ -127,30 +127,22 @@ export function FurnitureDetailDrawer({
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles size={12} className="text-[var(--admin-accent)]" />
{drawerT("automaticPlacement")}
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
{drawerT("category")}
</span>
<span className="text-[var(--admin-text-muted)]">Category</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
{drawerT("price")}
</span>
<span className="text-[var(--admin-text-muted)]">Price</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? drawerT("priceCreditsPoints", {
credits: detailPreview.credits,
points: detailPreview.points,
})
: drawerT("priceCredits", {
count: detailPreview.credits,
})}
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
@@ -171,14 +163,14 @@ export function FurnitureDetailDrawer({
) : (
<CloudDownload size={14} />
)}
{drawerT("importAdd")}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
{drawerT("bundleMissing")}
This furni is in the DB but its bundle file is missing.
</p>
<Button
variant="outline"
@@ -191,7 +183,7 @@ export function FurnitureDetailDrawer({
) : (
<RefreshCw size={14} />
)}
{drawerT("regenerate")}
Regenerate .nitro
</Button>
</>
)}
@@ -202,7 +194,7 @@ export function FurnitureDetailDrawer({
disabled={!detail.nitroExists}
>
<Pencil size={14} />
{drawerT("editNitro")}
Edit nitro
</Button>
</div>
)}
@@ -214,7 +206,7 @@ export function FurnitureDetailDrawer({
onClick={onDelete}
>
<Trash2 size={14} />
{drawerT("deleteImported")}
Delete imported furni
</Button>
)}
</div>
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
compareFurniture,
@@ -10,21 +9,6 @@ import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
const HEALTH_KEYS = {
complete: "healthComplete",
incomplete: "healthIncomplete",
new: "healthNew",
conflict: "healthConflict",
unknown: "healthUnknown",
} as const;
const FIELD_KEYS = {
Name: "fieldName",
Description: "fieldDescription",
Revision: "fieldRevision",
Type: "fieldType",
} as const;
export function FurnitureComparison({
item,
local,
@@ -32,81 +16,69 @@ export function FurnitureComparison({
item: FurniItem;
local: FurnitureInspection;
}) {
const t = useTranslations("admin.studio.inspector");
const comparison = compareFurniture(item, local);
return (
<div className="space-y-3 text-sm">
<FurnitureCompleteness local={local} />
{!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]">
{t("furnidataUnreadable")}
Local furnidata could not be read. Its values are not verified.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="p-2 text-left">{t("field")}</th>
<th className="p-2 text-left">{t("localNow")}</th>
<th className="p-2 text-left">{t("source")}</th>
<th className="p-2 text-left">Field</th>
<th className="p-2 text-left">Local now</th>
<th className="p-2 text-left">Source</th>
</tr>
</thead>
<tbody>
{comparison.fields.map((field) => {
const labelKey =
FIELD_KEYS[field.label as keyof typeof FIELD_KEYS];
return (
<tr
key={field.label}
className={
field.changed ? "bg-[var(--admin-warning)]/10" : ""
}
>
<th className="p-2 text-left">
{labelKey ? t(labelKey) : field.label}
{field.changed ? ` · ${t("changed")}` : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? t("notSupplied")}
</td>
</tr>
);
})}
{comparison.fields.map((field) => (
<tr
key={field.label}
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""}
>
<th className="p-2 text-left">
{field.label}
{field.changed ? " · Changed" : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? "Not supplied"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
{t("compareNote")}
Source values are compared with local data. Import may remap IDs and
translate names. Existing furniture keeps its local IDs and SQL
settings; new furniture receives a free local ID when the source ID is
occupied.
</p>
<details>
<summary className="cursor-pointer font-medium">
{t("sqlRecords")}
SQL records and catalog offers
</summary>
<div className="mt-2 space-y-2 text-xs">
{local.sql.length ? (
local.sql.map((row) => (
<p key={row.id}>
{t("itemRow", {
id: row.id,
sprite: row.spriteId,
name: row.name,
})}
Item #{row.id} · Sprite #{row.spriteId} · {row.name}
</p>
))
) : (
<p>{t("noSqlItem")}</p>
<p>No local SQL item.</p>
)}
{local.catalog.map((row) => (
<p key={row.id}>
{t("offerRow", {
id: row.id,
page: row.pageId,
credits: row.credits,
points: row.points,
})}
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "}
{row.points} points
</p>
))}
</div>
@@ -123,12 +95,11 @@ export function FurnitureInspector({
onRepair?: () => Promise<unknown>;
busy?: boolean;
}) {
const t = useTranslations("admin.studio.inspector");
const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading)
return (
<p role="status" className="py-3 text-sm">
{t("checking")}
Checking local data and assets…
</p>
);
if (inspection.error)
@@ -136,7 +107,7 @@ export function FurnitureInspector({
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
{t("retry")}
Retry inspection
</Button>
</div>
);
@@ -147,35 +118,51 @@ export function FurnitureInspector({
return local && health ? (
<div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong>{t(HEALTH_KEYS[health.state])}</strong>
<strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
{health.missing.length > 0 && (
<p className="text-sm">
{t("missing", { parts: health.missing.join(", ") })}
</p>
<p className="text-sm">Missing: {health.missing.join(", ")}</p>
)}
{health.state === "conflict" && (
<p className="text-sm">{t("conflictNote")}</p>
<p className="text-sm">
Duplicate records or mismatched IDs/types require review before
repair.
</p>
)}
{health.state === "unknown" && (
<p className="text-sm">{t("unknownNote")}</p>
<p className="text-sm">
Some local data could not be checked. Retry verification before
repair.
</p>
)}
{health.state === "incomplete" && onRepair && (
<>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("repairNote")}
Repair adds missing parts and preserves existing IDs, settings,
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
</p>
<Button disabled={busy} onClick={() => void onRepair()}>
{busy ? t("repairQueued") : t("repair")}
{busy ? "Repair queued or running…" : "Repair furni"}
</Button>
</>
)}
<Button variant="outline" disabled={busy} onClick={inspection.retry}>
{t("recheck")}
Recheck local state
</Button>
</section>
<FurnitureComparison item={item} local={local} />
</div>
) : (
<p>{t("unavailable")}</p>
<p>Local inspection unavailable.</p>
);
}
+51 -49
View File
@@ -41,7 +41,6 @@ export function ImportReview({
onConfirm: (items: FurniItem[]) => void;
}) {
const preflightT = useTranslations("admin.studio.preflight");
const reviewT = useTranslations("admin.studio.review");
const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify"
>("all");
@@ -60,16 +59,14 @@ export function ImportReview({
body: form,
});
const data = await response.json();
if (!response.ok) throw Error(data.error || reviewT("uploadFailed"));
if (!response.ok) throw Error(data.error || "Upload failed");
setAttachments((current) => ({
...current,
[item.classname]: data.attachmentId,
}));
toast.success(diagnosticsT("attached"));
} catch (error) {
toast.error(
error instanceof Error ? error.message : reviewT("uploadFailed"),
);
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
setUploading(null);
}
@@ -137,44 +134,46 @@ export function ImportReview({
<DialogHeader>
<DialogTitle>
{step === "review"
? reviewT("titleReview")
: reviewT("titleConfirm")}
? "Review furniture import"
: "Confirm furniture import"}
</DialogTitle>
<DialogDescription>{reviewT("steps")}</DialogDescription>
<DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p>
<strong>{reviewT("source")}</strong> {source}
<strong>Source:</strong> {source}
</p>
<p>
<strong>{reviewT("destination")}</strong>{" "}
{reviewT("destinationValue")}
<strong>Destination:</strong> local catalog, furnidata, bundle and
icons
</p>
<p>
<strong>{reviewT("translation")}</strong> {translation}
<strong>Translation:</strong> {translation}
</p>
<p>
<strong>{reviewT("selection")}</strong>{" "}
{reviewT("selectionCount", { count: items.length })}
<strong>Selection:</strong> {items.length} furniture items
</p>
</div>
{assets.loading && !inspection.loading && (
<p role="status">
{reviewT("checkingSource", { count: assets.items.length })}
Checking source files… {assets.items.length} checked
</p>
)}
{assets.error && (
<div role="alert">
<p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}>
{reviewT("retrySource")}
Retry source check
</Button>
</div>
)}
{assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]">
{reviewT("unknownSources")}
Some sources could not be verified. The import will still try to
download their files.
</p>
)}
{unavailable.map((check) => (
@@ -183,7 +182,7 @@ export function ImportReview({
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
>
<p className="text-sm font-medium">
{reviewT("sourceNotFound", { name: check.classname })}
Source files not found by preliminary check: {check.classname}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
{progressT("recoveryHint")}
@@ -191,25 +190,24 @@ export function ImportReview({
</section>
))}
{inspection.loading ? (
<p role="status">{reviewT("checkingLocal")}</p>
<p role="status">Checking local data and assets…</p>
) : inspection.error ? (
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
{reviewT("retryInspection")}
Retry inspection
</Button>
</div>
) : !complete ? (
<p role="alert">{reviewT("incomplete")}</p>
<p role="alert">
Inspection is incomplete. Close and retry before importing.
</p>
) : (
<>
<p role="status" className="text-sm">
{reviewT("summary", {
ready: ready.length,
existing,
blocked,
warnings: unavailable.length,
})}
{ready.length} ready to import · {existing} already in SQL
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} source file warnings
</p>
{blocked > 0 && (
<div
@@ -234,7 +232,8 @@ export function ImportReview({
item.nitro.exists === null,
) && (
<p className="text-sm text-[var(--admin-warning)]">
{reviewT("localUnverified")}
Some local files could not be verified. Review the item
details before continuing.
</p>
)}
{step === "review" ? (
@@ -330,14 +329,14 @@ export function ImportReview({
</span>
<span>
{comparison.state === "new"
? reviewT("statusNew")
? "New"
: comparison.state === "changed"
? reviewT("statusChanged")
? "Differences"
: comparison.state === "conflict"
? reviewT("statusConflict")
? "Conflict"
: comparison.state === "unknown"
? reviewT("statusUnknown")
: reviewT("statusPresent")}
? "Not fully verified"
: "Present"}
</span>
</button>
{expanded === item.classname && (
@@ -359,9 +358,7 @@ export function ImportReview({
}}
/>
{uploading === item.classname && (
<span role="status">
{reviewT("validatingFile")}
</span>
<span role="status">Validating file…</span>
)}
</label>
)}
@@ -384,10 +381,16 @@ export function ImportReview({
) : (
<>
<p className="text-sm">
{reviewT("confirmIntro", { count: ready.length })}
Only the {ready.length} eligible items below will be
submitted. Existing furniture keeps its local IDs, SQL
settings and catalog offers. Missing assets and catalog
entries are completed; furnidata is refreshed. Conflicts are
excluded.
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
{reviewT("confirmEstimates")}
Automatic categories and prices below are estimates. Source
rules may override them; final results appear in the import
summary.
</p>
<ul className="space-y-2">
{pageItems.map((item) => {
@@ -402,11 +405,8 @@ export function ImportReview({
>
<strong>{item.name}</strong>
<p>
{reviewT("placement", {
category: placement.categoryLabel,
credits: placement.credits,
points: placement.points,
})}
{placement.categoryLabel} · {placement.credits}{" "}
credits + {placement.points} points
</p>
</li>
);
@@ -424,15 +424,17 @@ export function ImportReview({
disabled={page === 0}
onClick={() => setPage((value) => value - 1)}
>
{reviewT("previous")}
Previous items
</Button>
<span>{reviewT("page", { page: page + 1, total: pageCount })}</span>
<span>
Page {page + 1} / {pageCount}
</span>
<Button
variant="outline"
disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)}
>
{reviewT("next")}
Next items
</Button>
</div>
)}
@@ -449,7 +451,7 @@ export function ImportReview({
: onCancel
}
>
{step === "confirm" ? reviewT("backToReview") : reviewT("cancel")}
{step === "confirm" ? "Back to review" : "Cancel"}
</Button>
<Button
style={{
@@ -478,8 +480,8 @@ export function ImportReview({
}}
>
{step === "review"
? reviewT("continue")
: reviewT("startImport", { count: ready.length })}
? "Continue to confirmation"
: `Start import (${ready.length})`}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -16,7 +15,6 @@ function kb(bytes: number): string {
}
function CompressionPanel({ report }: { report: CompressionReport }) {
const t = useTranslations("admin.studio.quality");
const { texture, json } = report,
// Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
@@ -28,20 +26,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
{t("compressionTitle", { format: texture.format.toUpperCase() })}
Compression · {texture.format.toUpperCase()}
{texture.format === "webp"
? texture.lossless
? ` ${t("losslessVp8l")}`
: ` ${t("lossyVp8")}`
? " Lossless (VP8L)"
: " Lossy (VP8)"
: ""}
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">{t("bundle")}</dt>
<dt className="text-muted-foreground">Bundle</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("texture")}</dt>
<dt className="text-muted-foreground">Texture</dt>
<dd className="font-medium">
{kb(texture.compressedBytes)}
<span className="text-muted-foreground">
@@ -51,85 +49,75 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("metadata")}</dt>
<dt className="text-muted-foreground">Metadata</dt>
<dd className="font-medium">
{kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("container")}</dt>
<dt className="text-muted-foreground">Container</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
{t("textureDetail", {
raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} ·
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames
</p>
{texture.format === "png" ? (
<p className="text-xs">
{texture.webpEstimate.savingBytes > 0
? t.rich("legacyPngSaving", {
size: kb(texture.webpEstimate.bytes),
saving: kb(texture.webpEstimate.savingBytes),
percent:
texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) *
100,
)
: 0,
strong: (chunks) => <strong>{chunks}</strong>,
})
: t.rich("legacyPngNoGain", {
size: kb(texture.webpEstimate.bytes),
strong: (chunks) => <strong>{chunks}</strong>,
})}
Legacy PNG texture. Re-importing as WebP Lossless would store{" "}
<strong>{kb(texture.webpEstimate.bytes)}</strong>
{texture.webpEstimate.savingBytes > 0 ? (
<>
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> (
{texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100,
)
: 0}
% smaller)
</>
) : (
" (no gain on this bundle)"
)}
</p>
) : (
<p className="text-xs">
{pngSaving > 0
? t.rich("webpSaving", {
raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
saving: kb(pngSaving),
percent:
texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0,
strong: (chunks) => <strong>{chunks}</strong>,
})
: t("webpNoGain", {
raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
})}
WebP Lossless stores {kb(texture.rawBytes)} against{" "}
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG
{pngSaving > 0 ? (
<>
, saving <strong>{kb(pngSaving)}</strong> (
{texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0}
% smaller)
</>
) : (
" (PNG is not larger on this bundle)"
)}
</p>
)}
<p className="text-xs">
{visibleDiffs === 0 ? (
<>
{t.rich("pixelExact", {
strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
<span className="font-medium text-foreground">Pixel-exact</span> —
every visible texel round-trips unchanged.
{texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground">
{" "}
{t("invisibleDiffs", {
count: texture.invisibleDifferences,
})}
{texture.invisibleDifferences.toLocaleString()} fully
transparent texels carry no RGB, which VP8L discards and no
renderer can see.
</span>
)}
</>
) : (
<strong className="text-destructive">
{t("texelsDiffer", { count: visibleDiffs })}
{visibleDiffs.toLocaleString()} texel
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
</strong>
)}
</p>
@@ -148,34 +136,32 @@ function ScalePanel({
}) {
const [asset, setAsset] = useState(report.assets[0] ?? ""),
[failed, setFailed] = useState(false);
const t = useTranslations("admin.studio.quality");
const stateLabel =
report.state === "missing"
? t("stateMissing")
: report.state === "issues"
? t("stateIssues")
: report.derived
? t("stateDerived")
: t("statePresent");
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
{t("scaleTitle", { size: report.size, state: stateLabel })}
Scale {report.size} ·{" "}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
</h3>
<p className="text-xs text-muted-foreground">
{t("spritesLine", {
sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
{report.assets.length} sprites · {report.animations.length} animation
definitions · directions {report.directions.join(", ") || "—"}
</p>
{report.state === "missing" && (
<p className="text-sm">{t("missingScale")}</p>
<p className="text-sm">
This bundle contains no sprites at this scale. Missing scale 32 does
not by itself mean the furniture is unusable.
</p>
)}
{report.assets.length > 0 && (
<>
<label className="block text-sm">
{t("sprite")}
Sprite
<select
className="mt-1 block w-full rounded border bg-background p-2"
value={asset}
@@ -193,7 +179,7 @@ function ScalePanel({
</label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? (
<p role="alert">{t("renderFailed")}</p>
<p role="alert">This sprite could not be rendered.</p>
) : (
<img
key={`${asset}-${revision}`}
@@ -224,7 +210,6 @@ function ScalePanel({
);
}
export function NitroQualityPanel({ classname }: { classname: string }) {
const t = useTranslations("admin.studio.quality");
const [report, setReport] = useState<NitroQuality | null>(null),
[error, setError] = useState(""),
[revision, setRevision] = useState(0);
@@ -239,32 +224,35 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal },
);
const data = await response.json();
if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!response.ok) throw Error(data.error || "Inspection failed");
if (!controller.signal.aborted) setReport(data.report);
} catch (error) {
if (!controller.signal.aborted)
setError(
error instanceof Error ? error.message : t("inspectionFailed"),
error instanceof Error ? error.message : "Inspection failed",
);
}
})();
return () => controller.abort();
}, [classname, revision, t]);
}, [classname, revision]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm">{t("intro")}</p>
<p className="text-sm">
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<Button
variant="outline"
onClick={() => setRevision((value) => value + 1)}
>
{t("recheck")}
Recheck
</Button>
</div>
{error ? (
<p role="alert">{error}</p>
) : !report ? (
<p role="status">{t("checking")}</p>
<p role="status">Checking scales and texture references…</p>
) : (
<div className="space-y-3">
{report.compression && (
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -12,7 +11,6 @@ export function NitroScale32Generator({
disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void;
}) {
const t = useTranslations("admin.studio.scale32");
const [preview, setPreview] = useState<{
hash: string;
before: string;
@@ -40,7 +38,7 @@ export function NitroScale32Generator({
}),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("generationFailed"));
if (!response.ok) throw Error(data.error || "Generation failed");
if (action === "preview") setPreview(data);
else {
setPreview(null);
@@ -52,37 +50,45 @@ export function NitroScale32Generator({
onChanged(data.metadata);
}
} catch (e) {
setError(e instanceof Error ? e.message : t("generationFailed"));
setError(e instanceof Error ? e.message : "Generation failed");
} finally {
setBusy(false);
}
}
return (
<section className="space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">{t("title")}</h3>
<p className="text-sm text-muted-foreground">{t("intro")}</p>
{disabled && <p className="text-sm">{t("saveFirst")}</p>}
<h3 className="font-semibold">Generate scale 32 from 64</h3>
<p className="text-sm text-muted-foreground">
Adds derived artwork using nearest-neighbor reduction. Existing scale 32
is preserved. Preview uses the saved bundle and shows one sprite at its
original pixel size.
</p>
{disabled && (
<p className="text-sm">Save or reset your pending edits first.</p>
)}
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("preview")}
>
{busy ? t("working") : t("preview")}
{busy ? "Working…" : "Preview generation"}
</Button>
{preview && (
<>
<p className="text-sm">
{t("previewCount", { count: preview.count })}
{preview.count} sprite entries will be added. Check positioning and
animations in the client after saving.
</p>
{preview.skipped ? (
<p className="text-sm text-destructive" role="alert">
{t("skipped", { count: preview.skipped })}
{preview.skipped} sprite(s) were skipped because the source bundle
has no usable artwork for them.
</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{[
[t("original64"), preview.before],
[t("derived32"), preview.after],
["Original 64", preview.before],
["Derived 32", preview.after],
].map(([label, src]) => (
<figure key={label} className="min-w-0">
<figcaption>{label}</figcaption>
@@ -97,26 +103,28 @@ export function NitroScale32Generator({
))}
</div>
<Button disabled={busy || disabled} onClick={() => run("apply")}>
{t("saveWithBackup")}
Save with backup
</Button>
<Button
variant="ghost"
disabled={busy}
onClick={() => setPreview(null)}
>
{t("discard")}
Discard preview
</Button>
</>
)}
{undo && (
<div>
<p className="text-sm">{t("saved")}</p>
<p className="text-sm">
Saved. A copy of the original bundle is preserved.
</p>
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("restore")}
>
{t("restore")}
Restore original bundle
</Button>
</div>
)}
@@ -851,10 +851,10 @@ export function OrganizeImportsDialog({
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">
{t("pointsTypeDuckets")}
Duckets
</SelectItem>
<SelectItem value="5" className="text-xs">
{t("pointsTypeDiamonds")}
Diamonds
</SelectItem>
</SelectContent>
</Select>
@@ -1571,7 +1571,7 @@ export function OrganizeImportsDialog({
</span>
</div>
<Badge variant="outline" className="text-[10px]">
{previewGroup.items.length} {t("itemsUnit")}
{previewGroup.items.length} items
</Badge>
</div>
{previewGroup.layout &&
@@ -1,7 +1,6 @@
"use client";
import { FolderTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react";
import { StudioPanelLoading } from "./studio-panel-loading";
@@ -12,7 +11,6 @@ const OrganizeImportsDialog = lazy(() =>
);
export function OrganizeImportsLauncher() {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const [open, setOpen] = useState(false);
const [requested, setRequested] = useState(false);
return (
@@ -26,7 +24,7 @@ export function OrganizeImportsLauncher() {
className="flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10"
>
<FolderTree size={16} />
{t("title")}
Organize imports
</button>
{requested && (
<Suspense
File diff suppressed because it is too large. Load diff
@@ -1,37 +0,0 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
interface StudioPageProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}
/**
* Consistent chrome for Catalog Studio sub-pages: the shared admin page
* header (icon tile, title, subtitle, optional actions) with the studio's
* standard padding, so every tool page lines up the same way.
*/
export function StudioPage({
icon,
title,
subtitle,
actions,
children,
}: StudioPageProps) {
return (
<div className="p-3 sm:p-4">
<AdminPageShell
icon={icon}
title={title}
subtitle={subtitle}
actions={actions}
>
{children}
</AdminPageShell>
</div>
);
}
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { setFurnidataTranslateEnabled } from "@/actions/import-furni";
@@ -25,7 +24,6 @@ const TRANSLATE_LANGS = [
] as const;
export function useStudioTranslations(defaultTranslate: boolean) {
const t = useTranslations("admin.studio");
// Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
@@ -54,16 +52,13 @@ export function useStudioTranslations(defaultTranslate: boolean) {
const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length;
toast.success(
t("toasts.languagesTranslated", {
ok: okCount,
total: langs.length,
}),
`Languages translated (${okCount}/${langs.length} completed)`,
);
} else {
toast.error(data.error ?? t("toasts.translationFailed"));
toast.error(data.error ?? "Translation failed");
}
} catch {
toast.error(t("toasts.networkTranslating"));
toast.error("Network error while translating languages");
} finally {
setBuildingLanguages(false);
}
@@ -80,7 +75,7 @@ export function useStudioTranslations(defaultTranslate: boolean) {
try {
await setFurnidataTranslateEnabled({ enabled: next });
} catch {
toast.error(t("toasts.saveDefaultFailed"));
toast.error("Could not save the default translation setting");
}
};
-37
View File
@@ -75,9 +75,6 @@ const schema = z
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset.
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_PATH: z.string().optional(),
EMULATOR_BACKUP_DIR: z.string().optional(),
@@ -103,27 +100,6 @@ const schema = z
// Redis — strongly recommended in production (required for multi-instance).
// Without it, rate limits / shared caches are in-process only.
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
// production; set to "false" or "0" to disable without removing it.
ANTI_DDOS_ENABLED: z
@@ -172,19 +148,6 @@ const schema = z
.string()
.optional()
.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) => {
if (data.NODE_ENV !== "production") return;
+2 -120
View File
@@ -31,13 +31,7 @@ export type UpdateOfferInput = {
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
};
type OfferRow = {
id: number;
pageId: number | string;
itemIds: string;
limitedStack?: number | string;
limitedSells?: number | string;
};
type OfferRow = { id: number; pageId: number | string; itemIds: string };
async function lockedOffers(
tx: Transaction,
ids: number[],
@@ -45,7 +39,7 @@ async function lockedOffers(
): Promise<OfferRow[]> {
const offers = rowsFrom<OfferRow>(
await tx.execute(
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(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
@@ -199,118 +193,6 @@ export async function updateOfferCommand(
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(
orders: Array<{ id: number; orderNumber: number }>,
) {
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_AVATAR_FIGURE, normalizeAvatarFigure } from "./avatar-figure";
describe("normalizeAvatarFigure", () => {
it("keeps a figure the imager can draw", () => {
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";
expect(normalizeAvatarFigure(figure)).toBe(figure);
});
it("trims surrounding whitespace", () => {
expect(normalizeAvatarFigure(" hd-180-1 ")).toBe("hd-180-1");
});
it("replaces a stored look with an empty colour slot", () => {
// The legacy register default: `hr-100-` has no colour, so the imager
// rejects the whole figure with 400 and the avatar renders broken.
expect(
normalizeAvatarFigure("hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"),
).toBe(DEFAULT_AVATAR_FIGURE);
});
it.each([
"",
" ",
"0",
"../bad",
"hd-180-1/../x",
`hd-180-${"x".repeat(600)}`,
`hd-180${".a-1".repeat(30)}`,
])("replaces %j with the default", (value) => {
expect(normalizeAvatarFigure(value)).toBe(DEFAULT_AVATAR_FIGURE);
});
});
-38
View File
@@ -1,38 +0,0 @@
/**
* Client-safe avatar figure rules, shared by the URL builder (`imager.ts`) and
* the server-side query parser (`imager-upstream.ts`).
*
* The imager is the authority on what it can draw: a figure with an empty part
* slot (e.g. `hr-100-.hd-180-1.ch-255-66`) is rejected with 400 "Invalid figure
* format". Legacy accounts can still carry such stored looks, so the URL
* builder normalises them to a valid default instead of emitting a request that
* renders as a broken image.
*/
/** Valid starter figure — also the default look new accounts are given. */
export const DEFAULT_AVATAR_FIGURE =
"hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
/**
* The first segment must 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 AVATAR_FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const AVATAR_FIGURE_MAX_LEN = 512;
export const AVATAR_FIGURE_MAX_PARTS = 24;
/**
* Return `figure` unchanged when the imager can draw it, otherwise the default
* figure. Trims surrounding whitespace either way.
*/
export function normalizeAvatarFigure(figure: string): string {
const trimmed = figure.trim();
if (!trimmed || trimmed.length > AVATAR_FIGURE_MAX_LEN)
return DEFAULT_AVATAR_FIGURE;
if (!AVATAR_FIGURE_RE.test(trimmed)) return DEFAULT_AVATAR_FIGURE;
if (trimmed.split(".").length > AVATAR_FIGURE_MAX_PARTS)
return DEFAULT_AVATAR_FIGURE;
return trimmed;
}
-107
View File
@@ -1,107 +0,0 @@
// @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();
});
+1 -17
View File
@@ -1,5 +1,5 @@
// @ts-nocheck
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => {
@@ -19,20 +19,4 @@ describe("csp", () => {
expect(csp).toContain("https://challenges.cloudflare.com");
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();
});
});
-6
View File
@@ -28,17 +28,11 @@ export function buildContentSecurityPolicy(nonce: string): string {
"style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"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:",
"font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; ");
}
-4
View File
@@ -14,10 +14,6 @@ describe("classifyDdos", () => {
["/api/auth/callback/credentials", "auth"],
["/api/articles/hello", "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) => {
expect(classifyDdos(pathname)).toBe(expected);
});
-8
View File
@@ -1,13 +1,5 @@
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 {
if (
pathname === "/api/auth" ||
+16 -14
View File
@@ -188,27 +188,29 @@ describe("Docker clone updates", () => {
expect(r.calls).not.toContain("docker volume prune");
expect(r.calls).not.toContain("docker system prune");
});
it.each(["dirty", "pull-failure", "build-failure", "migration-failure"])(
"does not replace the container after %s",
(scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0);
expect(r.calls).not.toContain("compose up");
},
);
it.each([
"dirty",
"ci-active",
"ci-green",
"ci-upstream",
"pull-failure",
"build-failure",
"migration-failure",
])("does not replace the container after %s", (scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0);
expect(r.calls).not.toContain("compose up");
});
// Regressie: na een cutover naar het groene slot bestaat epicnext-cms-app
// niet meer en zag deze controle alleen een vrijgekomen blauwe container. De
// dagelijkse `docker-update.sh` startte toen een compose-replica op poort
// 3002 en blokkeerde elke volgende release. De blauwe container alleen
// controleren is dus geen bewijs dat de host niet door CI beheerd wordt.
//
// 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)",
it.each(["ci-green", "ci-upstream"])(
"refuses a compose deployment while CI owns the host (%s)",
(scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).toBe(0);
expect(r.status, r.output).not.toBe(0);
expect(r.output).toContain("This host is managed by CI");
expect(r.calls).not.toContain("compose up");
},
+2 -3
View File
@@ -5,9 +5,8 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
/**
* Local furniture-bundle lookups.
*
* New bundles are written with the configured primary extension
* (`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
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
* assets is still `.nitro`, so every existence check has to consider both. These
* 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
* nitro-cleanup scan from classifying them as fake leftovers.
+1 -40
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
getBaseClassname,
getBundleFileName,
@@ -11,7 +11,6 @@ import {
getSwfFileName,
isValidClassname,
normalizeClassname,
resolveLocalBundleExt,
SOURCE_BUNDLE_EXT,
} from "./classname";
@@ -62,44 +61,6 @@ 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", () => {
it("accepts lowercase letters, digits and allowed symbols", () => {
expect(isValidClassname("chair_basic")).toBe(true);
+6 -48
View File
@@ -17,65 +17,23 @@ export function getSwfFileName(classname: string): string {
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
* 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.
* Nitro client requests `<classname>.hab`, so that is what new imports produce.
*/
export const LOCAL_BUNDLE_EXT: FurnitureBundleExt = resolveLocalBundleExt();
export const LOCAL_BUNDLE_EXT = ".hab";
/**
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
* 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.
*/
export const SOURCE_BUNDLE_EXT = ".nitro" as const;
export const SOURCE_BUNDLE_EXT = ".nitro";
/**
* 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),
];
/** Both extensions a local bundle may carry, newest convention first. */
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const;
/** Primary local bundle name — the configured extension wins over the legacy one. */
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */
export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
}
-39
View File
@@ -1,39 +0,0 @@
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);
});
});
-33
View File
@@ -1,33 +0,0 @@
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}`;
});
}
-62
View File
@@ -1,62 +0,0 @@
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);
});
});
-43
View File
@@ -1,43 +0,0 @@
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;
}
+1 -21
View File
@@ -15,11 +15,7 @@ vi.mock("@/lib/runtime-asset-config", () => ({
resolveImagerBase: (origin: string) => `${origin}/imager`,
}));
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "./imager-upstream";
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream";
const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
@@ -140,19 +136,3 @@ it("does not queue a new caller behind a render that is already too old", async
expect([...shared[0].body]).toEqual([9]);
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);
}
});
+3 -9
View File
@@ -1,10 +1,5 @@
import "server-only";
import {
AVATAR_FIGURE_MAX_LEN as FIGURE_MAX_LEN,
AVATAR_FIGURE_MAX_PARTS as FIGURE_MAX_PARTS,
AVATAR_FIGURE_RE as FIGURE_RE,
} from "@/lib/avatar-figure";
import {
avatarCacheDir,
imagingCacheKey,
@@ -13,10 +8,9 @@ import {
} from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
// The client's figure strings are passed straight through to the imager, which
// is the authority on what it can draw. The rules live in `avatar-figure.ts` so
// the URL builder applies the exact same ones; the parser only enforces them.
export { FIGURE_MAX_LEN, FIGURE_MAX_PARTS, FIGURE_RE };
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24;
const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
-6
View File
@@ -3,7 +3,6 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AVATAR_FIGURE } from "./avatar-figure";
import { getAvatarUrl } from "./imager";
import {
FALLBACK_TIMEOUT_MS,
@@ -53,11 +52,6 @@ describe("getAvatarUrl", () => {
expect(url).not.toContain("&x=1");
});
it("falls back to the default when the stored look is malformed", () => {
const url = getAvatarUrl("hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62");
expect(url).toContain(`figure=${DEFAULT_AVATAR_FIGURE}`);
});
it("allows custom img_format", () => {
const url = getAvatarUrl("hd-180-1", { img_format: "png" });
expect(url).toContain("img_format=png");
+1 -2
View File
@@ -7,7 +7,6 @@
export type { AvatarOptions } from "@/types/admin";
import type { SyntheticEvent } from "react";
import { normalizeAvatarFigure } from "@/lib/avatar-figure";
import type { AvatarOptions } from "@/types/admin";
/**
@@ -47,7 +46,7 @@ export function getAvatarUrl(
} = options;
const params = new URLSearchParams({
figure: normalizeAvatarFigure(figure),
figure,
img_format,
effect: String(effect),
});
-171
View File
@@ -1,171 +0,0 @@
// @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");
});
-54
View File
@@ -1,54 +0,0 @@
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);
});
});
-55
View File
@@ -1,55 +0,0 @@
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;
})();
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more