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
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:
1 parent
640e5a5dd0
commit
3c8abb6fbf
17 files changed
+288
-20
No files matched your search
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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).*)",
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user