From 3c8abb6fbf7637b54b5ecd98c34d7a36e364bba2 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 14 Oct 2026 21:46:56 +0200 Subject: [PATCH] feat(assets): choose furniture bundle ext via FURNI_BUNDLE_EXT 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. --- .env.example | 7 +++ .gitignore | 2 + README.md | 2 +- e2e/imaging/performance-budget.spec.ts | 39 ++++++++++++++ scripts/cf-setup-cache.sh | 16 ++++-- scripts/ci-deploy.sh | 22 +++++++- scripts/docker-update.sh | 7 ++- scripts/imager-health-alert.sh | 72 ++++++++++++++++++++++++++ src/app/api/imaging/avatar/route.ts | 10 ++-- src/app/imaging/route.ts | 14 +++++ src/env.ts | 3 ++ src/lib/ddos.test.ts | 4 ++ src/lib/ddos.ts | 8 +++ src/lib/furni/bundle-file.ts | 5 +- src/lib/furni/classname.test.ts | 41 ++++++++++++++- src/lib/furni/classname.ts | 50 +++++++++++++++--- src/proxy.ts | 6 +++ 17 files changed, 288 insertions(+), 20 deletions(-) create mode 100644 e2e/imaging/performance-budget.spec.ts create mode 100755 scripts/imager-health-alert.sh diff --git a/.env.example b/.env.example index 10c9b98e..a57337e1 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,13 @@ DB_BACKUP_KEEP=7 # Minutes between repeat health-fail alerts from jobs-worker (default 15). HEALTH_ALERT_COOLDOWN_MIN=15 +# --- FURNITURE ASSETS --- +# Extension the local Nitro client requests for furniture bundles. The CMS +# writes new/imported/repaired bundles with this extension and prefers it when +# resolving existing ones (the other stays readable as a legacy fallback). +# ".hab" (default) or ".nitro". +FURNI_BUNDLE_EXT=.hab + # --- RCON (Low Latency Loop) --- RCON_HOST=127.0.0.1 RCON_PORT=3003 diff --git a/.gitignore b/.gitignore index 412be796..f81169dd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ public/cache/ prod.log *.log +# Imager health-alert state (last-known up/down + timestamp) +logs/imager-health.state # Database backups db_backup_*.sql diff --git a/README.md b/README.md index 505266ff..c7432705 100644 --- a/README.md +++ b/README.md @@ -594,7 +594,7 @@ URLs look like `https:///imaging/avatarimage?figure=hd-180-1.ch-210-66&im ### Requirements (host) - Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables). -- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab`, 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` diff --git a/e2e/imaging/performance-budget.spec.ts b/e2e/imaging/performance-budget.spec.ts new file mode 100644 index 00000000..2d8b8c95 --- /dev/null +++ b/e2e/imaging/performance-budget.spec.ts @@ -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); +}); diff --git a/scripts/cf-setup-cache.sh b/scripts/cf-setup-cache.sh index edd8f8b2..47acd724 100755 --- a/scripts/cf-setup-cache.sh +++ b/scripts/cf-setup-cache.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # 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 -# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`). +# allowlist, the client-facing gamedata tree and the avatar/badge imaging +# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public` +# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage +# stuurt). # # Why a rule is required: Cloudflare only caches a handful of file extensions # 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" BASE="https://api.cloudflare.com/client/v4" 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). # Geen regex: `matches` vereist Business; vrije operators zijn `in` en # `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() { local name="$1" diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index 091d36a5..1dd095a5 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -410,7 +410,10 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then fi 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" # 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" PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e + # 4b. Cross-browser imaging gate. The chromium suite above proves the + # candidate serves the right bytes; this proves every bundled engine can + # actually decode the render. The webkit path is the one that historically + # broke (CSP upgrade-insecure-requests + animated PNG), and a regression + # there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get + # a release out in an emergency. + if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then + echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2 + else + PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging + fi + # 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat. cutover_started=1 switch_upstream "$new_port" @@ -569,6 +584,11 @@ else healthy 3002 node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha" PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e + if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then + echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2 + else + PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging + fi verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)" fi docker tag "$verified_image" "epicnext-cms:verified-$sha" diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh index 4bf9d545..28ab3d34 100755 --- a/scripts/docker-update.sh +++ b/scripts/docker-update.sh @@ -11,6 +11,9 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}" mkdir -p "$(dirname "$LOG_FILE")" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; } die() { log "ERROR: $*"; exit 1; } +# A run that legitimately has nothing to do (the host is owned by CI) must not +# look like a failure to cron. `die` stays reserved for real errors. +skip() { log "SKIP: $*"; exit 0; } migration_image="" remote_migration_image="" previous_image="" @@ -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. 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 - 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 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 - 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 done [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first." diff --git a/scripts/imager-health-alert.sh b/scripts/imager-health-alert.sh new file mode 100755 index 00000000..f8a16f2d --- /dev/null +++ b/scripts/imager-health-alert.sh @@ -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 diff --git a/src/app/api/imaging/avatar/route.ts b/src/app/api/imaging/avatar/route.ts index 46c5700d..6cd21db5 100644 --- a/src/app/api/imaging/avatar/route.ts +++ b/src/app/api/imaging/avatar/route.ts @@ -21,12 +21,16 @@ function cacheControlFor(source: string): string { switch (source) { case "fallback": case "cache-stale": - // Reuse the render briefly but revalidate the upstream soon. - return "public, max-age=300"; + // Reuse the render briefly but revalidate the upstream soon. The + // shared `s-maxage` lets the edge (Cloudflare) hold it for an hour + // without pinning a degraded fallback in the browser. + return "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"; case "unavailable": return "no-cache, max-age=0"; 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"; } } diff --git a/src/app/imaging/route.ts b/src/app/imaging/route.ts index acb0b547..26cc84dd 100644 --- a/src/app/imaging/route.ts +++ b/src/app/imaging/route.ts @@ -16,6 +16,20 @@ import { } 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 TranscodeFormat = Exclude; diff --git a/src/env.ts b/src/env.ts index a45d0e79..546171d5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -75,6 +75,9 @@ const schema = z // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // when unset. BADGE_UPLOAD_DIR: z.string().optional(), + // Extension local furniture bundles are written with and the client + // requests: ".hab" (default) or ".nitro". Accepts the bare form too. + FURNI_BUNDLE_EXT: z.string().optional(), // Emulator JAR backup job (jobs-worker, host-side); no-op unless both set. EMULATOR_JAR_PATH: z.string().optional(), EMULATOR_BACKUP_DIR: z.string().optional(), diff --git a/src/lib/ddos.test.ts b/src/lib/ddos.test.ts index 1cbc9ee7..77249d29 100644 --- a/src/lib/ddos.test.ts +++ b/src/lib/ddos.test.ts @@ -14,6 +14,10 @@ describe("classifyDdos", () => { ["/api/auth/callback/credentials", "auth"], ["/api/articles/hello", "api"], ["/api/radio/stream", "api"], + // Imaging is deliberately in the `api` bucket: it is high-volume and + // cacheable, so it gets the generous api budget, not the page budget. + ["/api/imaging/avatar", "api"], + ["/api/imaging/badge", "api"], ])(`classifies %s as %s`, (pathname, expected) => { expect(classifyDdos(pathname)).toBe(expected); }); diff --git a/src/lib/ddos.ts b/src/lib/ddos.ts index 5afbfd4c..5dfc815c 100644 --- a/src/lib/ddos.ts +++ b/src/lib/ddos.ts @@ -1,5 +1,13 @@ export type DdosCategory = "pages" | "api" | "auth"; +/** + * Bucket a request path for rate limiting. Everything under `/api/` that is not + * an auth route counts as `api` — including `/api/imaging/*` (avatars, badges). + * That is intentional: imaging is a deliberate high-volume, cacheable surface, + * so it gets the wider api budget rather than the page budget. The bare + * `/imaging` renderer path never reaches this gate (it is excluded by the proxy + * matcher and, in production, fronted by the edge proxy directly). + */ export function classifyDdos(pathname: string): DdosCategory { if ( pathname === "/api/auth" || diff --git a/src/lib/furni/bundle-file.ts b/src/lib/furni/bundle-file.ts index d485319f..befedae2 100644 --- a/src/lib/furni/bundle-file.ts +++ b/src/lib/furni/bundle-file.ts @@ -5,8 +5,9 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname"; /** * Local furniture-bundle lookups. * - * Bundles are written as `.hab`, but a large body of pre-existing - * assets is still `.nitro`, so every existence check has to consider both. These + * New bundles are written with the configured primary extension + * (`FURNI_BUNDLE_EXT`, `.hab` by default), but a large body of pre-existing + * assets may be `.nitro`, so every existence check has to consider both. These * helpers are the single place that knows that, which keeps "is this item's * asset present?" from silently flipping to `false` for legacy files and the * nitro-cleanup scan from classifying them as fake leftovers. diff --git a/src/lib/furni/classname.test.ts b/src/lib/furni/classname.test.ts index be214751..0252a7a9 100644 --- a/src/lib/furni/classname.test.ts +++ b/src/lib/furni/classname.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { getBaseClassname, getBundleFileName, @@ -11,6 +11,7 @@ import { getSwfFileName, isValidClassname, normalizeClassname, + resolveLocalBundleExt, SOURCE_BUNDLE_EXT, } 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", () => { it("accepts lowercase letters, digits and allowed symbols", () => { expect(isValidClassname("chair_basic")).toBe(true); diff --git a/src/lib/furni/classname.ts b/src/lib/furni/classname.ts index 703cdbc9..08ec5d0b 100644 --- a/src/lib/furni/classname.ts +++ b/src/lib/furni/classname.ts @@ -17,23 +17,61 @@ export function getSwfFileName(classname: string): string { return `${getBaseClassname(classname)}.swf`; } +/** The two extensions a furniture bundle may carry. */ +export const BUNDLE_EXT_CHOICES = [".hab", ".nitro"] as const; +export type FurnitureBundleExt = (typeof BUNDLE_EXT_CHOICES)[number]; + +/** + * Read an env value via a computed key so Next does not inline it at build time + * — changing it should take effect on restart, not require a rebuild (same + * pattern as `runtime-asset-config.ts`). + */ +function readBundleExtEnv(): string | undefined { + const name = "FURNI_BUNDLE_EXT"; + return process.env[name]?.trim() || undefined; +} + +/** + * Resolve the local furniture-bundle extension this deployment's client asks + * for. Set `FURNI_BUNDLE_EXT` in `.env` to `hab`/`.hab` or `nitro`/`.nitro`; + * anything missing or unrecognised falls back to `.hab`. + */ +export function resolveLocalBundleExt( + raw: string | undefined = readBundleExtEnv(), +): FurnitureBundleExt { + const value = raw?.trim().toLowerCase(); + if (!value) return ".hab"; + const normalized = value.startsWith(".") ? value : `.${value}`; + return (BUNDLE_EXT_CHOICES as readonly string[]).includes(normalized) + ? (normalized as FurnitureBundleExt) + : ".hab"; +} + /** * Extension locally stored furniture bundles are written with. This deployment's - * Nitro client requests `.hab`, so that is what new imports produce. + * Nitro client requests `.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 * configured clone source) keeps shipping `.nitro`, so download URLs are built * with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk. */ -export const SOURCE_BUNDLE_EXT = ".nitro"; +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 { return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`; } diff --git a/src/proxy.ts b/src/proxy.ts index 522625dd..75b538f7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -103,6 +103,12 @@ export const proxy = async (req: import("next/server").NextRequest) => { }; 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: [ "/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)", ],