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 -3
View File
@@ -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";
}
}
+14
View File
@@ -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<AvatarFormat, "png">;
+3
View File
@@ -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(),
+4
View File
@@ -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);
});
+8
View File
@@ -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" ||
+3 -2
View File
@@ -5,8 +5,9 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
/**
* Local furniture-bundle lookups.
*
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
* assets is still `.nitro`, so every existence check has to consider both. These
* 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.
+40 -1
View File
@@ -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);
+44 -6
View File
@@ -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 `<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
* 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}`;
}
+6
View File
@@ -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).*)",
],