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.
This commit is contained in:
openhands committed 2026-10-14 21:46:56 +02:00
1 parent 640e5a5dd0
commit 3c8abb6fbf
17 files changed
+288 -20

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
+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
+1 -1
View File
@@ -594,7 +594,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`
+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);
});
+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
+7 -3
View File
@@ -21,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";
} }
} }
+14
View File
@@ -16,6 +16,20 @@ import {
} from "@/lib/imager-upstream"; } from "@/lib/imager-upstream";
import { resolveImagerBase } from "@/lib/runtime-asset-config"; 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">; type TranscodeFormat = Exclude<AvatarFormat, "png">;
+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(),
+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" ||
+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}`;
} }
+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).*)",
], ],