7 Commits
Author SHA1 Message Date
openhands ac23d7d1d9 fix(ops): treat a CI-managed host as a skip, not a failed updater run
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 1m57s
CI / tests-integration (push) Successful in 2m5s
CI / tests-imaging-browsers (push) Successful in 1m37s
CI / tests-ui (push) Failing after 3m13s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Commit 3c8abb6f made docker-update.sh exit 0 (SKIP) on a host owned by CI so
the cron does not report a failed deployment. The tests still expected a
nonzero exit for the ci-active/ci-green/ci-upstream scenarios, so five cases
failed.

- docker-update.test.ts: move the CI scenarios out of the non-zero group and
  assert the skip: exit 0, 'This host is managed by CI' in the output and no
  'compose up'. All three CI scenarios (both slot containers and the nginx
  upstream) now share one test.
- README.md: document the exit-0 skip.
2026-10-14 21:59:42 +02:00
openhands 3c8abb6fbf feat(assets): choose furniture bundle ext via FURNI_BUNDLE_EXT
CI / check (push) Successful in 39s
CI / tests-imaging-browsers (push) Successful in 2m9s
CI / tests-integration (push) Successful in 2m47s
CI / tests-unit (push) Failing after 3m22s
CI / tests-ui (push) Failing after 4m14s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Bundle the remaining imaging hardening and the host/CI fixes from the
"Doe alles" pass into one release.

Furniture:
- Resolve the local bundle extension from FURNI_BUNDLE_EXT (".hab" default,
  ".nitro" for a stock Nitro client). The configured extension is written by
  imports/repairs and preferred on lookup; the other stays a legacy fallback.

Imaging:
- Avatar proxy sends s-maxage=86400 + stale-while-revalidate (300/3600 for the
  fallback path) so the edge can hold a render a day.
- Cloudflare cache rule now covers /api/imaging/ (verified MISS->HIT live).
- Document that bare /imaging is shadowed by the edge proxy to the renderer.

Ops/CI:
- docker-update.sh skips a CI-managed host (exit 0) instead of failing cron.
- ci-deploy.sh runs the cross-browser imaging gate (chromium+firefox+webkit)
  against the candidate before cutover; DEPLOY_SKIP_CROSS_BROWSER=1 as escape.
- scripts/imager-health-alert.sh alerts (Discord) on imager up/down transitions;
  runs from cron every 5 minutes.

Tests:
- Furniture bundle-ext resolver + FURNI_BUNDLE_EXT wiring.
- Imaging performance budget (bytes + warm latency).
- classifyDdos: /api/imaging/* stays in the api bucket.
2026-10-14 21:46:56 +02:00
openhands 640e5a5dd0 test(imaging): broaden coverage across contract, transcode, routing and pixels
CI / check (push) Successful in 32s
CI / tests-imaging-browsers (push) Successful in 1m33s
CI / tests-integration (push) Successful in 2m5s
CI / tests-unit (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m47s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m44s
Environment-independent contract tests (cache headers + source, CORS, OPTIONS
preflight, negative figure/badge tables, concurrency burst) run in both the
deploy gate and the cross-browser job.

CMS-only /imaging assertions (webp/avif transcode, animated-APNG passthrough,
s-maxage edge caching) live in cms-imaging.spec.ts and are skipped behind the
edge proxy; the public topology has its own public-routing.spec.ts smoke that
fails if the edge ever stops fronting the imager or starts shadowing the CMS
endpoints. A committed per-engine visual baseline pins the static render.

The cross-browser imaging job is now blocking (IMAGING_EDGE=1), so avatar
imaging on chromium/firefox/webkit is a required signal.
2026-10-14 21:19:18 +02:00
openhands 3fa643828d test(imaging): drop /imaging e2e assertion shadowed by the edge proxy
CI / check (push) Successful in 40s
CI / tests-imaging-browsers (push) Successful in 1m13s
CI / tests-integration (push) Successful in 2m18s
CI / tests-unit (push) Successful in 2m39s
CI / tests-ui (push) Successful in 3m7s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m15s
In production the edge proxy fronts /imaging to the imager container, so the
CMS route only answers when reached directly. Asserting it in the shared spec
failed against the public site in all three engines. Its behaviour is covered
by src/lib/imaging-route.test.ts; the e2e spec now only checks the endpoints
that are identical in both topologies (/api/imaging/avatar and the badge).
2026-10-14 21:05:36 +02:00
openhands 888765c47f test(imaging): add endpoint contract e2e and cross-browser render checks
CI / check (push) Successful in 40s
CI / tests-imaging-browsers (push) Failing after 1m20s
CI / tests-unit (push) Successful in 2m12s
CI / tests-integration (push) Successful in 2m24s
CI / tests-ui (push) Successful in 2m55s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m38s
The deploy gate only installs chromium, so imaging had no browser-level
coverage. Add two Playwright specs:

- e2e/imaging/avatar-endpoint.spec.ts: HTTP contract for the avatar
  (/api/imaging/avatar), default (/imaging) and badge endpoints — animated
  APNG for a figure with an effect, the trailing-`.0` regression figure,
  ETag/304 revalidation, and a 400 for a malformed figure. Runs in the
  deploy gate automatically via the default Playwright config.
- e2e/imaging/avatar-render.spec.ts: loads the avatar in a real browser and
  asserts it decodes at a non-zero size and animates. Navigates to the image
  URL directly so production CSP `upgrade-insecure-requests` cannot rewrite
  the request over a plain-HTTP host.

Add playwright.imaging.config.ts running both across chromium, firefox and
webkit, a `test:imaging` script, and a non-blocking CI job that installs all
three engines and checks the live site.
2026-10-14 21:00:27 +02:00
openhands f3407ebfdf fix(imaging): support .hab assets, cache avatars/badges, harden routes
CI / check (push) Successful in 37s
CI / tests-integration (push) Successful in 2m2s
CI / tests-unit (push) Successful in 2m9s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m12s
The hotel moved from .nitro to .hab bundles, so the imager must treat .hab
like .nitro and the public avatar endpoints must accept the figure strings
the client sends (including extra trailing segments such as `.0`, which the
old FIGURE_RE rejected with a 400).

- imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage
- api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304
- api/imaging/badge: single-flight upstream fetch and conditional responses
- imager-http/imager-png: strong ETag helpers and APNG passthrough guard
- csp + next/image: unoptimized external images, tightened CSP
- deps: override postcss-selector-parser to ^7.1.6
- also fold in the pending drizzle draft snapshot and stray test cleanup
2026-10-14 20:40:50 +02:00
openhands 56676effc3 feat(studio): localize catalog studio UI and polish responsive layout
CI / check (push) Failing after 43s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Route all Catalog Studio strings through the admin.studio namespace
(workspace, filters, review, inspector, quality, sync, scale32, organize
imports and page titles/subtitles) with English fallback, and add shared
StudioPage chrome plus a loading skeleton. Tighten mobile padding on the
header and sub-bars.
2026-10-13 20:31:26 +02:00
85 changed files with 17230 additions and 765 deletions

No files matched your search

+7
View File
@@ -89,6 +89,13 @@ DB_BACKUP_KEEP=7
# Minutes between repeat health-fail alerts from jobs-worker (default 15). # Minutes between repeat health-fail alerts from jobs-worker (default 15).
HEALTH_ALERT_COOLDOWN_MIN=15 HEALTH_ALERT_COOLDOWN_MIN=15
# --- FURNITURE ASSETS ---
# Extension the local Nitro client requests for furniture bundles. The CMS
# writes new/imported/repaired bundles with this extension and prefers it when
# resolving existing ones (the other stays readable as a legacy fallback).
# ".hab" (default) or ".nitro".
FURNI_BUNDLE_EXT=.hab
# --- RCON (Low Latency Loop) --- # --- RCON (Low Latency Loop) ---
RCON_HOST=127.0.0.1 RCON_HOST=127.0.0.1
RCON_PORT=3003 RCON_PORT=3003
+29
View File
@@ -116,6 +116,35 @@ jobs:
test-results/ui/ test-results/ui/
retention-days: 14 retention-days: 14
# Cross-browser imaging: the deploy gate only runs chromium, so this verifies
# a real render (decode + APNG animation + visual baseline) across every
# bundled engine against the public site. Blocking on purpose: this is the
# signal that avatar imaging works on every system, so a failure here must
# stop the pipeline. IMAGING_EDGE marks the target as edge-fronted, enabling
# the public-routing smoke and skipping the CMS-only /imaging assertions.
tests-imaging-browsers:
needs: check
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install cross-browser engines
run: pnpm exec playwright install --with-deps chromium firefox webkit
- name: Cross-browser imaging checks
env:
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
IMAGING_EDGE: "1"
run: pnpm test:imaging
preflight: preflight:
needs: [tests-unit, tests-integration, tests-ui] needs: [tests-unit, tests-integration, tests-ui]
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/')) if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
+2
View File
@@ -15,6 +15,8 @@ public/cache/
prod.log prod.log
*.log *.log
# Imager health-alert state (last-known up/down + timestamp)
logs/imager-health.state
# Database backups # Database backups
db_backup_*.sql db_backup_*.sql
+6 -3
View File
@@ -191,7 +191,9 @@ it does not fetch changes to this Git-built CMS. Use the updater for releases.
A clone does **not** install an automatic scheduler. If desired, explicitly add a A clone does **not** install an automatic scheduler. If desired, explicitly add a
cron entry with the absolute path of **your** checkout; keep logs in its `logs` cron entry with the absolute path of **your** checkout; keep logs in its `logs`
directory. Do not configure both CI and Compose updates for the same instance. directory. Do not configure both CI and Compose updates for the same instance.
The updater refuses an active `epicnext-cms-app` CI-managed container. On a CI-managed host (either slot container running, or the nginx upstream
pointing at a blue/green slot) the updater skips with exit 0, so a cron run
that legitimately has nothing to do does not look like a failed deployment.
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
failure automatically recreates the CMS with its previous image and verifies its failure automatically recreates the CMS with its previous image and verifies its
@@ -594,7 +596,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
### Requirements (host) ### Requirements (host)
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables). - Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab`, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`. - An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
### Configuration — `/docker/Polaris-imager/.env` ### Configuration — `/docker/Polaris-imager/.env`
@@ -898,7 +900,8 @@ that:
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only - `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is `epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream. CI-managed. It now checks both slot containers and the nginx upstream, and
reports the skip with exit 0 instead of failing the cron.
- `ci-deploy.sh` retires a compose replica of *this* checkout - `ci-deploy.sh` retires a compose replica of *this* checkout
(`com.docker.compose.project.config_files`) from the candidate port before (`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx starting the candidate — but never a slot container, and never the port nginx
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE `website_daily_reward_claims` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`claim_date` date NOT NULL,
`streak` int unsigned NOT NULL DEFAULT 1,
`reward_day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
);
--> statement-breakpoint
CREATE TABLE `website_daily_rewards` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1788791201804, "when": 1788791201804,
"tag": "0000_premium_spirit", "tag": "0000_premium_spirit",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1791828901011,
"tag": "0001_breezy_maestro",
"breakpoints": true
} }
] ]
} }
+192
View File
@@ -0,0 +1,192 @@
import { expect, test } from "@playwright/test";
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
// Endpoints exercised here are identical whether the CMS is reached directly
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
// in the cross-browser job. Topology-specific checks live in
// `cms-imaging.spec.ts` (direct only) and `public-routing.spec.ts` (edge only).
const IMAGE_SOURCES = new Set([
"primary",
"fallback",
"cache",
"cache-stale",
"unavailable",
]);
test("renders an animated APNG for a figure with an effect", async ({
request,
}) => {
const response = await request.get(
avatarPath({ img_format: "apng", effect: "14" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
// `unavailable` means the placeholder was served, i.e. no real render.
expect(response.headers()["x-imager-source"]).not.toBe("unavailable");
const body = await response.body();
expect(body.length).toBeGreaterThan(1000);
// APNG signature chunks: `acTL` announces the animation, `fcTL` marks frames.
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("serves a static PNG when no animation is requested", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(1, 4).toString("ascii")).toBe("PNG");
});
test("advertises a cacheable response and a known render source", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
const source = response.headers()["x-imager-source"];
expect(source, "x-imager-source header").toBeTruthy();
expect(IMAGE_SOURCES.has(source as string)).toBe(true);
const cacheControl = response.headers()["cache-control"];
expect(cacheControl, "cache-control header").toContain("public");
expect(cacheControl).toMatch(/max-age=\d+/);
// Render sources other than the graceful placeholder are worth caching.
if (source !== "unavailable") {
expect(cacheControl).not.toContain("no-cache");
}
});
test("answers conditional requests with 304", async ({ request }) => {
const first = await request.get(avatarPath({ img_format: "png" }));
expect(first.status()).toBe(200);
const etag = first.headers()["etag"];
expect(etag).toBeTruthy();
const second = await request.get(avatarPath({ img_format: "png" }), {
headers: { "if-none-match": etag as string },
});
expect(second.status()).toBe(304);
expect(second.headers()["etag"]).toBe(etag);
});
test("serves the rendered avatar behind permissive CORS", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
});
test("preflight requests advertise GET and revalidation", async ({
request,
}) => {
const response = await request.fetch(avatarPath(), { method: "OPTIONS" });
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
expect(response.headers()["access-control-allow-headers"]).toContain(
"If-None-Match",
);
});
test.describe("rejects malformed figures", () => {
const cases: Array<{ name: string; query: string }> = [
{ name: "missing figure", query: "" },
{ name: "empty figure", query: "?figure=" },
{ name: "path traversal", query: "?figure=../bad" },
{ name: "separator injection", query: "?figure=hd-180-1/../x" },
{
name: "over-long figure",
query: `?figure=hd-180-${"x".repeat(600)}`,
},
{
name: "too many parts",
query: `?figure=hd-180${".a-1".repeat(30)}`,
},
];
for (const { name, query } of cases) {
test(name, async ({ request }) => {
const response = await request.get(`/api/imaging/avatar${query}`);
expect(response.status()).toBe(400);
});
}
});
test("collapses a burst of concurrent requests for one figure", async ({
request,
}) => {
// Vary the key so this can hit a cold (or at least distinct) render while a
// page-load style burst is in flight. Every response must still be a real
// image — single-flight must not turn contention into 5xx/placeholders.
const path = avatarPath({
img_format: "png",
direction: "6",
head_direction: "1",
});
const responses = await Promise.all(
Array.from({ length: 6 }, () => request.get(path)),
);
for (const response of responses) {
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
}
});
test("the badge endpoint serves an image and revalidates", async ({
request,
}) => {
const first = await request.get("/api/imaging/badge?code=ADM");
expect(first.status()).toBe(200);
expect(first.headers()["content-type"]).toContain("image/");
expect(first.headers()["access-control-allow-origin"]).toBe("*");
const etag = first.headers()["etag"];
expect(etag).toBeTruthy();
const second = await request.get("/api/imaging/badge?code=ADM", {
headers: { "if-none-match": etag as string },
});
expect(second.status()).toBe(304);
});
test("the badge endpoint can resolve from a local source", async ({
request,
}) => {
const response = await request.get(
"/api/imaging/badge?code=ADM&source=local",
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(response.headers()["x-imager-source"]).toBeTruthy();
});
test.describe("rejects malformed badge codes", () => {
const cases = [
{ name: "missing code", query: "" },
{ name: "empty code", query: "?code=" },
{ name: "path traversal", query: "?code=../x" },
{ name: "too long", query: `?code=${"A".repeat(51)}` },
];
for (const { name, query } of cases) {
test(name, async ({ request }) => {
const response = await request.get(`/api/imaging/badge${query}`);
expect(response.status()).toBe(400);
});
}
});
test("the badge preflight advertises GET", async ({ request }) => {
const response = await request.fetch("/api/imaging/badge?code=ADM", {
method: "OPTIONS",
});
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
+57
View File
@@ -0,0 +1,57 @@
import { expect, test } from "@playwright/test";
import { avatarPath } from "./imaging-target";
/**
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
* the avatar the imager produces and that it actually animates. The endpoint
* spec covers the HTTP contract; this one covers the client-side decode, where
* an unsupported image format would surface as a blank/broken avatar.
*
* Navigates straight to the image URL (a top-level navigation, which CSP
* `upgrade-insecure-requests` does not rewrite) rather than embedding it in a
* page: over a plain-HTTP host the upgrade directive would otherwise send the
* subresource request to https and break the load, without exercising anything
* about the image itself.
*
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
*/
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
const STATIC = avatarPath({ img_format: "png" });
test("the avatar decodes and animates in the browser", async ({ page }) => {
const response = await page.goto(ANIMATED);
expect(response?.status()).toBe(200);
const image = page.locator("img");
await expect(image).toBeVisible();
const decoded = await image.evaluate((element: HTMLImageElement) => ({
width: element.naturalWidth,
height: element.naturalHeight,
}));
expect(decoded.width).toBeGreaterThan(0);
expect(decoded.height).toBeGreaterThan(0);
// An animated APNG keeps producing new frames; a static/undecodable one
// yields the same pixels every time. Sampling screenshots over a second is
// enough to tell them apart without depending on exact frame timing.
const frames = new Set<string>();
for (let attempt = 0; attempt < 8; attempt += 1) {
frames.add((await image.screenshot()).toString("base64"));
await page.waitForTimeout(180);
}
expect(frames.size).toBeGreaterThan(1);
});
test("the static render matches its committed visual baseline", async ({
page,
}) => {
// Pins the exact pixels of a non-animated render so a renderer/asset drift
// (wrong palette, missing part, changed sizing) is caught even when the
// image still decodes. Baselines are per engine and committed under
// e2e/imaging/__screenshots__/.
const response = await page.goto(STATIC);
expect(response?.status()).toBe(200);
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
animations: "disabled",
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+78
View File
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
// These assertions only hold when the CMS itself answers `/imaging`. In
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
test.skip(
IS_EDGE,
"The CMS /imaging route is shadowed by the edge proxy in production",
);
const imagerPath = (params: Record<string, string> = {}) =>
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
test("serves the default render as a PNG", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
expect(looksLikeImage(body)).toBe(true);
});
test("transcodes to WebP on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "webp" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/webp");
const body = await response.body();
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
});
test("transcodes to AVIF on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "avif" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/avif");
const body = await response.body();
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
});
test("leaves animated APNG renders untranscoded", async ({ request }) => {
// The renderer emits effected avatars as APNG; libvips here reads only the
// first frame, so the animated original must be served as-is even when a
// transcode is requested — otherwise the avatar would stop animating.
const response = await request.get(
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("advertises long-lived edge caching", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl).toContain("public");
expect(cacheControl).toContain("s-maxage");
expect(cacheControl).toContain("stale-while-revalidate");
});
test("rejects a malformed figure with 400", async ({ request }) => {
const response = await request.get(
`/imaging?figure=${encodeURIComponent("../bad")}`,
);
expect(response.status()).toBe(400);
});
test("the imager preflight advertises GET", async ({ request }) => {
const response = await request.fetch("/imaging", { method: "OPTIONS" });
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Where the imaging specs are pointed.
*
* - Deploy gate (`playwright.config.ts`): hits the CMS directly, so the CMS's
* own `/imaging` route answers and its transcoding variants are reachable.
* - Cross-browser job (`playwright.imaging.config.ts` against the public
* origin): the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
* imager container, so the CMS route is shadowed. Only the endpoints that are
* identical in both topologies may be asserted unconditionally.
*
* Set `IMAGING_EDGE=1` when the base URL is behind the edge proxy.
*/
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
// The exact figure that regressed in production: the game client appends a
// trailing `.<n>` segment that the renderer ignores, but the previous
// validation regex rejected with a 400.
export const FIGURE =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
export const avatarPath = (params: Record<string, string> = {}) =>
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Public imager endpoint served through the edge proxy (imager container). */
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
`/imaging/avatarimage?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Counts non-overlapping occurrences of `needle` inside `haystack`. */
export function countBytes(haystack: Buffer, needle: Buffer): number {
let count = 0;
for (
let index = haystack.indexOf(needle);
index !== -1;
index = haystack.indexOf(needle, index + needle.length)
) {
count += 1;
}
return count;
}
/** PNG/JPEG/GIF/WebP/AVIF signatures, so a test can prove a body is an image. */
export function looksLikeImage(body: Buffer): boolean {
if (body.length < 12) return false;
if (body.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")))
return true; // PNG / APNG
if (body.subarray(0, 3).toString("ascii") === "GIF") return true;
if (body.subarray(0, 2).toString("hex") === "ffd8") return true; // JPEG
if (body.subarray(0, 4).toString("ascii") === "RIFF") return true; // WebP
if (body.subarray(4, 12).toString("ascii") === "ftypavif") return true; // AVIF
return false;
}
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
import { avatarPath } from "./imaging-target";
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
// correctness; this one catches a regression that keeps the image correct but
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
// flag a real regression, not a cold cache or a slow CI network.
const STATIC_MAX_BYTES = 64 * 1024;
const ANIMATED_MAX_BYTES = 512 * 1024;
const WARM_MAX_MS = 3000;
test("the static avatar stays inside its byte budget", async ({ request }) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
});
test("the animated avatar stays inside its byte budget", async ({
request,
}) => {
const response = await request.get(
avatarPath({ img_format: "apng", effect: "14" }),
);
expect(response.status()).toBe(200);
expect((await response.body()).length).toBeLessThanOrEqual(
ANIMATED_MAX_BYTES,
);
});
test("a warm avatar request is served quickly", async ({ request }) => {
const path = avatarPath({ img_format: "png" });
await request.get(path); // warm the disk and edge caches
const started = Date.now();
const response = await request.get(path);
const elapsed = Date.now() - started;
expect([200, 304]).toContain(response.status());
expect(elapsed).toBeLessThan(WARM_MAX_MS);
});
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
import {
avatarPath,
IMAGER_PATH,
IS_EDGE,
looksLikeImage,
} from "./imaging-target";
// Regression guard for the public topology: the edge proxy must front the
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
// Only meaningful against the public origin, so it is skipped when the CMS is
// reached directly (set `IMAGING_EDGE=1` for the public origin).
test.skip(
!IS_EDGE,
"Public routing is only asserted against the edge-fronted origin",
);
test("the edge serves the imager endpoint as an image", async ({ request }) => {
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the CMS avatar endpoint is reachable through the edge", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
const response = await request.get("/api/imaging/badge?code=ADM");
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
});
+2
View File
@@ -40,6 +40,8 @@
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts", "test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts", "test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots", "test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
"test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts",
"test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs", "performance:report": "node scripts/performance-report.mjs",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts" "test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
}, },
+7
View File
@@ -7,6 +7,13 @@ export default defineConfig({
retries: 1, retries: 1,
timeout: 30000, timeout: 30000,
reporter: [["list"], ["html", { open: "never" }]], reporter: [["list"], ["html", { open: "never" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
use: { use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002", baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure", trace: "retain-on-failure",
+41
View File
@@ -0,0 +1,41 @@
import { defineConfig, devices } from "@playwright/test";
// Cross-browser imaging checks. Unlike the main e2e config (chromium only,
// used as the deploy gate), this runs the same specs across every bundled
// engine so an avatar format that only one browser can decode is caught.
//
// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local
// host port). Requires all three engines: `pnpm exec playwright install
// chromium firefox webkit`.
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
export default defineConfig({
testDir: "./e2e/imaging",
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 60000,
expect: {
timeout: 15000,
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
reporter: [
["list"],
["html", { outputFolder: "playwright-report/imaging", open: "never" }],
],
outputDir: "test-results/imaging",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
+5 -4
View File
@@ -167,6 +167,7 @@ overrides:
'@esbuild-kit/core-utils': npm:tsx@^4.23.15 '@esbuild-kit/core-utils': npm:tsx@^4.23.15
'@esbuild-kit/esm-loader': npm:tsx@^4.23.15 '@esbuild-kit/esm-loader': npm:tsx@^4.23.15
source-map-js: 1.2.2 source-map-js: 1.2.2
postcss-selector-parser: ^7.1.6
importers: importers:
@@ -3255,8 +3256,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
postcss-selector-parser@6.0.10: postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
[email protected]: [email protected]:
@@ -4961,7 +4962,7 @@ snapshots:
'@tailwindcss/[email protected]([email protected])': '@tailwindcss/[email protected]([email protected])':
dependencies: dependencies:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 7.1.6
tailwindcss: 4.3.3 tailwindcss: 4.3.3
'@tanstack/[email protected]': {} '@tanstack/[email protected]': {}
@@ -6110,7 +6111,7 @@ snapshots:
[email protected]: {} [email protected]: {}
postcss-selector-parser@6.0.10: postcss-selector-parser@7.1.6:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
+5
View File
@@ -17,3 +17,8 @@ overrides:
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15' '@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15' '@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2' source-map-js: '1.2.2'
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
# exactly and has no upstream bump. The API subset it uses is unchanged in
# v7, so force the patched major.
postcss-selector-parser: '^7.1.6'
+12 -4
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Create/update the Cloudflare Cache Rule that stores the CMS public API # Create/update the Cloudflare Cache Rule that stores the CMS public API
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx # allowlist, the client-facing gamedata tree and the avatar/badge imaging
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`). # endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
# stuurt).
# #
# Why a rule is required: Cloudflare only caches a handful of file extensions # Why a rule is required: Cloudflare only caches a handful of file extensions
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even # by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
@@ -35,12 +37,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env" ENV_FILE="$SCRIPT_DIR/../.env"
BASE="https://api.cloudflare.com/client/v4" BASE="https://api.cloudflare.com/client/v4"
PHASE="http_request_cache_settings" PHASE="http_request_cache_settings"
DESCRIPTION="EpicNabbo CMS public API + gamedata edge cache (cms-public, cms-gamedata)" DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf). # Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en # Geen regex: `matches` vereist Business; vrije operators zijn `in` en
# `starts_with()`. # `starts_with()`.
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/gamedata/"))' #
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
# tussen gebruikers.
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
load_env() { load_env() {
local name="$1" local name="$1"
+21 -1
View File
@@ -410,7 +410,10 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
fi fi
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm exec playwright install chromium # All three engines are needed: the chromium gate below plus the cross-browser
# imaging gate just before cutover. Idempotent — already-installed engines in
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
pnpm exec playwright install chromium firefox webkit
echo "Building $image" echo "Building $image"
# No --network=host: BuildKit only grants it via --allow=network.host, and the # No --network=host: BuildKit only grants it via --allow=network.host, and the
@@ -536,6 +539,18 @@ if [ "$blue_green" -eq 1 ]; then
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha" node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
# 4b. Cross-browser imaging gate. The chromium suite above proves the
# candidate serves the right bytes; this proves every bundled engine can
# actually decode the render. The webkit path is the one that historically
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
# a release out in an emergency.
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
else
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
fi
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat. # 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
cutover_started=1 cutover_started=1
switch_upstream "$new_port" switch_upstream "$new_port"
@@ -569,6 +584,11 @@ else
healthy 3002 healthy 3002
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha" node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
else
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
fi
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)" verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
fi fi
docker tag "$verified_image" "epicnext-cms:verified-$sha" docker tag "$verified_image" "epicnext-cms:verified-$sha"
+5 -2
View File
@@ -11,6 +11,9 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$LOG_FILE")"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; } log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit 1; } die() { log "ERROR: $*"; exit 1; }
# A run that legitimately has nothing to do (the host is owned by CI) must not
# look like a failure to cron. `die` stays reserved for real errors.
skip() { log "SKIP: $*"; exit 0; }
migration_image="" migration_image=""
remote_migration_image="" remote_migration_image=""
previous_image="" previous_image=""
@@ -72,11 +75,11 @@ trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were dele
# the only thing that still marks the host as blue/green when a slot is idle. # the only thing that still marks the host as blue/green when a slot is idle.
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}" ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment." skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
fi fi
for slot_container in epicnext-cms-app epicnext-cms-green; do for slot_container in epicnext-cms-app epicnext-cms-green; do
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment." skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
fi fi
done done
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first." [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Alert when the local avatar imager stops rendering, and again when it recovers.
#
# Intended to run from cron every few minutes (see `crontab -l`). Steady-state is
# silent; a state change posts to DISCORD_WEBHOOK_URL (from the environment or
# .env). While the imager stays down it re-alerts at most once per
# HEALTH_ALERT_COOLDOWN_MIN. The script always exits 0 so an outage never turns
# into a noisy cron failure; the alert itself (and this log) is the signal.
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_FILE="${IMAGER_HEALTH_LOG:-$DIR/logs/imager-health.log}"
STATE_FILE="${IMAGER_HEALTH_STATE:-$DIR/logs/imager-health.state}"
HEALTH_URL="${IMAGER_HEALTH_URL:-http://127.0.0.1:8082/health}"
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"; }
load_env() {
local name="$1"
if [ -n "${!name:-}" ]; then return 0; fi
if [ -f "$DIR/.env" ]; then
local line
line="$(grep -m1 "^$name=" "$DIR/.env" | cut -d= -f2- | tr -d "'\"")" || true
if [ -n "$line" ]; then printf -v "$name" '%s' "$line"; fi
fi
}
load_env DISCORD_WEBHOOK_URL || true
COOLDOWN_MIN="${HEALTH_ALERT_COOLDOWN_MIN:-30}"
notify() {
local msg="$1"
log "ALERT: $msg"
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
log "no DISCORD_WEBHOOK_URL configured; alert written to log only"
return 0
fi
local payload
payload="$(python3 -c 'import json,sys;print(json.dumps({"content":sys.argv[1]}))' "$msg")"
curl -sS -m 10 -H "Content-Type: application/json" -d "$payload" \
"$DISCORD_WEBHOOK_URL" >/dev/null 2>&1 || log "failed to post alert to Discord"
}
body="$(curl -sS -m 5 "$HEALTH_URL" 2>/dev/null || true)"
ready=0
if printf '%s' "$body" | grep -q '"ready":true'; then ready=1; fi
now="$(date +%s)"
prev_state="up"
prev_at=0
if [ -f "$STATE_FILE" ]; then read -r prev_state prev_at < "$STATE_FILE" || true; fi
[ -n "${prev_state:-}" ] || prev_state=up
[ -n "${prev_at:-}" ] || prev_at=0
if [ "$ready" = 1 ]; then
if [ "$prev_state" != "up" ]; then
notify "Avatar imager RECOVERED ($HEALTH_URL reports ready)"
fi
printf 'up %s\n' "$now" > "$STATE_FILE"
exit 0
fi
if [ "$prev_state" != "down" ]; then
notify "Avatar imager DOWN ($HEALTH_URL not ready: ${body:-unreachable})"
printf 'down %s\n' "$now" > "$STATE_FILE"
exit 0
fi
if [ "$(( now - prev_at ))" -ge "$(( COOLDOWN_MIN * 60 ))" ]; then
notify "Avatar imager STILL DOWN after ${COOLDOWN_MIN}m ($HEALTH_URL)"
printf 'down %s\n' "$now" > "$STATE_FILE"
fi
exit 0
+1
View File
@@ -99,6 +99,7 @@ async function NewsPage({
fill fill
sizes="(max-width: 1024px) 50vw, 300px" sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
unoptimized
/> />
</div> </div>
) : ( ) : (
+34 -23
View File
@@ -170,12 +170,9 @@ function GlassPanel({
} }
async function getHotelData() { async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [ const [
hotelName,
logo,
online, online,
users, users,
rooms, rooms,
@@ -185,6 +182,8 @@ async function getHotelData() {
recentUsers, recentUsers,
recentPhotos, recentPhotos,
] = await Promise.all([ ] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")), cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch( cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"), publicReadFailure("home.users"),
@@ -243,22 +242,34 @@ export default async function Home() {
const session = await auth(); const session = await auth();
if (session?.user?.id) redirect("/me"); if (session?.user?.id) redirect("/me");
const tp = await getTranslations("pages.home"); const [
const te = await getTranslations("publicData"); tp,
const th = await getTranslations("header"); te,
const tn = await getTranslations("nav"); th,
const { tn,
hotelName, {
online, hotelName,
users, online,
rooms, users,
totalPhotos, rooms,
articleCount, totalPhotos,
articles, articleCount,
recentUsers: rawRecentUsers, articles,
recentPhotos, recentUsers: rawRecentUsers,
logo, recentPhotos,
} = await getHotelData(); logo,
},
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
// Users who hide their online state must not surface in the "who is online" // Users who hide their online state must not surface in the "who is online"
// rails on the homepage either. // rails on the homepage either.
@@ -271,8 +282,7 @@ export default async function Home() {
: rawRecentUsers.filter( : rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false, (u) => recentUserPrivacy.get(u.id)?.online !== false,
); );
const captcha = await captchaConfig(); const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const nonce = (await headers()).get("x-nonce") ?? undefined;
const features = [ const features = [
{ {
@@ -315,6 +325,7 @@ export default async function Home() {
fill fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"} sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108" className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/> />
<div <div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100" className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
+10 -9
View File
@@ -1,5 +1,8 @@
import { ClipboardCheck } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuditClient } from "@/app/admin/import/audit/audit-client"; import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioAuditPage(_props: { export default async function StudioAuditPage(_props: {
@@ -9,16 +12,14 @@ export default async function StudioAuditPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={ClipboardCheck}
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1> title={t("nav.catalogAudit")}
<p className="mt-1 text-muted-foreground"> subtitle={t("pages.auditSubtitle")}
Scan your entire furniture catalog for missing entries, broken >
references, missing files, and items available from clone sources.
</p>
</div>
<AuditClient /> <AuditClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { BadgeCheck } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioBadgesPage(_props: { export default async function StudioBadgesPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioBadgesPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={BadgeCheck}
title={t("nav.badges")}
subtitle={t("pages.badgesSubtitle")}
>
<ImportBadgesClient /> <ImportBadgesClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Copy } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClonePage(_props: { export default async function StudioClonePage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClonePage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Copy}
title={t("nav.importFromSource")}
subtitle={t("pages.cloneSubtitle")}
>
<ImportCloneClient /> <ImportCloneClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Shirt } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: { export default async function StudioClothingPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClothingPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Shirt}
title={t("nav.clothing")}
subtitle={t("pages.clothingSubtitle")}
>
<ImportClothingClient /> <ImportClothingClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Dumbbell } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioEffectsPage(_props: { export default async function StudioEffectsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioEffectsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Dumbbell}
title={t("nav.effects")}
subtitle={t("pages.effectsSubtitle")}
>
<ImportEffectsClient /> <ImportEffectsClient />
</div> </StudioPage>
); );
} }
+30
View File
@@ -0,0 +1,30 @@
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>
);
}
+23 -14
View File
@@ -1,7 +1,9 @@
import { Wrench } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel"; import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() { export default async function StudioCatalogMaintenancePage() {
@@ -16,20 +18,27 @@ export default async function StudioCatalogMaintenancePage() {
session.user.rank, session.user.rank,
); );
const t = await getTranslations("pages.admin.catalog.integrity"); const t = await getTranslations("pages.admin.catalog.integrity");
const studioT = await getTranslations("admin.studio");
return ( return (
<div className="min-h-0 space-y-6"> <StudioPage
<CatalogIntegrityPanel canRepair={canRepair} /> icon={Wrench}
{canRepair && ( title={studioT("nav.catalogMaintenance")}
<details className="rounded-xl border p-4"> subtitle={studioT("pages.maintenanceSubtitle")}
<summary className="cursor-pointer font-medium"> >
{t("legacyTitle")} <div className="min-h-0 space-y-6">
</summary> <CatalogIntegrityPanel canRepair={canRepair} />
<p className="mt-3 text-sm text-muted-foreground"> {canRepair && (
{t("legacyHint")} <details className="rounded-xl border p-4">
</p> <summary className="cursor-pointer font-medium">
<CatalogMaintenancePanel /> {t("legacyTitle")}
</details> </summary>
)} <p className="mt-3 text-sm text-muted-foreground">
</div> {t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
</StudioPage>
); );
} }
+10 -9
View File
@@ -1,5 +1,8 @@
import { FileX2 } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel"; import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioNitroCleanupPage() { export default async function StudioNitroCleanupPage() {
@@ -7,16 +10,14 @@ export default async function StudioNitroCleanupPage() {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={FileX2}
<h1 className="text-2xl font-bold tracking-tight">Nitro cleanup</h1> title={t("nav.nitroCleanup")}
<p className="mt-1 text-muted-foreground"> subtitle={t("pages.nitroCleanupSubtitle")}
Scan the nitro directories for fake bundles (no matching furniture) >
and broken bundles (unparseable) and remove them.
</p>
</div>
<NitroCleanupPanel /> <NitroCleanupPanel />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Cat } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioPetsPage(_props: { export default async function StudioPetsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioPetsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Cat}
title={t("nav.pets")}
subtitle={t("pages.petsSubtitle")}
>
<ImportPetsClient /> <ImportPetsClient />
</div> </StudioPage>
); );
} }
+10 -12
View File
@@ -1,5 +1,8 @@
import { Image as ImageIcon } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioRepairIconsPage(_props: { export default async function StudioRepairIconsPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioRepairIconsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={ImageIcon}
<h1 className="text-2xl font-bold tracking-tight"> title={t("pages.repairIconsTitle")}
Repair Missing Icons subtitle={t("pages.repairIconsSubtitle")}
</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 /> <RepairIconsClient />
</div> </StudioPage>
); );
} }
+35 -28
View File
@@ -17,6 +17,7 @@ import {
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper"; import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher"; import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
@@ -28,34 +29,38 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type StudioLink = { href: string; label: string; icon: LucideIcon }; type StudioLink = { href: string; labelKey: string; icon: LucideIcon };
const assets: StudioLink[] = [ const assets: StudioLink[] = [
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle }, { href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck }, { href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt }, { href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell }, { href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat }, { href: "/admin/studio/pets", labelKey: "pets", icon: Cat },
]; ];
const imports: StudioLink[] = [ const imports: StudioLink[] = [
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload }, { href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy }, { href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw }, { href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw },
]; ];
const maintenance: StudioLink[] = [ const maintenance: StudioLink[] = [
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck }, {
href: "/admin/studio/audit",
labelKey: "catalogAudit",
icon: ClipboardCheck,
},
{ {
href: "/admin/studio/repair-icons", href: "/admin/studio/repair-icons",
label: "Repair icons", labelKey: "repairIcons",
icon: ImageIcon, icon: ImageIcon,
}, },
{ {
href: "/admin/studio/maintenance", href: "/admin/studio/maintenance",
label: "Catalog maintenance", labelKey: "catalogMaintenance",
icon: Wrench, icon: Wrench,
}, },
{ {
href: "/admin/studio/nitro-cleanup", href: "/admin/studio/nitro-cleanup",
label: "Nitro cleanup", labelKey: "nitroCleanup",
icon: FileX2, icon: FileX2,
}, },
]; ];
@@ -71,6 +76,7 @@ function StudioMenu({
links: StudioLink[]; links: StudioLink[];
pathname: string; pathname: string;
}) { }) {
const t = useTranslations("admin.studio");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href)); const active = links.find((link) => activePath(pathname, link.href));
return ( return (
@@ -90,7 +96,7 @@ function StudioMenu({
align="end" align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]" className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
> >
{links.map(({ href, label: title, icon: Icon }) => ( {links.map(({ href, labelKey, icon: Icon }) => (
<Link <Link
key={href} key={href}
href={href} href={href}
@@ -103,8 +109,8 @@ function StudioMenu({
aria-current={activePath(pathname, href) ? "page" : undefined} 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" 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} /> <Icon size={16} aria-hidden />
{title} {t(`nav.${labelKey}`)}
</Link> </Link>
))} ))}
</PopoverContent> </PopoverContent>
@@ -120,14 +126,15 @@ export function StudioLayoutClient({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const t = useTranslations("admin.studio");
return ( return (
<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)]"> <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]">
<nav <nav
aria-label="Catalog Studio" aria-label={t("nav.ariaLabel")}
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2" className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
> >
<div className="flex flex-wrap gap-1"> <div className="flex shrink-0 gap-1">
{assets.map(({ href, label, icon: Icon }) => ( {assets.map(({ href, labelKey, icon: Icon }) => (
<Link <Link
key={href} key={href}
href={href} href={href}
@@ -138,35 +145,35 @@ export function StudioLayoutClient({
}} }}
aria-current={activePath(pathname, href) ? "page" : undefined} aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn( className={cn(
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors", "flex min-h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm font-medium transition-colors",
activePath(pathname, href) activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]" ? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10", : "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)} )}
> >
<Icon size={16} /> <Icon size={16} aria-hidden />
{label} {t(`nav.${labelKey}`)}
</Link> </Link>
))} ))}
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="ml-auto flex shrink-0 gap-1 pl-1">
{canEditCatalog && ( {canEditCatalog && (
<> <>
<CatalogManagerDialog <CatalogManagerDialog
catalogType="normal" catalogType="normal"
canEdit canEdit
triggerLabel="Catalog manager" triggerLabel={t("nav.catalogManager")}
/> />
<OrganizeImportsLauncher /> <OrganizeImportsLauncher />
</> </>
)} )}
<StudioMenu <StudioMenu
label="Import & sync" label={t("nav.importSync")}
links={imports} links={imports}
pathname={pathname} pathname={pathname}
/> />
<StudioMenu <StudioMenu
label="Maintenance" label={t("nav.maintenance")}
links={maintenance} links={maintenance}
pathname={pathname} pathname={pathname}
/> />
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,6 +14,7 @@ interface Configuration {
hasToken: boolean; hasToken: boolean;
} }
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) { export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const t = useTranslations("admin.studio.sync");
const [config, setConfig] = useState<Configuration | null>(null), const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""), [token, setToken] = useState(""),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
@@ -26,10 +28,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
signal: abort.signal, signal: abort.signal,
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) if (!res.ok) throw new Error(t("loadFailed"));
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json(); const data = await res.json();
if (!abort.signal.aborted) setConfig(data); if (!abort.signal.aborted) setConfig(data);
} catch (error) { } catch (error) {
@@ -37,7 +36,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
} }
})(); })();
return () => abort.abort(); return () => abort.abort();
}, []); }, [t]);
async function submit(action: "save" | "test") { async function submit(action: "save" | "test") {
if (!config || busy) return; if (!config || busy) return;
setBusy(true); setBusy(true);
@@ -50,12 +49,11 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
body: JSON.stringify({ ...config, token, action }), body: JSON.stringify({ ...config, token, action }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) if (!res.ok) throw new Error(data.error || t("requestFailed"));
throw new Error(data.error || "Git configuration request failed");
if (action === "save") { if (action === "save") {
setConfig(data); setConfig(data);
setToken(""); setToken("");
setMessage("Gitea configuration saved."); setMessage(t("saved"));
onSaved(); onSaved();
} else setMessage(data.message); } else setMessage(data.message);
} catch (error) { } catch (error) {
@@ -70,19 +68,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
open open
> >
<summary className="cursor-pointer font-semibold"> <summary className="cursor-pointer font-semibold">
Gitea connection settings {t("gitTitle")}
</summary> </summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]"> <p className="my-3 text-sm text-[var(--admin-text-muted)]">
Connect your self-hosted Gitea repository over HTTPS. The CMS manages {t("gitIntro")}
its own checkout; no Git-specific .env settings are needed.
</p> </p>
{config && ( {config && (
<fieldset disabled={busy} className="space-y-3"> <fieldset disabled={busy} className="space-y-3">
<p className="text-sm"> <p className="text-sm">
Provider: <strong>Gitea self-hosted</strong> {t("providerLabel")} <strong>{t("providerValue")}</strong>
</p> </p>
<label htmlFor="catalog-git-1" className="block text-sm"> <label htmlFor="catalog-git-1" className="block text-sm">
Repository URL {t("repositoryUrl")}
<Input <Input
id="catalog-git-1" id="catalog-git-1"
type="url" type="url"
@@ -94,7 +91,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label> </label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm"> <label htmlFor="catalog-git-2" className="block text-sm">
Branch {t("branch")}
<Input <Input
id="catalog-git-2" id="catalog-git-2"
value={config.branch} value={config.branch}
@@ -104,7 +101,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
/> />
</label> </label>
<label htmlFor="catalog-git-3" className="block text-sm"> <label htmlFor="catalog-git-3" className="block text-sm">
Gitea username {t("username")}
<Input <Input
id="catalog-git-3" id="catalog-git-3"
autoComplete="off" autoComplete="off"
@@ -116,23 +113,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label> </label>
</div> </div>
<label htmlFor="catalog-git-4" className="block text-sm"> <label htmlFor="catalog-git-4" className="block text-sm">
Gitea access token {t("accessToken")}
<Input <Input
id="catalog-git-4" id="catalog-git-4"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={token} value={token}
placeholder={ placeholder={config.hasToken ? t("tokenSaved") : t("tokenEnter")}
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)} onChange={(event) => setToken(event.target.value)}
/> />
</label> </label>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
The token is stored encrypted and is never returned to the browser. {t("tokenNote")}
Changing repository or username requires entering the token again.
</p> </p>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input <input
@@ -142,14 +134,14 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
setConfig({ ...config, enabled: event.target.checked }) setConfig({ ...config, enabled: event.target.checked })
} }
/> />
Automatically export catalog changes {t("autoExport")}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}> <Button variant="outline" onClick={() => void submit("test")}>
Test connection {t("testConnection")}
</Button> </Button>
<Button onClick={() => void submit("save")}> <Button onClick={() => void submit("save")}>
{busy ? "Working…" : "Save Gitea settings"} {busy ? t("working") : t("saveSettings")}
</Button> </Button>
</div> </div>
</fieldset> </fieldset>
+30 -29
View File
@@ -1,6 +1,9 @@
import { RefreshCw } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client"; import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client"; import { CatalogExportClient } from "./catalog-export-client";
@@ -12,44 +15,42 @@ export default async function StudioSyncPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-8"> <StudioPage
<CatalogExportClient icon={RefreshCw}
canConfigure={canAccess( title={t("nav.syncRepository")}
permissions, subtitle={t("sync.pageSubtitle")}
PERMS.SETTINGS_EDIT, >
session.user.rank, <div className="space-y-6">
)} <CatalogExportClient
/> canConfigure={canAccess(
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1> permissions,
<p className="text-muted-foreground mt-2"> PERMS.SETTINGS_EDIT,
Scan configured sources and automatically import any furniture that is session.user.rank,
not yet present in your hotel. )}
</p> />
<div className="space-y-4"> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<div> <h2 className="text-lg font-semibold">
<h2 className="text-lg font-semibold text-muted-foreground mb-2"> {t("sync.cloneSourcesTitle")}
Clone Sources
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-[var(--admin-text-muted)]">
Scan all configured clone sources and automatically import any {t("sync.cloneSourcesBody")}
furniture that is not yet present in your hotel.
</p> </p>
<SyncAllClient /> <SyncAllClient />
</div> </section>
<div> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold text-muted-foreground mb-2"> <h2 className="text-lg font-semibold">
Official Furnidata {t("sync.officialFurnidataTitle")}
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-[var(--admin-text-muted)]">
Scan the official Habbo furnidata and automatically import any {t("sync.officialFurnidataBody")}
furniture that your hotel doesn't have yet.
</p> </p>
<OfficialSyncClient /> <OfficialSyncClient />
</div> </section>
</div> </div>
</div> </StudioPage>
); );
} }
+10 -12
View File
@@ -1,5 +1,8 @@
import { Upload } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioUploadPage(_props: { export default async function StudioUploadPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioUploadPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={Upload}
<h1 className="text-2xl font-bold tracking-tight"> title={t("pages.uploadTitle")}
Upload .hab Furniture subtitle={t("pages.uploadSubtitle")}
</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 /> <UploadFurniClient />
</div> </StudioPage>
); );
} }
+37 -4
View File
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager"; import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { import {
fetchAvatarImage, fetchAvatarImage,
ImagerUnavailableError, ImagerUnavailableError,
@@ -20,12 +21,16 @@ function cacheControlFor(source: string): string {
switch (source) { switch (source) {
case "fallback": case "fallback":
case "cache-stale": case "cache-stale":
// Reuse the render briefly but revalidate the upstream soon. // Reuse the render briefly but revalidate the upstream soon. The
return "public, max-age=300"; // shared `s-maxage` lets the edge (Cloudflare) hold it for an hour
// without pinning a degraded fallback in the browser.
return "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600";
case "unavailable": case "unavailable":
return "no-cache, max-age=0"; return "no-cache, max-age=0";
default: default:
return "public, max-age=3600"; // s-maxage=86400 lets the edge cache a day off the origin, matching
// the sibling /imaging route; the URL already keys per figure.
return "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
} }
} }
@@ -43,24 +48,52 @@ export async function GET(request: NextRequest) {
parsed.params, parsed.params,
); );
const etag = strongEtag(body);
const cacheControl = cacheControlFor(source);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, { return new NextResponse(body as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Cache-Control": cacheControlFor(source), "Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
}); });
} catch (error) { } catch (error) {
if (error instanceof ImagerUnavailableError) { if (error instanceof ImagerUnavailableError) {
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControlFor("unavailable"),
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, { return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "image/svg+xml", "Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length), "Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"), "Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable", "X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
+90 -21
View File
@@ -7,6 +7,7 @@ import {
readImagingCache, readImagingCache,
writeImagingCache, writeImagingCache,
} from "@/lib/imager-cache"; } from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { resolveBadgeBase } from "@/lib/runtime-asset-config"; import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -33,6 +34,64 @@ const BADGE_PLACEHOLDER_SVG =
type BadgeSource = "upstream" | "local" | "cache" | "unavailable"; type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
interface ResolvedBadge {
body: Uint8Array;
contentType: string;
source: BadgeSource;
}
/**
* Concurrent cold-cache requests for the same badge would otherwise each walk
* the whole candidate list, and when the first candidate is a slow/unreachable
* CDN that means every one of them waits out the fetch timeout before falling
* back. Sharing a single resolution keeps a cold cache from stacking identical
* upstream work. Held on globalThis because Next can evaluate this module more
* than once per process.
*/
const globalForBadgeFetches = globalThis as typeof globalThis & {
imagingBadgeFetches?: Map<string, Promise<ResolvedBadge>>;
};
const inFlight =
globalForBadgeFetches.imagingBadgeFetches ??
new Map<string, Promise<ResolvedBadge>>();
globalForBadgeFetches.imagingBadgeFetches = inFlight;
async function resolveBadge(
cacheKey: string,
code: string,
candidates: string[],
): Promise<ResolvedBadge> {
const pending = inFlight.get(cacheKey);
if (pending) return pending;
const work = (async (): Promise<ResolvedBadge> => {
for (const base of candidates) {
const result = await fetchCandidate(base, code);
if (result) {
await writeImagingCache(
badgeCacheDir(),
cacheKey,
result.body,
result.contentType,
);
return result;
}
}
return {
body: new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
contentType: "image/svg+xml",
source: "unavailable",
};
})();
inFlight.set(cacheKey, work);
try {
return await work;
} finally {
if (inFlight.get(cacheKey) === work) inFlight.delete(cacheKey);
}
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code")?.trim(); const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) { if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
@@ -56,25 +115,20 @@ export async function GET(request: NextRequest) {
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`); const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey); const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache"); if (cached)
return badgeResponse(
for (const base of candidates) { cached.body,
const result = await fetchCandidate(base, code); cached.contentType,
if (result) { "cache",
await writeImagingCache( request.headers.get("if-none-match"),
badgeCacheDir(), );
cacheKey,
result.body,
result.contentType,
);
return badgeResponse(result.body, result.contentType, result.source);
}
}
const resolved = await resolveBadge(cacheKey, code, candidates);
return badgeResponse( return badgeResponse(
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG), resolved.body,
"image/svg+xml", resolved.contentType,
"unavailable", resolved.source,
request.headers.get("if-none-match"),
); );
} }
@@ -140,16 +194,31 @@ function badgeResponse(
body: Uint8Array, body: Uint8Array,
contentType: string, contentType: string,
source: BadgeSource, source: BadgeSource,
ifNoneMatch: string | null = null,
) { ) {
const etag = strongEtag(body);
const cacheControl =
source === "unavailable" ? "no-cache, max-age=0" : "public, max-age=3600";
// Same bytes the client already has: skip the body. Validators are hashed
// from the body, so a fallback→upstream swap still forces a fresh 200.
if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, { return new NextResponse(body as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Cache-Control": "Cache-Control": cacheControl,
source === "unavailable" ETag: etag,
? "no-cache, max-age=0"
: "public, max-age=3600",
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
+186 -28
View File
@@ -1,8 +1,47 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp"; import sharp from "sharp";
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream"; import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import {
avatarCacheDir,
imagingCacheKey,
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { isAnimatedPng } from "@/lib/imager-png";
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "@/lib/imager-upstream";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
// Site-wide default imager endpoint. NOTE ON TOPOLOGY: in the current
// production deployment the edge proxy (Traefik) fronts bare `/imaging` and
// `/imaging/*` directly to the local pixinode renderer container, so this CMS
// route is shadowed publicly and only answers when the CMS is reached directly
// (local/dev, self-hosted installs without that proxy rule, or the deploy gate).
// That is intentional: it keeps `habbo_imaging_url=/imaging` working without the
// CMS in the hot path, and this route remains the correct fallback for any
// install where no separate imager is proxied. Its behaviour is covered by
// `src/lib/imaging-route.test.ts` and the CMS-only e2e (`e2e/imaging/cms-imaging.spec.ts`).
//
// It differs from the sibling `/api/imaging/avatar` route in that it also
// transcodes PNG renders to WebP/AVIF (`?format=`) and is cached on disk per
// (figure, variant).
type AvatarFormat = "png" | "webp" | "avif"; type AvatarFormat = "png" | "webp" | "avif";
type TranscodeFormat = Exclude<AvatarFormat, "png">;
// Same neutral silhouette the sibling /api/imaging/avatar route serves, so a
// figure no upstream can render still shows a graceful image instead of the
// browser's broken-image glyph. This is the site-wide default imager endpoint,
// so a 502 here would blank avatars across every server-rendered page.
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
decodeURIComponent(
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
),
);
function resolveFormat(raw: string | null): AvatarFormat { function resolveFormat(raw: string | null): AvatarFormat {
switch (raw?.toLowerCase()) { switch (raw?.toLowerCase()) {
@@ -15,6 +54,89 @@ function resolveFormat(raw: string | null): AvatarFormat {
} }
} }
/**
* Concurrent cold-cache requests for the same figure all arrive with the same
* decoded PNG and the same target format, so they would each run the same
* sharp transcode — the one operation on this route that is CPU-bound. Sharing
* a single transcode across those requests keeps a cold cache from stacking
* identical encoder work. Held on globalThis because Next can evaluate this
* module more than once per process; separate copies would each start their own
* transcode and defeat the point.
*/
const globalForTranscodes = globalThis as typeof globalThis & {
imagingTranscodes?: Map<string, Promise<TranscodeResult | null>>;
};
const inFlight =
globalForTranscodes.imagingTranscodes ??
new Map<string, Promise<TranscodeResult | null>>();
globalForTranscodes.imagingTranscodes = inFlight;
interface TranscodeResult {
body: Uint8Array;
contentType: string;
}
/**
* Serve a WebP/AVIF variant of the decoded render, cached on disk so sharp only
* runs once per (figure, variant) rather than on every avatar request. This
* endpoint is the default imager, so re-encoding on each hit would burn CPU for
* an image that never changes.
*
* Returns `null` when a conversion cannot be produced (e.g. sharp cannot decode
* the input) so the caller falls back to serving the original render unchanged.
*/
async function transcodeToVariant(
body: Uint8Array,
format: TranscodeFormat,
variantKey: string,
): Promise<TranscodeResult | null> {
if (isAnimatedPng(body)) return null;
const cached = await readImagingCache(avatarCacheDir(), variantKey);
if (cached) return cached;
const pending = inFlight.get(variantKey);
if (pending) return pending;
const work = runTranscode(body, format, variantKey);
inFlight.set(variantKey, work);
try {
return await work;
} finally {
if (inFlight.get(variantKey) === work) inFlight.delete(variantKey);
}
}
async function runTranscode(
body: Uint8Array,
format: TranscodeFormat,
variantKey: string,
): Promise<TranscodeResult | null> {
try {
// `animated: true` lets sharp carry animation through for inputs it can
// decode frame-by-frame (animated GIF/WebP). APNG is handled before this
// point: libvips here reads only its first frame, so those are left as-is.
const pipeline = sharp(Buffer.from(body), { animated: true });
const out =
format === "webp"
? await pipeline.webp().toBuffer()
: await pipeline.avif().toBuffer();
const contentType = format === "webp" ? "image/webp" : "image/avif";
const result = { body: new Uint8Array(out), contentType };
// Best-effort: a failed write must never turn a good render into an
// error; the next request simply re-runs the transcode.
await writeImagingCache(
avatarCacheDir(),
variantKey,
result.body,
contentType,
);
return result;
} catch {
return null;
}
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -31,48 +153,84 @@ export async function GET(request: NextRequest) {
parsed.params, parsed.params,
); );
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
// Falls back to the original PNG if conversion fails.
let respBody: Uint8Array = body; let respBody: Uint8Array = body;
let respContentType = contentType; let respContentType = contentType;
if (format !== "png") { if (format !== "png") {
try { // The variant is keyed off the exact render that produced it (the
if (format === "webp") { // resolved upstream + the normalized query) plus the target format,
respBody = new Uint8Array( // mirroring how the underlying render itself is cached.
await sharp(Buffer.from(body)).webp().toBuffer(), const variantKey = imagingCacheKey(
); `${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
respContentType = "image/webp"; );
} else { const converted = await transcodeToVariant(body, format, variantKey);
respBody = new Uint8Array( if (converted) {
await sharp(Buffer.from(body)).avif().toBuffer(), respBody = converted.body;
); respContentType = converted.contentType;
respContentType = "image/avif";
}
} catch {
respBody = body;
respContentType = contentType;
} }
} }
const etag = strongEtag(respBody);
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
const cacheControl =
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(respBody as unknown as BodyInit, { return new NextResponse(respBody as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": respContentType, "Content-Type": respContentType,
"Content-Length": String(respBody.length), "Content-Length": String(respBody.length),
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC), "Cache-Control": cacheControl,
// stale-while-revalidate keeps them fresh without blocking requests. ETag: etag,
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
"Cache-Control":
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
}); });
} catch { } catch (error) {
if (error instanceof ImagerUnavailableError) {
// Every upstream is down and nothing was cached: serve the graceful
// silhouette as a 200 so <img> tags never break, and keep it
// uncached so a real render is picked up as soon as one recovers.
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": "no-cache, max-age=0",
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": "no-cache, max-age=0",
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" }, { error: "Failed to fetch avatar from upstream" },
{ status: 502 }, { status: 502 },
+4
View File
@@ -2,6 +2,10 @@
`studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree. `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 ## Client responsibilities
- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array. - `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array.
+37 -32
View File
@@ -9,19 +9,20 @@ import {
RefreshCw, RefreshCw,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useEffect, useMemo, useState } from "react"; import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types"; import type { BatchItemStatus, VerificationSummary } from "./studio-types";
const statusLabels: Record<BatchItemStatus["status"], string> = { const STATUS_KEYS: Record<BatchItemStatus["status"], string> = {
pending: "Waiting", pending: "status.pending",
started: "Starting", started: "status.started",
downloading: "Downloading assets", downloading: "status.downloading",
converting: "Preparing assets", converting: "status.converting",
writing_db: "Saving", writing_db: "status.writingDb",
done: "Imported", done: "status.done",
failed: "Failed", failed: "status.failed",
}; };
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -65,6 +66,7 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void; onDismiss?: () => void;
onRetry?: (classnames: string[]) => void; onRetry?: (classnames: string[]) => void;
}) { }) {
const t = useTranslations("admin.studio.batch");
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -99,20 +101,20 @@ export const BatchProgress = memo(function BatchProgress({
const rate = formatRate(completed, elapsed); const rate = formatRate(completed, elapsed);
const heading = !done const heading = !done
? cancelling ? cancelling
? "Stopping import…" ? t("headingStopping")
: "Importing furniture…" : t("headingImporting")
: unfinished : unfinished
? "Import stopped" ? t("headingStopped")
: errors : errors
? "Import completed with errors" ? t("headingCompletedErrors")
: "Import completed"; : t("headingCompleted");
const issues = verification const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts ? verification.furniDataMissing + verification.furniDataConflicts
: 0; : 0;
return ( return (
<section <section
aria-label="Import progress" aria-label={t("ariaProgress")}
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" 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"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -128,7 +130,7 @@ export const BatchProgress = memo(function BatchProgress({
<span className="font-semibold text-[var(--admin-success)]"> <span className="font-semibold text-[var(--admin-success)]">
{imported} {imported}
</span>{" "} </span>{" "}
imported {t("imported")}
</span> </span>
<span className="text-[var(--admin-border)]">·</span> <span className="text-[var(--admin-border)]">·</span>
<span> <span>
@@ -140,13 +142,13 @@ export const BatchProgress = memo(function BatchProgress({
> >
{errors} {errors}
</span>{" "} </span>{" "}
failed {t("failed")}
</span> </span>
{unfinished > 0 && ( {unfinished > 0 && (
<> <>
<span className="text-[var(--admin-border)]">·</span> <span className="text-[var(--admin-border)]">·</span>
<span> <span>
{unfinished} {done ? "not completed" : "remaining"} {unfinished} {done ? t("notCompleted") : t("remaining")}
</span> </span>
</> </>
)} )}
@@ -173,12 +175,12 @@ export const BatchProgress = memo(function BatchProgress({
onClick={() => onRetry(failedClassnames)} onClick={() => onRetry(failedClassnames)}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
Retry failed ({failedClassnames.length}) {t("retryFailed", { count: failedClassnames.length })}
</Button> </Button>
)} )}
<Button size="sm" variant="secondary" onClick={onDismiss}> <Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} /> <Check size={14} />
Close summary {t("closeSummary")}
</Button> </Button>
</> </>
) : ( ) : (
@@ -193,14 +195,14 @@ export const BatchProgress = memo(function BatchProgress({
) : ( ) : (
<Ban size={14} /> <Ban size={14} />
)} )}
Stop import {t("stopImport")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<div <div
role="progressbar" role="progressbar"
aria-label="Furniture processed" aria-label={t("ariaProcessed")}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={total || 1} aria-valuemax={total || 1}
aria-valuenow={completed} aria-valuenow={completed}
@@ -215,39 +217,42 @@ export const BatchProgress = memo(function BatchProgress({
<div className="mb-3 flex flex-wrap items-center gap-1.5"> <div className="mb-3 flex flex-wrap items-center gap-1.5">
<p className="mr-1 text-sm text-[var(--admin-text-muted)]"> <p className="mr-1 text-sm text-[var(--admin-text-muted)]">
{issues {issues
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.` ? t("verificationIssues", { count: issues })
: "Catalog data verification completed."} : t("verificationDone")}
</p> </p>
<NodeChip <NodeChip
count={verification.furniDataFixedIds} count={verification.furniDataFixedIds}
label="furnidata ids fixed" label={t("chipFurnidataIds")}
/> />
<NodeChip <NodeChip
count={verification.offerIdsFixed} count={verification.offerIdsFixed}
label="offer ids reconciled" label={t("chipOfferIds")}
/> />
<NodeChip <NodeChip
count={verification.ownershipFixed.length} count={verification.ownershipFixed.length}
label="ownerships fixed" label={t("chipOwnerships")}
/> />
<NodeChip <NodeChip
count={verification.nitrosSynced.length} count={verification.nitrosSynced.length}
label="nitros synced" label={t("chipNitros")}
/> />
<NodeChip <NodeChip
count={verification.iconsSynced.length} count={verification.iconsSynced.length}
label="icons synced" label={t("chipIcons")}
/> />
</div> </div>
)} )}
<details> <details>
<summary className="cursor-pointer text-xs font-medium"> <summary className="cursor-pointer text-xs font-medium">
View item details ({total}) {t("viewItemDetails", { count: total })}
</summary> </summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto"> <div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.length > VISIBLE_ITEMS && ( {entries.length > VISIBLE_ITEMS && (
<p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]"> <p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]">
Showing {VISIBLE_ITEMS} of {entries.length} items {t("showingItems", {
shown: VISIBLE_ITEMS,
total: entries.length,
})}
</p> </p>
)} )}
{entries.slice(0, VISIBLE_ITEMS).map((item) => ( {entries.slice(0, VISIBLE_ITEMS).map((item) => (
@@ -280,8 +285,8 @@ export const BatchProgress = memo(function BatchProgress({
</span> </span>
<span className="shrink-0 text-[var(--admin-text-muted)]"> <span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed" {done && item.status !== "done" && item.status !== "failed"
? "Not completed" ? t("statusNotCompleted")
: statusLabels[item.status]} : t(STATUS_KEYS[item.status])}
</span> </span>
</span> </span>
{item.message && ( {item.message && (
+18 -11
View File
@@ -10,6 +10,7 @@ import {
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useMemo, useState, useTransition } from "react"; import { memo, useMemo, useState, useTransition } from "react";
import { import {
refreshCatalogTree, refreshCatalogTree,
@@ -25,6 +26,7 @@ import type { TreeNode } from "@/types/catalog";
* member has no other signal that the import they just ran is not on screen. * member has no other signal that the import they just ran is not on screen.
*/ */
function LiveCatalogNotice() { function LiveCatalogNotice() {
const t = useTranslations("admin.studio");
const { refreshError, refreshing } = useLiveCatalog(); const { refreshError, refreshing } = useLiveCatalog();
const [retrying, startRetry] = useTransition(); const [retrying, startRetry] = useTransition();
if (!refreshError && !refreshing) return null; if (!refreshError && !refreshing) return null;
@@ -41,7 +43,7 @@ function LiveCatalogNotice() {
{refreshError ? ( {refreshError ? (
<> <>
<AlertTriangle size={14} className="shrink-0" /> <AlertTriangle size={14} className="shrink-0" />
<span className="min-w-0 flex-1">Catalog could not refresh.</span> <span className="min-w-0 flex-1">{t("rail.refreshFailed")}</span>
<button <button
type="button" type="button"
disabled={retrying} disabled={retrying}
@@ -53,13 +55,13 @@ function LiveCatalogNotice() {
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50" 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")} /> <RefreshCw size={12} className={cn(retrying && "animate-spin")} />
Retry {t("rail.retry")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<Loader2 size={14} className="shrink-0 animate-spin" /> <Loader2 size={14} className="shrink-0 animate-spin" />
<span className="min-w-0 flex-1">Updating catalog…</span> <span className="min-w-0 flex-1">{t("rail.updating")}</span>
</> </>
)} )}
</p> </p>
@@ -77,6 +79,7 @@ export const CatalogRail = memo(function CatalogRail({
onSelect: (id: number | null) => void; onSelect: (id: number | null) => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const t = useTranslations("admin.studio");
const [collapsed, setCollapsed] = useState<Set<number>>(new Set()); const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const visible = useMemo(() => { const visible = useMemo(() => {
@@ -100,7 +103,7 @@ export const CatalogRail = memo(function CatalogRail({
} }
return ( return (
<aside <aside
aria-label="Catalog categories" aria-label={t("aria.catalogCategories")}
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" 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"> <div className="flex items-center gap-2 px-3 pt-3">
@@ -109,12 +112,12 @@ export const CatalogRail = memo(function CatalogRail({
style={{ fontSize: 14, margin: 0 }} style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold" className="flex-1 text-sm font-semibold"
> >
Categories {t("rail.title")}
</h2> </h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close categories" aria-label={t("aria.closeCategories")}
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10" className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
> >
<X size={16} /> <X size={16} />
@@ -125,8 +128,8 @@ export const CatalogRail = memo(function CatalogRail({
<input <input
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder="Find a category…" placeholder={t("aria.findCategory")}
aria-label="Find a category" aria-label={t("aria.findCategory")}
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none" className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/> />
</label> </label>
@@ -136,13 +139,13 @@ export const CatalogRail = memo(function CatalogRail({
aria-pressed={selectedId === null} 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" 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"
> >
All categories {t("rail.all")}
</button> </button>
<LiveCatalogNotice /> <LiveCatalogNotice />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && ( {visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]"> <p className="p-3 text-sm text-[var(--admin-text-muted)]">
No matching categories. {t("rail.noMatches")}
</p> </p>
)} )}
{visible.map((node) => ( {visible.map((node) => (
@@ -159,7 +162,11 @@ export const CatalogRail = memo(function CatalogRail({
{node.childCount > 0 && !search.trim() ? ( {node.childCount > 0 && !search.trim() ? (
<button <button
type="button" type="button"
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`} aria-label={
collapsed.has(node.id)
? t("aria.expandCategory", { name: node.caption })
: t("aria.collapseCategory", { name: node.caption })
}
aria-expanded={!collapsed.has(node.id)} aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(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" className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
@@ -15,11 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector"; import { FurnitureInspector } from "./furniture-inspector";
import { import { getFurniImageUrl, handleImgError } from "./studio-furni-icons";
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps { interface FurnitureDetailDrawerProps {
@@ -49,6 +45,10 @@ export function FurnitureDetailDrawer({
onRepair, onRepair,
}: FurnitureDetailDrawerProps) { }: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness"); 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( const detailPreview = previewAutoCatalog(
detail.classname, detail.classname,
detail.type === "wallitem" ? "i" : "s", 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"> <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"> <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)]"> <span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail {drawerT("title")}
</span> </span>
<button <button
type="button" type="button"
aria-label="Close furniture details" aria-label={studioT("aria.closeDetails")}
onClick={onClose} onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" 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} ) : null}
<div className="mb-4 flex flex-wrap gap-1.5"> <div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">{typeLabel}</Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type} {drawerT("revision", { revision: detail.revision })}
</Badge> </Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? ( {detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40"> <Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")} <Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge> </Badge>
) : ( ) : (
<Badge className="text-[var(--admin-accent-foreground)]"> <Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready <Sparkles size={10} /> {drawerT("autoImportReady")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -127,22 +127,30 @@ export function FurnitureDetailDrawer({
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3"> <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)]"> <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)]" /> <WandSparkles size={12} className="text-[var(--admin-accent)]" />
Automatic placement {drawerT("automaticPlacement")}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Category</span> <span className="text-[var(--admin-text-muted)]">
{drawerT("category")}
</span>
<span className="font-semibold text-[var(--admin-text)]"> <span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel} {detailPreview.categoryLabel}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Price</span> <span className="text-[var(--admin-text-muted)]">
{drawerT("price")}
</span>
<span className="font-semibold text-[var(--admin-text)]"> <span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0 {detailPreview.points > 0
? ` + ${detailPreview.points} points` ? drawerT("priceCreditsPoints", {
: ""} credits: detailPreview.credits,
points: detailPreview.points,
})
: drawerT("priceCredits", {
count: detailPreview.credits,
})}
</span> </span>
</div> </div>
</div> </div>
@@ -163,14 +171,14 @@ export function FurnitureDetailDrawer({
) : ( ) : (
<CloudDownload size={14} /> <CloudDownload size={14} />
)} )}
Import & add to catalog {drawerT("importAdd")}
</Button> </Button>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{!detail.nitroExists && ( {!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)]"> <p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but its bundle file is missing. {drawerT("bundleMissing")}
</p> </p>
<Button <Button
variant="outline" variant="outline"
@@ -183,7 +191,7 @@ export function FurnitureDetailDrawer({
) : ( ) : (
<RefreshCw size={14} /> <RefreshCw size={14} />
)} )}
Regenerate .nitro {drawerT("regenerate")}
</Button> </Button>
</> </>
)} )}
@@ -194,7 +202,7 @@ export function FurnitureDetailDrawer({
disabled={!detail.nitroExists} disabled={!detail.nitroExists}
> >
<Pencil size={14} /> <Pencil size={14} />
Edit nitro {drawerT("editNitro")}
</Button> </Button>
</div> </div>
)} )}
@@ -206,7 +214,7 @@ export function FurnitureDetailDrawer({
onClick={onDelete} onClick={onDelete}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Delete imported furni {drawerT("deleteImported")}
</Button> </Button>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
compareFurniture, compareFurniture,
@@ -9,6 +10,21 @@ import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection"; 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({ export function FurnitureComparison({
item, item,
local, local,
@@ -16,69 +32,81 @@ export function FurnitureComparison({
item: FurniItem; item: FurniItem;
local: FurnitureInspection; local: FurnitureInspection;
}) { }) {
const t = useTranslations("admin.studio.inspector");
const comparison = compareFurniture(item, local); const comparison = compareFurniture(item, local);
return ( return (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<FurnitureCompleteness local={local} /> <FurnitureCompleteness local={local} />
{!local.furnidataReadable && ( {!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]"> <p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified. {t("furnidataUnreadable")}
</p> </p>
)} )}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr> <tr>
<th className="p-2 text-left">Field</th> <th className="p-2 text-left">{t("field")}</th>
<th className="p-2 text-left">Local now</th> <th className="p-2 text-left">{t("localNow")}</th>
<th className="p-2 text-left">Source</th> <th className="p-2 text-left">{t("source")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{comparison.fields.map((field) => ( {comparison.fields.map((field) => {
<tr const labelKey =
key={field.label} FIELD_KEYS[field.label as keyof typeof FIELD_KEYS];
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""} return (
> <tr
<th className="p-2 text-left"> key={field.label}
{field.label} className={
{field.changed ? " · Changed" : ""} field.changed ? "bg-[var(--admin-warning)]/10" : ""
</th> }
<td className="max-w-48 break-words p-2"> >
{field.before ?? "—"} <th className="p-2 text-left">
</td> {labelKey ? t(labelKey) : field.label}
<td className="max-w-48 break-words p-2"> {field.changed ? ` · ${t("changed")}` : ""}
{field.after ?? "Not supplied"} </th>
</td> <td className="max-w-48 break-words p-2">
</tr> {field.before ?? "—"}
))} </td>
<td className="max-w-48 break-words p-2">
{field.after ?? t("notSupplied")}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Source values are compared with local data. Import may remap IDs and {t("compareNote")}
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> </p>
<details> <details>
<summary className="cursor-pointer font-medium"> <summary className="cursor-pointer font-medium">
SQL records and catalog offers {t("sqlRecords")}
</summary> </summary>
<div className="mt-2 space-y-2 text-xs"> <div className="mt-2 space-y-2 text-xs">
{local.sql.length ? ( {local.sql.length ? (
local.sql.map((row) => ( local.sql.map((row) => (
<p key={row.id}> <p key={row.id}>
Item #{row.id} · Sprite #{row.spriteId} · {row.name} {t("itemRow", {
id: row.id,
sprite: row.spriteId,
name: row.name,
})}
</p> </p>
)) ))
) : ( ) : (
<p>No local SQL item.</p> <p>{t("noSqlItem")}</p>
)} )}
{local.catalog.map((row) => ( {local.catalog.map((row) => (
<p key={row.id}> <p key={row.id}>
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "} {t("offerRow", {
{row.points} points id: row.id,
page: row.pageId,
credits: row.credits,
points: row.points,
})}
</p> </p>
))} ))}
</div> </div>
@@ -95,11 +123,12 @@ export function FurnitureInspector({
onRepair?: () => Promise<unknown>; onRepair?: () => Promise<unknown>;
busy?: boolean; busy?: boolean;
}) { }) {
const t = useTranslations("admin.studio.inspector");
const inspection = useFurnitureInspection([item.classname]); const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading) if (inspection.loading)
return ( return (
<p role="status" className="py-3 text-sm"> <p role="status" className="py-3 text-sm">
Checking local data and assets… {t("checking")}
</p> </p>
); );
if (inspection.error) if (inspection.error)
@@ -107,7 +136,7 @@ export function FurnitureInspector({
<div role="alert"> <div role="alert">
<p>{inspection.error}</p> <p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}> <Button variant="outline" onClick={inspection.retry}>
Retry inspection {t("retry")}
</Button> </Button>
</div> </div>
); );
@@ -118,51 +147,35 @@ export function FurnitureInspector({
return local && health ? ( return local && health ? (
<div className="space-y-3"> <div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2"> <section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong> <strong>{t(HEALTH_KEYS[health.state])}</strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
{health.missing.length > 0 && ( {health.missing.length > 0 && (
<p className="text-sm">Missing: {health.missing.join(", ")}</p> <p className="text-sm">
{t("missing", { parts: health.missing.join(", ") })}
</p>
)} )}
{health.state === "conflict" && ( {health.state === "conflict" && (
<p className="text-sm"> <p className="text-sm">{t("conflictNote")}</p>
Duplicate records or mismatched IDs/types require review before
repair.
</p>
)} )}
{health.state === "unknown" && ( {health.state === "unknown" && (
<p className="text-sm"> <p className="text-sm">{t("unknownNote")}</p>
Some local data could not be checked. Retry verification before
repair.
</p>
)} )}
{health.state === "incomplete" && onRepair && ( {health.state === "incomplete" && onRepair && (
<> <>
<p className="text-sm text-[var(--admin-text-muted)]"> <p className="text-sm text-[var(--admin-text-muted)]">
Repair adds missing parts and preserves existing IDs, settings, {t("repairNote")}
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
</p> </p>
<Button disabled={busy} onClick={() => void onRepair()}> <Button disabled={busy} onClick={() => void onRepair()}>
{busy ? "Repair queued or running…" : "Repair furni"} {busy ? t("repairQueued") : t("repair")}
</Button> </Button>
</> </>
)} )}
<Button variant="outline" disabled={busy} onClick={inspection.retry}> <Button variant="outline" disabled={busy} onClick={inspection.retry}>
Recheck local state {t("recheck")}
</Button> </Button>
</section> </section>
<FurnitureComparison item={item} local={local} /> <FurnitureComparison item={item} local={local} />
</div> </div>
) : ( ) : (
<p>Local inspection unavailable.</p> <p>{t("unavailable")}</p>
); );
} }
+49 -51
View File
@@ -41,6 +41,7 @@ export function ImportReview({
onConfirm: (items: FurniItem[]) => void; onConfirm: (items: FurniItem[]) => void;
}) { }) {
const preflightT = useTranslations("admin.studio.preflight"); const preflightT = useTranslations("admin.studio.preflight");
const reviewT = useTranslations("admin.studio.review");
const [reviewFilter, setReviewFilter] = useState< const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify" "all" | "complete" | "conflict" | "verify"
>("all"); >("all");
@@ -59,14 +60,16 @@ export function ImportReview({
body: form, body: form,
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Upload failed"); if (!response.ok) throw Error(data.error || reviewT("uploadFailed"));
setAttachments((current) => ({ setAttachments((current) => ({
...current, ...current,
[item.classname]: data.attachmentId, [item.classname]: data.attachmentId,
})); }));
toast.success(diagnosticsT("attached")); toast.success(diagnosticsT("attached"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed"); toast.error(
error instanceof Error ? error.message : reviewT("uploadFailed"),
);
} finally { } finally {
setUploading(null); setUploading(null);
} }
@@ -134,46 +137,44 @@ export function ImportReview({
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{step === "review" {step === "review"
? "Review furniture import" ? reviewT("titleReview")
: "Confirm furniture import"} : reviewT("titleConfirm")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{reviewT("steps")}</DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto"> <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"> <div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p> <p>
<strong>Source:</strong> {source} <strong>{reviewT("source")}</strong> {source}
</p> </p>
<p> <p>
<strong>Destination:</strong> local catalog, furnidata, bundle and <strong>{reviewT("destination")}</strong>{" "}
icons {reviewT("destinationValue")}
</p> </p>
<p> <p>
<strong>Translation:</strong> {translation} <strong>{reviewT("translation")}</strong> {translation}
</p> </p>
<p> <p>
<strong>Selection:</strong> {items.length} furniture items <strong>{reviewT("selection")}</strong>{" "}
{reviewT("selectionCount", { count: items.length })}
</p> </p>
</div> </div>
{assets.loading && !inspection.loading && ( {assets.loading && !inspection.loading && (
<p role="status"> <p role="status">
Checking source files… {assets.items.length} checked {reviewT("checkingSource", { count: assets.items.length })}
</p> </p>
)} )}
{assets.error && ( {assets.error && (
<div role="alert"> <div role="alert">
<p>{assets.error}</p> <p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}> <Button variant="outline" onClick={assets.retry}>
Retry source check {reviewT("retrySource")}
</Button> </Button>
</div> </div>
)} )}
{assets.items.some((item) => item.state === "unknown") && ( {assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]"> <p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to {reviewT("unknownSources")}
download their files.
</p> </p>
)} )}
{unavailable.map((check) => ( {unavailable.map((check) => (
@@ -182,7 +183,7 @@ export function ImportReview({
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2" className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
> >
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Source files not found by preliminary check: {check.classname} {reviewT("sourceNotFound", { name: check.classname })}
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
{progressT("recoveryHint")} {progressT("recoveryHint")}
@@ -190,24 +191,25 @@ export function ImportReview({
</section> </section>
))} ))}
{inspection.loading ? ( {inspection.loading ? (
<p role="status">Checking local data and assets…</p> <p role="status">{reviewT("checkingLocal")}</p>
) : inspection.error ? ( ) : inspection.error ? (
<div role="alert"> <div role="alert">
<p>{inspection.error}</p> <p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}> <Button variant="outline" onClick={inspection.retry}>
Retry inspection {reviewT("retryInspection")}
</Button> </Button>
</div> </div>
) : !complete ? ( ) : !complete ? (
<p role="alert"> <p role="alert">{reviewT("incomplete")}</p>
Inspection is incomplete. Close and retry before importing.
</p>
) : ( ) : (
<> <>
<p role="status" className="text-sm"> <p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL {reviewT("summary", {
(complete missing data) · {blocked} conflicts ·{" "} ready: ready.length,
{unavailable.length} source file warnings existing,
blocked,
warnings: unavailable.length,
})}
</p> </p>
{blocked > 0 && ( {blocked > 0 && (
<div <div
@@ -232,8 +234,7 @@ export function ImportReview({
item.nitro.exists === null, item.nitro.exists === null,
) && ( ) && (
<p className="text-sm text-[var(--admin-warning)]"> <p className="text-sm text-[var(--admin-warning)]">
Some local files could not be verified. Review the item {reviewT("localUnverified")}
details before continuing.
</p> </p>
)} )}
{step === "review" ? ( {step === "review" ? (
@@ -329,14 +330,14 @@ export function ImportReview({
</span> </span>
<span> <span>
{comparison.state === "new" {comparison.state === "new"
? "New" ? reviewT("statusNew")
: comparison.state === "changed" : comparison.state === "changed"
? "Differences" ? reviewT("statusChanged")
: comparison.state === "conflict" : comparison.state === "conflict"
? "Conflict" ? reviewT("statusConflict")
: comparison.state === "unknown" : comparison.state === "unknown"
? "Not fully verified" ? reviewT("statusUnknown")
: "Present"} : reviewT("statusPresent")}
</span> </span>
</button> </button>
{expanded === item.classname && ( {expanded === item.classname && (
@@ -358,7 +359,9 @@ export function ImportReview({
}} }}
/> />
{uploading === item.classname && ( {uploading === item.classname && (
<span role="status">Validating file…</span> <span role="status">
{reviewT("validatingFile")}
</span>
)} )}
</label> </label>
)} )}
@@ -381,16 +384,10 @@ export function ImportReview({
) : ( ) : (
<> <>
<p className="text-sm"> <p className="text-sm">
Only the {ready.length} eligible items below will be {reviewT("confirmIntro", { count: ready.length })}
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>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Automatic categories and prices below are estimates. Source {reviewT("confirmEstimates")}
rules may override them; final results appear in the import
summary.
</p> </p>
<ul className="space-y-2"> <ul className="space-y-2">
{pageItems.map((item) => { {pageItems.map((item) => {
@@ -405,8 +402,11 @@ export function ImportReview({
> >
<strong>{item.name}</strong> <strong>{item.name}</strong>
<p> <p>
{placement.categoryLabel} · {placement.credits}{" "} {reviewT("placement", {
credits + {placement.points} points category: placement.categoryLabel,
credits: placement.credits,
points: placement.points,
})}
</p> </p>
</li> </li>
); );
@@ -424,17 +424,15 @@ export function ImportReview({
disabled={page === 0} disabled={page === 0}
onClick={() => setPage((value) => value - 1)} onClick={() => setPage((value) => value - 1)}
> >
Previous items {reviewT("previous")}
</Button> </Button>
<span> <span>{reviewT("page", { page: page + 1, total: pageCount })}</span>
Page {page + 1} / {pageCount}
</span>
<Button <Button
variant="outline" variant="outline"
disabled={page + 1 === pageCount} disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
> >
Next items {reviewT("next")}
</Button> </Button>
</div> </div>
)} )}
@@ -451,7 +449,7 @@ export function ImportReview({
: onCancel : onCancel
} }
> >
{step === "confirm" ? "Back to review" : "Cancel"} {step === "confirm" ? reviewT("backToReview") : reviewT("cancel")}
</Button> </Button>
<Button <Button
style={{ style={{
@@ -480,8 +478,8 @@ export function ImportReview({
}} }}
> >
{step === "review" {step === "review"
? "Continue to confirmation" ? reviewT("continue")
: `Start import (${ready.length})`} : reviewT("startImport", { count: ready.length })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -15,6 +16,7 @@ function kb(bytes: number): string {
} }
function CompressionPanel({ report }: { report: CompressionReport }) { function CompressionPanel({ report }: { report: CompressionReport }) {
const t = useTranslations("admin.studio.quality");
const { texture, json } = report, const { texture, json } = report,
// Differences that a renderer can actually see. // Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences, visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
@@ -26,20 +28,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return ( return (
<section className="min-w-0 space-y-3 rounded-lg border p-3"> <section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold"> <h3 className="font-semibold">
Compression · {texture.format.toUpperCase()} {t("compressionTitle", { format: texture.format.toUpperCase() })}
{texture.format === "webp" {texture.format === "webp"
? texture.lossless ? texture.lossless
? " Lossless (VP8L)" ? ` ${t("losslessVp8l")}`
: " Lossy (VP8)" : ` ${t("lossyVp8")}`
: ""} : ""}
</h3> </h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground">Bundle</dt> <dt className="text-muted-foreground">{t("bundle")}</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd> <dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Texture</dt> <dt className="text-muted-foreground">{t("texture")}</dt>
<dd className="font-medium"> <dd className="font-medium">
{kb(texture.compressedBytes)} {kb(texture.compressedBytes)}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -49,75 +51,85 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Metadata</dt> <dt className="text-muted-foreground">{t("metadata")}</dt>
<dd className="font-medium"> <dd className="font-medium">
{kb(json.compressedBytes)} {kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span> <span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Container</dt> <dt className="text-muted-foreground">{t("container")}</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd> <dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div> </div>
</dl> </dl>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} · {t("textureDetail", {
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
</p> </p>
{texture.format === "png" ? ( {texture.format === "png" ? (
<p className="text-xs"> <p className="text-xs">
Legacy PNG texture. Re-importing as WebP Lossless would store{" "} {texture.webpEstimate.savingBytes > 0
<strong>{kb(texture.webpEstimate.bytes)}</strong> ? t.rich("legacyPngSaving", {
{texture.webpEstimate.savingBytes > 0 ? ( size: kb(texture.webpEstimate.bytes),
<> saving: kb(texture.webpEstimate.savingBytes),
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> ( percent:
{texture.webpEstimate.bytes > 0 texture.webpEstimate.bytes > 0
? Math.round( ? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100, (1 - texture.webpEstimate.bytes / texture.rawBytes) *
) 100,
: 0} )
% smaller) : 0,
</> strong: (chunks) => <strong>{chunks}</strong>,
) : ( })
" (no gain on this bundle)" : t.rich("legacyPngNoGain", {
)} size: kb(texture.webpEstimate.bytes),
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p> </p>
) : ( ) : (
<p className="text-xs"> <p className="text-xs">
WebP Lossless stores {kb(texture.rawBytes)} against{" "} {pngSaving > 0
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG ? t.rich("webpSaving", {
{pngSaving > 0 ? ( raw: kb(texture.rawBytes),
<> png: kb(texture.pngEstimate.bytes),
, saving <strong>{kb(pngSaving)}</strong> ( saving: kb(pngSaving),
{texture.pngEstimate.bytes > 0 percent:
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100) texture.pngEstimate.bytes > 0
: 0} ? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
% smaller) : 0,
</> strong: (chunks) => <strong>{chunks}</strong>,
) : ( })
" (PNG is not larger on this bundle)" : t("webpNoGain", {
)} raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
})}
</p> </p>
)} )}
<p className="text-xs"> <p className="text-xs">
{visibleDiffs === 0 ? ( {visibleDiffs === 0 ? (
<> <>
<span className="font-medium text-foreground">Pixel-exact</span> — {t.rich("pixelExact", {
every visible texel round-trips unchanged. strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
{texture.invisibleDifferences > 0 && ( {texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
{texture.invisibleDifferences.toLocaleString()} fully {t("invisibleDiffs", {
transparent texels carry no RGB, which VP8L discards and no count: texture.invisibleDifferences,
renderer can see. })}
</span> </span>
)} )}
</> </>
) : ( ) : (
<strong className="text-destructive"> <strong className="text-destructive">
{visibleDiffs.toLocaleString()} texel {t("texelsDiffer", { count: visibleDiffs })}
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
</strong> </strong>
)} )}
</p> </p>
@@ -136,32 +148,34 @@ function ScalePanel({
}) { }) {
const [asset, setAsset] = useState(report.assets[0] ?? ""), const [asset, setAsset] = useState(report.assets[0] ?? ""),
[failed, setFailed] = useState(false); [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 ( return (
<section className="min-w-0 space-y-3 rounded-lg border p-3"> <section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold"> <h3 className="font-semibold">
Scale {report.size} ·{" "} {t("scaleTitle", { size: report.size, state: stateLabel })}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{report.assets.length} sprites · {report.animations.length} animation {t("spritesLine", {
definitions · directions {report.directions.join(", ") || "—"} sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
</p> </p>
{report.state === "missing" && ( {report.state === "missing" && (
<p className="text-sm"> <p className="text-sm">{t("missingScale")}</p>
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 && ( {report.assets.length > 0 && (
<> <>
<label className="block text-sm"> <label className="block text-sm">
Sprite {t("sprite")}
<select <select
className="mt-1 block w-full rounded border bg-background p-2" className="mt-1 block w-full rounded border bg-background p-2"
value={asset} value={asset}
@@ -179,7 +193,7 @@ function ScalePanel({
</label> </label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30"> <div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? ( {failed ? (
<p role="alert">This sprite could not be rendered.</p> <p role="alert">{t("renderFailed")}</p>
) : ( ) : (
<img <img
key={`${asset}-${revision}`} key={`${asset}-${revision}`}
@@ -210,6 +224,7 @@ function ScalePanel({
); );
} }
export function NitroQualityPanel({ classname }: { classname: string }) { export function NitroQualityPanel({ classname }: { classname: string }) {
const t = useTranslations("admin.studio.quality");
const [report, setReport] = useState<NitroQuality | null>(null), const [report, setReport] = useState<NitroQuality | null>(null),
[error, setError] = useState(""), [error, setError] = useState(""),
[revision, setRevision] = useState(0); [revision, setRevision] = useState(0);
@@ -224,35 +239,32 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal }, { signal: controller.signal },
); );
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Inspection failed"); if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!controller.signal.aborted) setReport(data.report); if (!controller.signal.aborted) setReport(data.report);
} catch (error) { } catch (error) {
if (!controller.signal.aborted) if (!controller.signal.aborted)
setError( setError(
error instanceof Error ? error.message : "Inspection failed", error instanceof Error ? error.message : t("inspectionFailed"),
); );
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [classname, revision]); }, [classname, revision, t]);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm"> <p className="text-sm">{t("intro")}</p>
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<Button <Button
variant="outline" variant="outline"
onClick={() => setRevision((value) => value + 1)} onClick={() => setRevision((value) => value + 1)}
> >
Recheck {t("recheck")}
</Button> </Button>
</div> </div>
{error ? ( {error ? (
<p role="alert">{error}</p> <p role="alert">{error}</p>
) : !report ? ( ) : !report ? (
<p role="status">Checking scales and texture references…</p> <p role="status">{t("checking")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{report.compression && ( {report.compression && (
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -11,6 +12,7 @@ export function NitroScale32Generator({
disabled: boolean; disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void; onChanged: (metadata: Record<string, unknown>) => void;
}) { }) {
const t = useTranslations("admin.studio.scale32");
const [preview, setPreview] = useState<{ const [preview, setPreview] = useState<{
hash: string; hash: string;
before: string; before: string;
@@ -38,7 +40,7 @@ export function NitroScale32Generator({
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Generation failed"); if (!response.ok) throw Error(data.error || t("generationFailed"));
if (action === "preview") setPreview(data); if (action === "preview") setPreview(data);
else { else {
setPreview(null); setPreview(null);
@@ -50,45 +52,37 @@ export function NitroScale32Generator({
onChanged(data.metadata); onChanged(data.metadata);
} }
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Generation failed"); setError(e instanceof Error ? e.message : t("generationFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
return ( return (
<section className="space-y-3 rounded-lg border p-3"> <section className="space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">Generate scale 32 from 64</h3> <h3 className="font-semibold">{t("title")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t("intro")}</p>
Adds derived artwork using nearest-neighbor reduction. Existing scale 32 {disabled && <p className="text-sm">{t("saveFirst")}</p>}
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 <Button
variant="outline" variant="outline"
disabled={busy || disabled} disabled={busy || disabled}
onClick={() => run("preview")} onClick={() => run("preview")}
> >
{busy ? "Working…" : "Preview generation"} {busy ? t("working") : t("preview")}
</Button> </Button>
{preview && ( {preview && (
<> <>
<p className="text-sm"> <p className="text-sm">
{preview.count} sprite entries will be added. Check positioning and {t("previewCount", { count: preview.count })}
animations in the client after saving.
</p> </p>
{preview.skipped ? ( {preview.skipped ? (
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
{preview.skipped} sprite(s) were skipped because the source bundle {t("skipped", { count: preview.skipped })}
has no usable artwork for them.
</p> </p>
) : null} ) : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {[
["Original 64", preview.before], [t("original64"), preview.before],
["Derived 32", preview.after], [t("derived32"), preview.after],
].map(([label, src]) => ( ].map(([label, src]) => (
<figure key={label} className="min-w-0"> <figure key={label} className="min-w-0">
<figcaption>{label}</figcaption> <figcaption>{label}</figcaption>
@@ -103,28 +97,26 @@ export function NitroScale32Generator({
))} ))}
</div> </div>
<Button disabled={busy || disabled} onClick={() => run("apply")}> <Button disabled={busy || disabled} onClick={() => run("apply")}>
Save with backup {t("saveWithBackup")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
disabled={busy} disabled={busy}
onClick={() => setPreview(null)} onClick={() => setPreview(null)}
> >
Discard preview {t("discard")}
</Button> </Button>
</> </>
)} )}
{undo && ( {undo && (
<div> <div>
<p className="text-sm"> <p className="text-sm">{t("saved")}</p>
Saved. A copy of the original bundle is preserved.
</p>
<Button <Button
variant="outline" variant="outline"
disabled={busy || disabled} disabled={busy || disabled}
onClick={() => run("restore")} onClick={() => run("restore")}
> >
Restore original bundle {t("restore")}
</Button> </Button>
</div> </div>
)} )}
@@ -851,10 +851,10 @@ export function OrganizeImportsDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0" className="text-xs"> <SelectItem value="0" className="text-xs">
Duckets {t("pointsTypeDuckets")}
</SelectItem> </SelectItem>
<SelectItem value="5" className="text-xs"> <SelectItem value="5" className="text-xs">
Diamonds {t("pointsTypeDiamonds")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1571,7 +1571,7 @@ export function OrganizeImportsDialog({
</span> </span>
</div> </div>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{previewGroup.items.length} items {previewGroup.items.length} {t("itemsUnit")}
</Badge> </Badge>
</div> </div>
{previewGroup.layout && {previewGroup.layout &&
@@ -1,6 +1,7 @@
"use client"; "use client";
import { FolderTree } from "lucide-react"; import { FolderTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { StudioPanelLoading } from "./studio-panel-loading"; import { StudioPanelLoading } from "./studio-panel-loading";
@@ -11,6 +12,7 @@ const OrganizeImportsDialog = lazy(() =>
); );
export function OrganizeImportsLauncher() { export function OrganizeImportsLauncher() {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [requested, setRequested] = useState(false); const [requested, setRequested] = useState(false);
return ( return (
@@ -24,7 +26,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" 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} /> <FolderTree size={16} />
Organize imports {t("title")}
</button> </button>
{requested && ( {requested && (
<Suspense <Suspense
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
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,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { setFurnidataTranslateEnabled } from "@/actions/import-furni"; import { setFurnidataTranslateEnabled } from "@/actions/import-furni";
@@ -24,6 +25,7 @@ const TRANSLATE_LANGS = [
] as const; ] as const;
export function useStudioTranslations(defaultTranslate: boolean) { export function useStudioTranslations(defaultTranslate: boolean) {
const t = useTranslations("admin.studio");
// Translation controls (per-import toggle + language selection, plus a global // Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle). // default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
@@ -52,13 +54,16 @@ export function useStudioTranslations(defaultTranslate: boolean) {
const langs = data.languages ?? []; const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length; const okCount = langs.filter((l) => l.ok).length;
toast.success( toast.success(
`Languages translated (${okCount}/${langs.length} completed)`, t("toasts.languagesTranslated", {
ok: okCount,
total: langs.length,
}),
); );
} else { } else {
toast.error(data.error ?? "Translation failed"); toast.error(data.error ?? t("toasts.translationFailed"));
} }
} catch { } catch {
toast.error("Network error while translating languages"); toast.error(t("toasts.networkTranslating"));
} finally { } finally {
setBuildingLanguages(false); setBuildingLanguages(false);
} }
@@ -75,7 +80,7 @@ export function useStudioTranslations(defaultTranslate: boolean) {
try { try {
await setFurnidataTranslateEnabled({ enabled: next }); await setFurnidataTranslateEnabled({ enabled: next });
} catch { } catch {
toast.error("Could not save the default translation setting"); toast.error(t("toasts.saveDefaultFailed"));
} }
}; };
+3
View File
@@ -75,6 +75,9 @@ const schema = z
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset. // when unset.
BADGE_UPLOAD_DIR: z.string().optional(), BADGE_UPLOAD_DIR: z.string().optional(),
// Extension local furniture bundles are written with and the client
// requests: ".hab" (default) or ".nitro". Accepts the bare form too.
FURNI_BUNDLE_EXT: z.string().optional(),
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set. // Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
EMULATOR_JAR_PATH: z.string().optional(), EMULATOR_JAR_PATH: z.string().optional(),
EMULATOR_BACKUP_DIR: z.string().optional(), EMULATOR_BACKUP_DIR: z.string().optional(),
+107
View File
@@ -0,0 +1,107 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/api/imaging/badge/route";
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-badge-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test so the candidate walk actually runs.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
// A configured CDN short-circuits the database lookup for badge_base_url.
vi.stubEnv("BADGE_URL", "https://badges.test/album1584");
globalThis.imagingBadgeFetches?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function gifResponse() {
return new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/gif" },
});
}
it("shares a single upstream fetch across concurrent cold-cache requests", async () => {
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return gifResponse();
}),
);
const request = () =>
new NextRequest("https://hotel.test/api/imaging/badge?code=ADM");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(a.headers.get("content-type")).toBe("image/gif");
expect(calls).toBe(1);
});
it("serves a previously fetched badge from disk without re-fetching", async () => {
const fetcher = vi.fn(async () => gifResponse());
vi.stubGlobal("fetch", fetcher);
const request = new NextRequest(
"https://hotel.test/api/imaging/badge?code=HABBO",
);
const first = await GET(request);
expect(first.status).toBe(200);
const callsAfterFirst = fetcher.mock.calls.length;
expect(callsAfterFirst).toBeGreaterThan(0);
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("x-imager-source")).toBe("cache");
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
});
it("revalidates a cached badge with a 304 when the client already has it", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => gifResponse()),
);
const url = "https://hotel.test/api/imaging/badge?code=ETAG";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves a placeholder when every candidate is unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 404 })),
);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=GONE"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
});
it("rejects malformed badge codes without contacting any candidate", async () => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=../bad"),
);
expect(result.status).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
+17 -1
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp"; import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => { describe("csp", () => {
@@ -19,4 +19,20 @@ describe("csp", () => {
expect(csp).toContain("https://challenges.cloudflare.com"); expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).not.toContain("https://cdn.jsdelivr.net"); expect(csp).not.toContain("https://cdn.jsdelivr.net");
}); });
it("blocks framing and upgrades insecure requests outside development", () => {
vi.stubEnv("NODE_ENV", "production");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
it("omits upgrade-insecure-requests in development", () => {
vi.stubEnv("NODE_ENV", "development");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
}); });
+6
View File
@@ -28,11 +28,17 @@ export function buildContentSecurityPolicy(nonce: string): string {
"style-src-attr 'unsafe-inline'", "style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
// Clickjacking defence that also covers the `frame-ancestors`-aware
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
// production-only: over plain-HTTP localhost it would rewrite same-origin
// asset URLs to https and break the dev server.
"frame-ancestors 'none'",
"connect-src 'self' https: wss:", "connect-src 'self' https: wss:",
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; "); ].join("; ");
} }
+4
View File
@@ -14,6 +14,10 @@ describe("classifyDdos", () => {
["/api/auth/callback/credentials", "auth"], ["/api/auth/callback/credentials", "auth"],
["/api/articles/hello", "api"], ["/api/articles/hello", "api"],
["/api/radio/stream", "api"], ["/api/radio/stream", "api"],
// Imaging is deliberately in the `api` bucket: it is high-volume and
// cacheable, so it gets the generous api budget, not the page budget.
["/api/imaging/avatar", "api"],
["/api/imaging/badge", "api"],
])(`classifies %s as %s`, (pathname, expected) => { ])(`classifies %s as %s`, (pathname, expected) => {
expect(classifyDdos(pathname)).toBe(expected); expect(classifyDdos(pathname)).toBe(expected);
}); });
+8
View File
@@ -1,5 +1,13 @@
export type DdosCategory = "pages" | "api" | "auth"; export type DdosCategory = "pages" | "api" | "auth";
/**
* Bucket a request path for rate limiting. Everything under `/api/` that is not
* an auth route counts as `api` — including `/api/imaging/*` (avatars, badges).
* That is intentional: imaging is a deliberate high-volume, cacheable surface,
* so it gets the wider api budget rather than the page budget. The bare
* `/imaging` renderer path never reaches this gate (it is excluded by the proxy
* matcher and, in production, fronted by the edge proxy directly).
*/
export function classifyDdos(pathname: string): DdosCategory { export function classifyDdos(pathname: string): DdosCategory {
if ( if (
pathname === "/api/auth" || pathname === "/api/auth" ||
+14 -16
View File
@@ -188,29 +188,27 @@ describe("Docker clone updates", () => {
expect(r.calls).not.toContain("docker volume prune"); expect(r.calls).not.toContain("docker volume prune");
expect(r.calls).not.toContain("docker system prune"); expect(r.calls).not.toContain("docker system prune");
}); });
it.each([ it.each(["dirty", "pull-failure", "build-failure", "migration-failure"])(
"dirty", "does not replace the container after %s",
"ci-active", (scenario) => {
"ci-green", const r = simulate(scenario);
"ci-upstream", expect(r.status, r.output).not.toBe(0);
"pull-failure", expect(r.calls).not.toContain("compose up");
"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 // Regressie: na een cutover naar het groene slot bestaat epicnext-cms-app
// niet meer en zag deze controle alleen een vrijgekomen blauwe container. De // niet meer en zag deze controle alleen een vrijgekomen blauwe container. De
// dagelijkse `docker-update.sh` startte toen een compose-replica op poort // dagelijkse `docker-update.sh` startte toen een compose-replica op poort
// 3002 en blokkeerde elke volgende release. De blauwe container alleen // 3002 en blokkeerde elke volgende release. De blauwe container alleen
// controleren is dus geen bewijs dat de host niet door CI beheerd wordt. // controleren is dus geen bewijs dat de host niet door CI beheerd wordt.
it.each(["ci-green", "ci-upstream"])( //
"refuses a compose deployment while CI owns the host (%s)", // A host owned by CI is a legitimate no-op, not an error: the cron run must
// exit 0 so it does not look like a failed deployment.
it.each(["ci-active", "ci-green", "ci-upstream"])(
"skips the compose deployment while CI owns the host (%s)",
(scenario) => { (scenario) => {
const r = simulate(scenario); const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0); expect(r.status, r.output).toBe(0);
expect(r.output).toContain("This host is managed by CI"); expect(r.output).toContain("This host is managed by CI");
expect(r.calls).not.toContain("compose up"); expect(r.calls).not.toContain("compose up");
}, },
+3 -2
View File
@@ -5,8 +5,9 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
/** /**
* Local furniture-bundle lookups. * Local furniture-bundle lookups.
* *
* Bundles are written as `<classname>.hab`, but a large body of pre-existing * New bundles are written with the configured primary extension
* assets is still `.nitro`, so every existence check has to consider both. These * (`FURNI_BUNDLE_EXT`, `.hab` by default), but a large body of pre-existing
* assets may be `.nitro`, so every existence check has to consider both. These
* helpers are the single place that knows that, which keeps "is this item's * helpers are the single place that knows that, which keeps "is this item's
* asset present?" from silently flipping to `false` for legacy files and the * asset present?" from silently flipping to `false` for legacy files and the
* nitro-cleanup scan from classifying them as fake leftovers. * nitro-cleanup scan from classifying them as fake leftovers.
+40 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
getBaseClassname, getBaseClassname,
getBundleFileName, getBundleFileName,
@@ -11,6 +11,7 @@ import {
getSwfFileName, getSwfFileName,
isValidClassname, isValidClassname,
normalizeClassname, normalizeClassname,
resolveLocalBundleExt,
SOURCE_BUNDLE_EXT, SOURCE_BUNDLE_EXT,
} from "./classname"; } from "./classname";
@@ -61,6 +62,44 @@ describe("normalizeClassname", () => {
}); });
}); });
describe("resolveLocalBundleExt", () => {
it("defaults to .hab", () => {
expect(resolveLocalBundleExt(undefined)).toBe(".hab");
expect(resolveLocalBundleExt("")).toBe(".hab");
});
it("accepts .nitro and the bare form", () => {
expect(resolveLocalBundleExt(".nitro")).toBe(".nitro");
expect(resolveLocalBundleExt("nitro")).toBe(".nitro");
expect(resolveLocalBundleExt("NITRO")).toBe(".nitro");
});
it("accepts .hab and the bare form", () => {
expect(resolveLocalBundleExt(".hab")).toBe(".hab");
expect(resolveLocalBundleExt("HAB")).toBe(".hab");
});
it("falls back to .hab for unknown values", () => {
expect(resolveLocalBundleExt("swf")).toBe(".hab");
});
});
describe("FURNI_BUNDLE_EXT wiring", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("prefers the configured extension and keeps the other as fallback", async () => {
vi.stubEnv("FURNI_BUNDLE_EXT", ".nitro");
vi.resetModules();
const mod = await import("./classname");
expect(mod.LOCAL_BUNDLE_EXT).toBe(".nitro");
expect(mod.getNitroFileName("sofa_blue")).toBe("sofa_blue.nitro");
expect(mod.getLocalBundleFileNames("sofa_blue")).toEqual([
"sofa_blue.nitro",
"sofa_blue.hab",
]);
});
});
describe("isValidClassname", () => { describe("isValidClassname", () => {
it("accepts lowercase letters, digits and allowed symbols", () => { it("accepts lowercase letters, digits and allowed symbols", () => {
expect(isValidClassname("chair_basic")).toBe(true); expect(isValidClassname("chair_basic")).toBe(true);
+44 -6
View File
@@ -17,23 +17,61 @@ export function getSwfFileName(classname: string): string {
return `${getBaseClassname(classname)}.swf`; return `${getBaseClassname(classname)}.swf`;
} }
/** The two extensions a furniture bundle may carry. */
export const BUNDLE_EXT_CHOICES = [".hab", ".nitro"] as const;
export type FurnitureBundleExt = (typeof BUNDLE_EXT_CHOICES)[number];
/**
* Read an env value via a computed key so Next does not inline it at build time
* — changing it should take effect on restart, not require a rebuild (same
* pattern as `runtime-asset-config.ts`).
*/
function readBundleExtEnv(): string | undefined {
const name = "FURNI_BUNDLE_EXT";
return process.env[name]?.trim() || undefined;
}
/**
* Resolve the local furniture-bundle extension this deployment's client asks
* for. Set `FURNI_BUNDLE_EXT` in `.env` to `hab`/`.hab` or `nitro`/`.nitro`;
* anything missing or unrecognised falls back to `.hab`.
*/
export function resolveLocalBundleExt(
raw: string | undefined = readBundleExtEnv(),
): FurnitureBundleExt {
const value = raw?.trim().toLowerCase();
if (!value) return ".hab";
const normalized = value.startsWith(".") ? value : `.${value}`;
return (BUNDLE_EXT_CHOICES as readonly string[]).includes(normalized)
? (normalized as FurnitureBundleExt)
: ".hab";
}
/** /**
* Extension locally stored furniture bundles are written with. This deployment's * Extension locally stored furniture bundles are written with. This deployment's
* Nitro client requests `<classname>.hab`, so that is what new imports produce. * Nitro client requests `<classname>.hab` by default, but a stock Nitro client
* asks for `.nitro` — choose with `FURNI_BUNDLE_EXT`. New imports and repairs
* produce this extension.
*/ */
export const LOCAL_BUNDLE_EXT = ".hab"; export const LOCAL_BUNDLE_EXT: FurnitureBundleExt = resolveLocalBundleExt();
/** /**
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every * Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
* configured clone source) keeps shipping `.nitro`, so download URLs are built * configured clone source) keeps shipping `.nitro`, so download URLs are built
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk. * with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
*/ */
export const SOURCE_BUNDLE_EXT = ".nitro"; export const SOURCE_BUNDLE_EXT = ".nitro" as const;
/** Both extensions a local bundle may carry, newest convention first. */ /**
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const; * Every extension a local bundle may be stored under, the configured one first
* so it always wins over a same-stem legacy file.
*/
export const LOCAL_BUNDLE_EXTS: readonly FurnitureBundleExt[] = [
LOCAL_BUNDLE_EXT,
...BUNDLE_EXT_CHOICES.filter((ext) => ext !== LOCAL_BUNDLE_EXT),
];
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */ /** Primary local bundle name — the configured extension wins over the legacy one. */
export function getNitroFileName(classname: string): string { export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`; return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
} }
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { ifNoneMatchSatisfied, strongEtag } from "./imager-http";
describe("strongEtag", () => {
it("is deterministic and quote-wrapped", () => {
const body = new Uint8Array([1, 2, 3]);
expect(strongEtag(body)).toBe(strongEtag(new Uint8Array([1, 2, 3])));
expect(strongEtag(body)).toMatch(/^"[A-Za-z0-9_-]+"$/);
});
it("changes when the body changes", () => {
expect(strongEtag(new Uint8Array([1, 2, 3]))).not.toBe(
strongEtag(new Uint8Array([1, 2, 4])),
);
});
});
describe("ifNoneMatchSatisfied", () => {
const etag = strongEtag(new Uint8Array([9]));
it("returns false without a header", () => {
expect(ifNoneMatchSatisfied(null, etag)).toBe(false);
expect(ifNoneMatchSatisfied("", etag)).toBe(false);
});
it("matches an exact, weak or wildcard validator", () => {
expect(ifNoneMatchSatisfied(etag, etag)).toBe(true);
expect(ifNoneMatchSatisfied(`W/${etag}`, etag)).toBe(true);
expect(ifNoneMatchSatisfied("*", etag)).toBe(true);
});
it("matches any entry in a comma-separated list", () => {
expect(ifNoneMatchSatisfied(`"other", ${etag}`, etag)).toBe(true);
});
it("does not match a different validator", () => {
expect(ifNoneMatchSatisfied('"other"', etag)).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
import "server-only";
import { createHash } from "node:crypto";
/**
* Strong HTTP validator for an imaging response body.
*
* Derived from the bytes rather than the request URL on purpose: the same URL
* can legitimately return different bytes over its lifetime — a degraded
* fallback render before the configured imager recovers, or a different
* transcode variant — and a URL-based validator would let a browser keep the
* worse image via a stale `304`. Hashing the body means the ETag changes
* exactly when the bytes do.
*/
export function strongEtag(body: Uint8Array): string {
return `"${createHash("sha1").update(body).digest("base64url")}"`;
}
/**
* Whether an `If-None-Match` request header matches `etag`. Handles the
* comma-separated list form, weak validators (`W/"…"`) and the `*` wildcard, so
* a revalidating browser gets a `304` whenever its cached copy still matches.
*/
export function ifNoneMatchSatisfied(
header: string | null,
etag: string,
): boolean {
if (!header) return false;
return header.split(",").some((raw) => {
const tag = raw.trim();
return tag === "*" || tag === etag || tag === `W/${etag}`;
});
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { isAnimatedPng } from "./imager-png";
function png(chunks: Array<{ type: string; data?: number[] }>): Uint8Array {
const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (const chunk of chunks) {
const data = chunk.data ?? [];
const len = data.length;
bytes.push(
(len >>> 24) & 0xff,
(len >>> 16) & 0xff,
(len >>> 8) & 0xff,
len & 0xff,
...chunk.type.split("").map((c) => c.charCodeAt(0)),
...data,
0,
0,
0,
0,
);
}
return new Uint8Array(bytes);
}
describe("isAnimatedPng", () => {
it("returns false for non-PNG input", () => {
expect(isAnimatedPng(new Uint8Array([1, 2, 3]))).toBe(false);
expect(isAnimatedPng(new Uint8Array())).toBe(false);
});
it("returns false for a plain PNG", () => {
const plain = png([{ type: "IHDR" }, { type: "IDAT" }, { type: "IEND" }]);
expect(isAnimatedPng(plain)).toBe(false);
});
it("returns true when an acTL chunk precedes IDAT", () => {
const anim = png([
{ type: "IHDR" },
{ type: "acTL", data: [0, 16, 0, 0] },
{ type: "IDAT" },
{ type: "IEND" },
]);
expect(isAnimatedPng(anim)).toBe(true);
});
it("ignores an acTL chunk that appears after IDAT", () => {
const late = png([
{ type: "IHDR" },
{ type: "IDAT" },
{ type: "acTL", data: [0, 16, 0, 0] },
]);
expect(isAnimatedPng(late)).toBe(false);
});
it("does not loop forever on a corrupt chunk length", () => {
const corrupt = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xff, 0xff, 0xff,
0x49, 0x48, 0x44, 0x52,
]);
expect(isAnimatedPng(corrupt)).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import "server-only";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/**
* Whether a buffer is an animated PNG (APNG).
*
* APNG is a PNG carrying an `acTL` control chunk before `IDAT`; a plain PNG has
* none. `sharp`/libvips in this build cannot decode APNG *animation* — it reads
* only the first frame and re-encodes that — so a WebP/AVIF transcode of an
* APNG silently turns a moving avatar into a still one. Callers that care about
* quality use this to leave animated renders untouched and only re-encode the
* static ones. Chunk walking stops at `IDAT`/`IEND` because the spec requires
* `acTL` to precede them.
*/
export function isAnimatedPng(bytes: Uint8Array): boolean {
if (bytes.length < 8) return false;
for (let i = 0; i < 8; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) return false;
}
let offset = 8;
while (offset + 8 <= bytes.length) {
const length =
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0;
const type = String.fromCharCode(
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
);
if (type === "acTL") return true;
if (type === "IDAT" || type === "IEND") return false;
// A corrupt length would loop forever; bail instead.
if (length > bytes.length) return false;
offset += 12 + length;
}
return false;
}
+21 -1
View File
@@ -15,7 +15,11 @@ vi.mock("@/lib/runtime-asset-config", () => ({
resolveImagerBase: (origin: string) => `${origin}/imager`, resolveImagerBase: (origin: string) => `${origin}/imager`,
})); }));
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream"; import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "./imager-upstream";
const params = new URLSearchParams({ figure: "hr-1", direction: "2" }); const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
@@ -136,3 +140,19 @@ it("does not queue a new caller behind a render that is already too old", async
expect([...shared[0].body]).toEqual([9]); expect([...shared[0].body]).toEqual([9]);
expect(shared[1].body).toBe(shared[0].body); expect(shared[1].body).toBe(shared[0].body);
}); });
it("accepts figures the renderer understands even with a trailing segment", () => {
// The client appends extra segments the imager still draws (e.g. the
// trailing `.0` here). Rejecting them turned valid avatars into 400s.
const figure =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
const parsed = parseAvatarQuery(new URLSearchParams({ figure }));
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(parsed.params.get("figure")).toBe(figure);
});
it("still rejects a figure that is not a figure", () => {
for (const figure of ["../bad", "hd-180-1/../x", "", "0"]) {
expect(parseAvatarQuery(new URLSearchParams({ figure })).ok).toBe(false);
}
});
+6 -1
View File
@@ -8,7 +8,12 @@ import {
} from "@/lib/imager-cache"; } from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config"; import { resolveImagerBase } from "@/lib/runtime-asset-config";
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; // The client's figure strings are passed straight through to the imager, which
// is the authority on what it can draw. Only the first segment is required to
// look like a real part (`hd-180`); the rest is deliberately loose because some
// clients append extra segments (e.g. a trailing `.0`) that the renderer still
// accepts. Over-strict validation here turned valid avatars into 400s.
export const FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const FIGURE_MAX_LEN = 512; export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24; export const FIGURE_MAX_PARTS = 24;
+171
View File
@@ -0,0 +1,171 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/imaging/route";
// sharp is the one CPU-bound operation on this default-imager route, so the
// tests instrument it directly to prove transcodes are cached rather than
// re-run on every request.
const { sharpMock, counters } = vi.hoisted(() => {
const counters = {
calls: 0,
options: [] as unknown[],
behavior: "ok" as "ok" | "throw",
};
const sharpMock = (_input: unknown, options?: unknown) => {
counters.calls += 1;
counters.options.push(options);
if (counters.behavior === "throw") throw new Error("decode failed");
return {
webp: () => ({ toBuffer: async () => Buffer.from([1, 2, 3]) }),
avif: () => ({ toBuffer: async () => Buffer.from([4, 5, 6]) }),
};
};
return { sharpMock, counters };
});
vi.mock("sharp", () => ({ default: sharpMock }));
// One scratch root for the whole file, removed afterwards, so the disk cache
// never leaks state between tests or into the runtime cache tree.
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-imaging-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test: a warm variant would short-circuit the
// transcode being asserted.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
vi.stubEnv("IMAGER_URL", "https://imager.test/avatarimage");
counters.calls = 0;
counters.options = [];
counters.behavior = "ok";
// Drop any single-flight entries a prior test may have left behind.
globalThis.imagingTranscodes?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function stubPngUpstream() {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([9, 8, 7]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
// A minimal APNG: the PNG signature followed by an `acTL` chunk, which is all
// the animated-format detector needs. sharp cannot re-encode APNG animation in
// this build, so these must be passed through untouched.
function apngBytes(): Uint8Array {
return new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0x61, 0x63,
0x54, 0x4c, 0, 0, 0, 0,
]);
}
it("re-encodes an avatar to WebP and caches the variant across requests", async () => {
const fetcher = stubPngUpstream();
const request = new NextRequest(
"https://hotel.test/imaging?figure=hd-180-1&format=webp",
);
const first = await GET(request);
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toBe("image/webp");
expect(counters.calls).toBe(1);
const fetchesAfterFirst = fetcher.mock.calls.length;
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("content-type")).toBe("image/webp");
// Served entirely from disk cache: neither the upstream nor sharp re-ran.
expect(counters.calls).toBe(1);
expect(fetcher.mock.calls.length).toBe(fetchesAfterFirst);
});
it("decodes with animation awareness for transcodeable inputs", async () => {
stubPngUpstream();
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
expect(counters.options.at(-1)).toMatchObject({ animated: true });
});
it("leaves an animated PNG untouched instead of dropping its frames", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(apngBytes(), {
headers: { "content-type": "image/png" },
}),
),
);
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
// Original bytes/content-type preserved and sharp never ran.
expect(result.headers.get("content-type")).toBe("image/png");
expect(counters.calls).toBe(0);
});
it("shares a single transcode across concurrent cold-cache requests", async () => {
stubPngUpstream();
const request = () =>
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=avif");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(counters.calls).toBe(1);
});
it("falls back to the original render when the transcode fails", async () => {
stubPngUpstream();
counters.behavior = "throw";
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/png");
});
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
stubPngUpstream();
const url = "https://hotel.test/imaging?figure=hd-55-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves the graceful silhouette instead of a broken image when every upstream is down", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("down", { status: 503 })),
);
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=xd-900-1&format=webp"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
expect(result.headers.get("cache-control")).toContain("no-cache");
});
+22
View File
@@ -108,6 +108,28 @@ it("serves a previously rendered avatar from the disk cache without re-fetching"
expect(fetcher.mock.calls.length).toBe(callsAfterFirst); expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
}); });
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
vi.stubEnv("IMAGER_URL", "https://etag.test/avatarimage");
const url = "https://hotel.test/api/imaging/avatar?figure=hd-77-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
expect(second.headers.get("x-imager-source")).toBe("cache");
});
it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => { it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
const fetcher = vi.fn( const fetcher = vi.fn(
async (_input: string | URL) => async (_input: string | URL) =>
+412
View File
@@ -2570,6 +2570,8 @@
"credits": "Credits", "credits": "Credits",
"points": "Points", "points": "Points",
"pointsType": "Type", "pointsType": "Type",
"pointsTypeDuckets": "Duckets",
"pointsTypeDiamonds": "Diamonds",
"newPriceHint": "Applies to new offers only; moved offers keep their current price.", "newPriceHint": "Applies to new offers only; moved offers keep their current price.",
"parent": "Place pages under", "parent": "Place pages under",
"root": "Catalog root", "root": "Catalog root",
@@ -6801,6 +6803,416 @@
}, },
"missing": "Missing: {parts}", "missing": "Missing: {parts}",
"unknown": "Not verified: {parts}" "unknown": "Not verified: {parts}"
},
"rail": {
"title": "Categories",
"all": "All categories",
"noMatches": "No matching categories.",
"refreshFailed": "Catalog could not refresh.",
"retry": "Retry",
"updating": "Updating catalog…"
},
"nav": {
"ariaLabel": "Catalog Studio",
"furniture": "Furniture",
"badges": "Badges",
"clothing": "Clothing",
"effects": "Effects",
"pets": "Pets",
"uploadFiles": "Upload files",
"importFromSource": "Import from a source",
"syncRepository": "Sync & repository",
"catalogAudit": "Catalog audit",
"repairIcons": "Repair icons",
"catalogMaintenance": "Catalog maintenance",
"nitroCleanup": "Nitro cleanup",
"catalogManager": "Catalog manager",
"importSync": "Import & sync",
"maintenance": "Maintenance"
},
"aria": {
"search": "Search furniture",
"searchPlaceholder": "Search furni, classnames, keywords… (/)",
"clearSearch": "Clear search",
"hideCatalogTree": "Hide catalog tree",
"showCatalogTree": "Show catalog tree",
"source": "Furniture source",
"type": "Furniture type",
"category": "Furniture category",
"status": "Import status",
"sort": "Sort furniture",
"importSpeed": "Import speed",
"translateImport": "Translate this import",
"defaultTranslation": "Default translation",
"selection": "Furniture selection",
"results": "Furniture results",
"gridView": "Grid view",
"listView": "List view",
"selectAll": "Select all",
"languageLabel": "Language {lang}",
"selectItem": "Select {name}",
"deselectItem": "Deselect {name}",
"viewItem": "View {name}",
"importItem": "Import {name}",
"editNitro": "Edit nitro {name}",
"regenerateNitro": "Regenerate nitro {name}",
"scrollToTop": "Scroll to top",
"closeCategoryPanel": "Close category panel",
"catalogCategories": "Catalog categories",
"closeCategories": "Close categories",
"findCategory": "Find a category",
"expandCategory": "Expand {name}",
"collapseCategory": "Collapse {name}",
"closeDetails": "Close furniture details"
},
"workspace": {
"title": "Studio",
"furnidata": "furnidata",
"catalog": "Catalog",
"results": "{count} results",
"statInDb": "{count} in DB",
"statInCatalog": "{count} in catalog",
"statMissingNitro": "{count} missing nitro",
"statTotal": "{count} total",
"statPresent": "{count} present",
"statToImport": "{count} to import",
"official": "(official)"
},
"types": {
"floor": "Floor",
"wall": "Wall"
},
"filters": {
"allTypes": "All types",
"floorItems": "Floor items",
"wallItems": "Wall items",
"allCategories": "All categories",
"allStatuses": "All statuses",
"allStatusesCount": "All statuses ({count} available to import)",
"statuses": {
"notImported": "Not imported",
"imported": "Imported",
"inCatalog": "In catalog",
"missingNitro": "Missing nitro",
"missingFurnidata": "Missing furnidata",
"missingCatalog": "Missing catalog entry"
},
"sorts": {
"default": "Default order",
"name": "Name A–Z",
"classname": "Classname A–Z",
"category": "Category",
"importedFirst": "Imported first",
"notImportedFirst": "Not imported first"
},
"reset": "Reset filters",
"noNitro": "{count} no nitro",
"noFurnidata": "{count} no furnidata"
},
"importOptions": {
"summary": "Import options",
"translationOn": "Translation on",
"translationOff": "Translation off",
"speed": "Speed",
"parallel": "{count} parallel",
"gentle": "1 (gentle)",
"recommended": "4 (recommended)",
"turbo": "12 (turbo)",
"translate": "Translate",
"noLanguages": "No languages",
"allLanguages": "All languages",
"languagesCount": "{count} languages",
"languages": "Languages",
"all": "All",
"none": "None",
"translateAllTitle": "Translate the entire catalog into all languages",
"translateAll": "Translate entire catalog",
"defaultFuture": "Default for future imports"
},
"selection": {
"selectVisible": "Select visible",
"deselectVisible": "Deselect visible",
"loadingAll": "Loading all…",
"selectAllResults": "Select all {count} results",
"selectedInView": "{count} selected in this view",
"clear": "Clear",
"preparingReview": "Preparing review…",
"importAllMissing": "Import all missing ({count})",
"importSelected": "Import selected ({count})",
"rebuildNitro": "Rebuild Nitro ({count})",
"addMissingFurnidata": "Add missing furnidata ({count})",
"deleteSelected": "Delete ({count})",
"hiddenWarning": "Some selected furniture is hidden by your filters. Actions apply to the visible selection."
},
"items": {
"tryAgain": "Try again",
"noFound": "No furni found for “{query}”",
"noFurni": "No furni to show yet",
"noFoundHint": "Try a different search or clear your filters.",
"noFurniHint": "Search a furni library to get started.",
"noNitro": "No nitro",
"noFurnidata": "No furnidata",
"noCatalog": "No catalog",
"imported": "Imported",
"new": "New",
"colName": "Name",
"colClassname": "Classname",
"colType": "Type",
"colCategory": "Category",
"colStatus": "Status",
"colActions": "Actions",
"loadingMore": "Loading more…",
"scrollForMore": "Scroll for more",
"endOfResults": "End of results",
"defaultLanguages": "Default languages",
"off": "Off"
},
"deleteDialog": {
"title": "Delete imported furni",
"body": "This permanently removes {name} from items_base and the catalog, plus its asset files.",
"cancel": "Cancel",
"delete": "Delete"
},
"drawer": {
"title": "Furni detail",
"revision": "rev {revision}",
"autoImportReady": "Auto-import ready",
"automaticPlacement": "Automatic placement",
"category": "Category",
"price": "Price",
"priceCredits": "{count} credits",
"priceCreditsPoints": "{credits} credits + {points} points",
"importAdd": "Import & add to catalog",
"bundleMissing": "This furni is in the DB but its bundle file is missing.",
"regenerate": "Regenerate .nitro",
"editNitro": "Edit nitro",
"deleteImported": "Delete imported furni"
},
"review": {
"titleReview": "Review furniture import",
"titleConfirm": "Confirm furniture import",
"steps": "1. Select → 2. Review → 3. Import",
"source": "Source:",
"destination": "Destination:",
"destinationValue": "local catalog, furnidata, bundle and icons",
"translation": "Translation:",
"selection": "Selection:",
"selectionCount": "{count} furniture items",
"checkingSource": "Checking source files… {count} checked",
"retrySource": "Retry source check",
"unknownSources": "Some sources could not be verified. The import will still try to download their files.",
"sourceNotFound": "Source files not found by preliminary check: {name}",
"checkingLocal": "Checking local data and assets…",
"retryInspection": "Retry inspection",
"incomplete": "Inspection is incomplete. Close and retry before importing.",
"summary": "{ready} ready to import · {existing} already in SQL (complete missing data) · {blocked} conflicts · {warnings} source file warnings",
"localUnverified": "Some local files could not be verified. Review the item details before continuing.",
"statusNew": "New",
"statusChanged": "Differences",
"statusConflict": "Conflict",
"statusUnknown": "Not fully verified",
"statusPresent": "Present",
"validatingFile": "Validating file…",
"confirmIntro": "Only the {count} 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.",
"confirmEstimates": "Automatic categories and prices below are estimates. Source rules may override them; final results appear in the import summary.",
"placement": "{category} · {credits} credits + {points} points",
"previous": "Previous items",
"page": "Page {page} / {total}",
"next": "Next items",
"backToReview": "Back to review",
"cancel": "Cancel",
"continue": "Continue to confirmation",
"startImport": "Start import ({count})",
"uploadFailed": "Upload failed"
},
"inspector": {
"furnidataUnreadable": "Local furnidata could not be read. Its values are not verified.",
"field": "Field",
"localNow": "Local now",
"source": "Source",
"fieldName": "Name",
"fieldDescription": "Description",
"fieldRevision": "Revision",
"fieldType": "Type",
"changed": "Changed",
"notSupplied": "Not supplied",
"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.",
"sqlRecords": "SQL records and catalog offers",
"noSqlItem": "No local SQL item.",
"itemRow": "Item #{id} · Sprite #{sprite} · {name}",
"offerRow": "Offer #{id} · Page #{page} · {credits} credits + {points} points",
"healthComplete": "Complete",
"healthIncomplete": "Needs repair",
"healthNew": "Not imported",
"healthConflict": "Local conflict",
"healthUnknown": "Not verified",
"missing": "Missing: {parts}",
"conflictNote": "Duplicate records or mismatched IDs/types require review before repair.",
"unknownNote": "Some local data could not be checked. Retry verification before repair.",
"repairNote": "Repair adds missing parts and preserves existing IDs, settings, furnidata and catalog offers. Missing offers use the automatic category and price shown below.",
"repairQueued": "Repair queued or running…",
"repair": "Repair furni",
"recheck": "Recheck local state",
"checking": "Checking local data and assets…",
"retry": "Retry inspection",
"unavailable": "Local inspection unavailable."
},
"quality": {
"compressionTitle": "Compression · {format}",
"losslessVp8l": "Lossless (VP8L)",
"lossyVp8": "Lossy (VP8)",
"bundle": "Bundle",
"texture": "Texture",
"metadata": "Metadata",
"container": "Container",
"textureDetail": "Texture {raw} decoded · deflate ×{ratio} · sheet {width}×{height} · {frames} frames",
"legacyPngSaving": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong>, saving <strong>{saving}</strong> ({percent}% smaller)",
"legacyPngNoGain": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong> (no gain on this bundle)",
"webpSaving": "WebP Lossless stores {raw} against {png} for the same artwork as PNG, saving <strong>{saving}</strong> ({percent}% smaller)",
"webpNoGain": "WebP Lossless stores {raw} against {png} for the same artwork as PNG (PNG is not larger on this bundle)",
"pixelExact": "<strong>Pixel-exact</strong> — every visible texel round-trips unchanged.",
"invisibleDiffs": "{count} fully transparent texels carry no RGB, which VP8L discards and no renderer can see.",
"texelsDiffer": "{count} {count, plural, one {texel} other {texels}} differ after a lossless round-trip — the furniture may not render identically.",
"scaleTitle": "Scale {size} · {state}",
"stateMissing": "Not included",
"stateIssues": "Needs review",
"stateDerived": "Derived from 64",
"statePresent": "Present",
"spritesLine": "{sprites} sprites · {animations} animation definitions · directions {directions}",
"missingScale": "This bundle contains no sprites at this scale. Missing scale 32 does not by itself mean the furniture is unusable.",
"sprite": "Sprite",
"renderFailed": "This sprite could not be rendered.",
"intro": "Inspect the saved bundle at both scales. Previews show individual sprites, not the assembled furniture or animation playback.",
"recheck": "Recheck",
"checking": "Checking scales and texture references…",
"inspectionFailed": "Inspection failed"
},
"sync": {
"pageSubtitle": "Keep the catalog in sync with your sources and export it to the repository.",
"cloneSourcesTitle": "Clone sources",
"cloneSourcesBody": "Scan all configured clone sources and automatically import any furniture that is not yet present in your hotel.",
"officialFurnidataTitle": "Official furnidata",
"officialFurnidataBody": "Scan the official Habbo furnidata and automatically import any furniture that your hotel doesn't have yet.",
"gitTitle": "Gitea connection settings",
"gitIntro": "Connect your self-hosted Gitea repository over HTTPS. The CMS manages its own checkout; no Git-specific .env settings are needed.",
"providerLabel": "Provider:",
"providerValue": "Gitea self-hosted",
"repositoryUrl": "Repository URL",
"branch": "Branch",
"username": "Gitea username",
"accessToken": "Gitea access token",
"tokenSaved": "Token saved — leave blank to keep it",
"tokenEnter": "Enter a token with repository write access",
"tokenNote": "The token is stored encrypted and is never returned to the browser. Changing repository or username requires entering the token again.",
"autoExport": "Automatically export catalog changes",
"testConnection": "Test connection",
"working": "Working…",
"saveSettings": "Save Gitea settings",
"loadFailed": "Unable to read Git settings. Settings edit permission is required.",
"requestFailed": "Git configuration request failed",
"saved": "Gitea configuration saved."
},
"scale32": {
"title": "Generate scale 32 from 64",
"intro": "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.",
"saveFirst": "Save or reset your pending edits first.",
"working": "Working…",
"preview": "Preview generation",
"previewCount": "{count} sprite entries will be added. Check positioning and animations in the client after saving.",
"skipped": "{count} sprite(s) were skipped because the source bundle has no usable artwork for them.",
"original64": "Original 64",
"derived32": "Derived 32",
"saveWithBackup": "Save with backup",
"discard": "Discard preview",
"saved": "Saved. A copy of the original bundle is preserved.",
"restore": "Restore original bundle",
"generationFailed": "Generation failed"
},
"pages": {
"auditSubtitle": "Scan your entire furniture catalog for missing entries, broken references, missing files, and items available from clone sources.",
"badgesSubtitle": "Search hotels and import badge designs into your hotel.",
"cloneSubtitle": "Clone furniture from your configured external sources.",
"clothingSubtitle": "Browse and import wearable clothing assets for your hotel.",
"effectsSubtitle": "Import effect definitions and their sprite assets.",
"maintenanceSubtitle": "Detect and repair catalog integrity problems, and run legacy maintenance tasks.",
"nitroCleanupSubtitle": "Scan the nitro directories for fake bundles (no matching furniture) and broken bundles (unparseable) and remove them.",
"petsSubtitle": "Import pet types, races and their sprite assets.",
"repairIconsTitle": "Repair missing icons",
"repairIconsSubtitle": "Scan all existing furniture and download any missing icon files. Icons are extracted from local .nitro bundles or fetched from configured clone sources.",
"uploadTitle": "Upload .hab furniture",
"uploadSubtitle": "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."
},
"confirm": {
"deleteTitle": "Delete {count} items?",
"deleteDescription": "All {count} selected imported items will be permanently removed from the database and the filesystem.",
"deleteConfirm": "Delete"
},
"batch": {
"ariaProgress": "Import progress",
"ariaProcessed": "Furniture processed",
"headingStopping": "Stopping import…",
"headingImporting": "Importing furniture…",
"headingStopped": "Import stopped",
"headingCompletedErrors": "Import completed with errors",
"headingCompleted": "Import completed",
"imported": "imported",
"failed": "failed",
"notCompleted": "not completed",
"remaining": "remaining",
"retryFailed": "Retry failed ({count})",
"closeSummary": "Close summary",
"stopImport": "Stop import",
"verificationIssues": "{count, plural, one {# catalog data issue still needs attention. Open Catalog audit to review it.} other {# catalog data issues still need attention. Open Catalog audit to review them.}}",
"verificationDone": "Catalog data verification completed.",
"chipFurnidataIds": "furnidata ids fixed",
"chipOfferIds": "offer ids reconciled",
"chipOwnerships": "ownerships fixed",
"chipNitros": "nitros synced",
"chipIcons": "icons synced",
"viewItemDetails": "View item details ({count})",
"showingItems": "Showing {shown} of {total} items",
"status": {
"pending": "Waiting",
"started": "Starting",
"downloading": "Downloading assets",
"converting": "Preparing assets",
"writingDb": "Saving",
"done": "Imported",
"failed": "Failed"
},
"statusNotCompleted": "Not completed"
},
"toasts": {
"allImported": "All items from this source are already imported",
"selectNotImported": "Select furni that isn't imported yet",
"loadMissingFailed": "Could not load missing furniture",
"prepareFailed": "Could not prepare the full selection",
"sourceChanged": "The source changed while preparing the selection. Refresh and retry.",
"batchFailed": "Batch import failed ({status})",
"importCancelled": "Import cancelled",
"importTimeout": "Import timed out. Progress was saved — open Import History to resume failed items.",
"networkUpload": "Network error during upload. Progress was saved — open Import History to resume failed items.",
"unexpectedImport": "Unexpected import error: {message}",
"retryFailed": "Retry failed ({status})",
"importedAgain": "{count} items imported again",
"retryConnection": "Connection failed while retrying",
"deleteFailed": "Delete failed",
"networkError": "Network error",
"deleted": "Deleted {name} ({count} files)",
"regenerated": "Nitro file regenerated for {name}",
"regenerationFailed": "Regeneration failed",
"bulkDeleted": "Deleted: {count}",
"bulkDeletedFailed": "Deleted: {deleted}, failed: {failed}",
"bulkDeleteFailed": "Bulk delete failed",
"regeneratedBulk": "Nitro regenerated: {count}",
"regeneratedBulkFailed": "Nitro regenerated: {succeeded}, failed: {failed}",
"furnidataAdded": "Furnidata added: {count}",
"furnidataAddedFailed": "Furnidata added: {fixed}, failed: {failed}",
"addFurnidataFailed": "Adding furnidata failed",
"languagesTranslated": "Languages translated ({ok}/{total} completed)",
"translationFailed": "Translation failed",
"networkTranslating": "Network error while translating languages",
"saveDefaultFailed": "Could not save the default translation setting"
} }
} }
}, },
+6
View File
@@ -103,6 +103,12 @@ export const proxy = async (req: import("next/server").NextRequest) => {
}; };
export const config = { export const config = {
// `/imaging` and `/images` are excluded: they are raw image responses that
// carry their own long-lived Cache-Control and must not be rewritten by the
// HTML/asset cache logic. In production the edge proxy also fronts
// `/imaging` straight to the renderer, so the CMS route never sees that
// traffic; `/api/imaging/*` (avatars, badges) is NOT excluded and therefore
// still passes through the anti-DDoS gate and keeps its asset headers.
matcher: [ matcher: [
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)", "/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
], ],
-3
View File
@@ -1,3 +0,0 @@
test-ci-runner
# final test
# CI test via Gitea runner
-1
View File
@@ -1 +0,0 @@
# clean v5 docker install test
-1
View File
@@ -1 +0,0 @@
# CI test na complete herinstallatie
-1
View File
@@ -1 +0,0 @@
# test nieuwe label aanpak
-1
View File
@@ -1 +0,0 @@
# test ubuntu-latest alleen
-4
View File
@@ -1,4 +0,0 @@
// test: runner actief
// second test
// third test - token sync fix
// fourth test - token config fix
-1
View File
@@ -1 +0,0 @@
# test met ubuntu-latest label
-1
View File
@@ -1 +0,0 @@
# test na volledige schone installatie v5