feat(catalog): store furniture bundles as .hab instead of .nitro
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

Every bundle the CMS writes — upload, clone, sync, repair and the pet /
effect / figure importers — now lands as `<classname>.hab`, the extension
this deployment's renderer asks for. `.hab` and `.nitro` are the same
container, so an upload of either extension is accepted.

Resolution goes through one module, src/lib/furni/bundle-file.ts, so
nothing has to know the extension twice. Every existence check probes
`.hab` first and falls back to `.nitro`: the on-disk asset set is still
predominantly `.nitro`, and without the fallback Studio would report every
imported item as missing and the cleanup scan would classify 18k live
bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every
configured clone source still serve `.nitro`, so the conversion happens on
write, not on request.

Deliberately unchanged: the staged-attachment store in furni-attachment.ts
keys on a UUID and never reaches the client, so renaming it would break
in-flight recovery jobs.

Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It
refuses to run without --dry-run or --yes, never overwrites an existing
.hab, never deletes, and is idempotent.

Note: renderer-config.json lives outside this repo and was patched to
.hab separately; that file is served with a 30-day max-age, so returning
clients need a cms-client cache purge to pick the change up.
This commit is contained in:
openhands committed 2026-10-10 17:09:36 +02:00
1 parent fdb7af7ef5
commit adffac7360
46 files changed
+1001 -210

No files matched your search

+1 -1
View File
@@ -583,7 +583,7 @@ URLs look like `https://<hotel>/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` (`.nitro` assets) 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`, 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`.
### Configuration — `/docker/Polaris-imager/.env`
+4 -6
View File
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = {
name: "fixture_chair.nitro",
name: "fixture_chair.hab",
mimeType: "application/octet-stream",
buffer: Buffer.from("isolated upload fixture"),
};
@@ -23,7 +23,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
expect(route.request().postData()).toContain('name="classname"');
expect(route.request().postData()).toContain("fixture_chair");
expect(route.request().postData()).toContain(
'filename="fixture_chair.nitro"',
'filename="fixture_chair.hab"',
);
await route.fulfill({ json: { ok: true, attachmentId } });
});
@@ -38,7 +38,7 @@ test("attachment upload and double-click resume send one retry and refresh histo
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ json: { ok: true } });
});
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await expect(
page.getByText("Matching original file attached", { exact: true }),
).toBeVisible();
@@ -94,9 +94,7 @@ for (const scenario of [
})
: route.abort("failed"),
);
await page
.getByLabel("Choose the original .nitro file")
.setInputFiles(file);
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await expect(page.getByRole("alert")).toContainText(scenario.message);
await expect(
page.getByRole("button", {
+4 -4
View File
@@ -1,9 +1,10 @@
import "./load-env";
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
import { readFurniData } from "@/lib/services/furni-data";
const ICON_DIR = path.join(
@@ -191,11 +192,10 @@ async function main(): Promise<void> {
);
let nitroMissing = 0;
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
for (const cls of catalogClasses) {
if (!nitroSet.has(`${cls}.nitro`)) nitroMissing++;
if (!bundleExistsInDir(NITRO_DIR, cls)) nitroMissing++;
}
console.log(`[5] catalog items without .nitro bundle: ${nitroMissing}`);
console.log(`[5] catalog items without a bundle on disk: ${nitroMissing}`);
await db.$client.end();
process.exit(0);
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env tsx
/**
* Rename on-disk furniture bundles from `.nitro` to `.hab`.
*
* Imports have written `<classname>.hab` since the bundle-extension switch, but
* everything already on disk kept its old name. That matters at runtime: the
* client asks for `.hab`, so a catalogue whose assets are still `.nitro` shows
* furniture that renders as nothing. This script closes that gap.
*
* It is deliberately conservative:
* - refuses to run without `--dry-run` or `--yes`;
* - never overwrites an existing `.hab` — a conflict is reported, not resolved;
* - never deletes anything, so a half-finished run is recoverable by hand;
* - idempotent: a second run over migrated dirs is a no-op.
*
* Run it in a maintenance window. The rename is per-file, so a client request
* for a given `.nitro` 404s from the moment that file is renamed until the
* client asks for `.hab`.
*
* Usage:
* pnpm tsx scripts/migrate-nitro-to-hab.ts --dry-run
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --dir /var/www/extra/bundles
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --skip-generic
*/
import "./load-env";
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { db } from "@/lib/db";
import {
getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs";
import { getPublicAssetRoot } from "@/lib/services/public-asset-root";
import { siteSettings } from "@/lib/services/site-settings";
import { getRuntimePath } from "@/lib/utils/runtime-path";
const LEGACY_EXT = ".nitro";
const TARGET_EXT = ".hab";
interface Args {
dryRun: boolean;
yes: boolean;
skipGeneric: boolean;
dirs: string[];
}
function parseArgs(argv: string[]): Args {
const dirs: string[] = [];
let dryRun = false;
let yes = false;
let skipGeneric = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--dry-run") dryRun = true;
else if (arg === "--yes" || arg === "-y") yes = true;
else if (arg === "--skip-generic") skipGeneric = true;
else if (arg === "--dir") {
const value = argv[++i];
if (!value) throw Error("--dir needs a path");
dirs.push(path.resolve(value));
} else throw Error(`Unknown argument: ${arg}`);
}
return { dryRun, yes, skipGeneric, dirs };
}
/**
* Where the app keeps bundles. Mirrors the resolution in figure-import.ts /
* effect-import.ts / pet-import.ts so the script follows the same site settings
* the running instance uses. Settings are best-effort: a database that is down
* must not stop an operator from migrating files, so failures fall back to the
* on-disk defaults rather than aborting.
*/
async function resolveDirs(skipGeneric: boolean): Promise<string[]> {
const found: string[] = [];
const push = (dir: string | null | undefined) => {
if (dir?.trim()) found.push(path.resolve(dir.trim()));
};
// Furniture: primary + every configured mirror (gamedata, nitro-files).
try {
const targets = await getFurniAssetWriteTargets();
push(targets.nitroDir);
for (const mirror of targets.mirrorDirs) push(mirror.nitroDir);
} catch (error) {
console.warn(
` ! could not read furniture asset settings (${(error as Error).message}); using defaults`,
);
push(
getRuntimePath(process.cwd(), "public/nitro-assets/bundled/furniture"),
);
push("/var/www/Gamedata/bundled/furniture");
}
let gamedataRoot = "";
try {
gamedataRoot = await getGamedataRoot();
} catch {
/* defaults below cover it */
}
for (const type of ["figure", "effect"]) {
let configured = "";
try {
configured = (
(await siteSettings.get(`${type}_nitro_dir`, "")) ?? ""
).trim();
} catch {
/* fall through to defaults */
}
if (configured) push(configured);
else if (gamedataRoot)
push(getRuntimePath(gamedataRoot, `bundled/${type}`));
else
push(
getRuntimePath(
getPublicAssetRoot(),
`public/nitro-assets/bundled/${type}`,
),
);
}
// Pets: the CMS dir is `pet`, this deployment's gamedata dir is `pets`.
// Both spellings are listed so neither is silently skipped.
push(getRuntimePath(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"));
if (gamedataRoot) {
push(getRuntimePath(gamedataRoot, "bundled/pet"));
push(getRuntimePath(gamedataRoot, "bundled/pets"));
}
// `generic` holds the stock client UI bundles (selection_arrow, room,
// tile_cursor, place_holder…) that this CMS never imported. They are included
// by default because the renderer's `generic.asset.url` template resolves to
// `.hab` too — leaving them behind breaks the room view, not just furniture.
if (!skipGeneric && gamedataRoot) {
push(getRuntimePath(gamedataRoot, "bundled/generic"));
}
return [...new Set(found)];
}
interface DirResult {
dir: string;
renamed: number;
alreadyHab: number;
conflicts: Array<{ from: string; to: string }>;
errors: Array<{ file: string; message: string }>;
}
async function migrateDir(dir: string, dryRun: boolean): Promise<DirResult> {
const result: DirResult = {
dir,
renamed: 0,
alreadyHab: 0,
conflicts: [],
errors: [],
};
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch (error) {
result.errors.push({ file: dir, message: (error as Error).message });
return result;
}
for (const entry of entries) {
if (!entry.toLowerCase().endsWith(LEGACY_EXT)) continue;
const from = path.join(dir, entry);
const to = path.join(
dir,
`${entry.slice(0, -LEGACY_EXT.length)}${TARGET_EXT}`,
);
// Never clobber. A pre-existing `.hab` is a live bundle the client is
// already serving; leaving the `.nitro` alone is the only safe answer.
if (existsSync(to)) {
result.conflicts.push({
from: path.basename(from),
to: path.basename(to),
});
continue;
}
if (dryRun) {
result.renamed++;
continue;
}
try {
await fs.rename(from, to);
result.renamed++;
} catch (error) {
result.errors.push({ file: entry, message: (error as Error).message });
}
}
// Report the target state too, so a run confirms the end condition rather
// than just the work it did.
for (const entry of await fs.readdir(dir)) {
if (entry.toLowerCase().endsWith(TARGET_EXT)) result.alreadyHab++;
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.dryRun && !args.yes) {
console.error(
"Nothing to do: pass --dry-run to preview, or --yes to rename for real.",
);
process.exitCode = 2;
}
const dirs = [
...new Set([...args.dirs, ...(await resolveDirs(args.skipGeneric))]),
];
const existing = dirs.filter((dir) => existsSync(dir));
console.log(
args.dryRun
? "DRY RUN — no files will be touched."
: "Renaming .nitro bundles to .hab.",
);
if (!args.dryRun) {
console.log(
"Run this in a maintenance window: the client 404s on each file between its rename and its switch to .hab.",
);
}
console.log(`\nDirectories (${existing.length} of ${dirs.length} exist):`);
for (const dir of existing) console.log(` ${dir}`);
const missing = dirs.filter((dir) => !existsSync(dir));
if (missing.length) {
console.log(`\nNot present, skipped:`);
for (const dir of missing) console.log(` ${dir}`);
}
if (!existing.length) {
console.log("\nNo bundle directories found — nothing to migrate.");
return;
}
console.log("");
const results: DirResult[] = [];
for (const dir of existing) {
results.push(await migrateDir(dir, args.dryRun));
}
let totalRenamed = 0;
let totalHab = 0;
let totalConflicts = 0;
let totalErrors = 0;
for (const result of results) {
totalRenamed += result.renamed;
totalHab += result.alreadyHab;
totalConflicts += result.conflicts.length;
totalErrors += result.errors.length;
console.log(
`${result.dir}\n` +
` ${args.dryRun ? "would rename" : "renamed"}: ${result.renamed}\n` +
` .hab present: ${result.alreadyHab}`,
);
for (const conflict of result.conflicts) {
console.log(
` CONFLICT: ${conflict.from} — ${conflict.to} already exists`,
);
}
for (const error of result.errors) {
console.log(` ERROR: ${error.file} — ${error.message}`);
}
}
console.log(
`\n${args.dryRun ? "Would rename" : "Renamed"} ${totalRenamed} file(s). ` +
`${totalHab} .hab bundle(s) present afterwards.`,
);
if (totalConflicts) {
console.log(
`\n${totalConflicts} conflict(s): a .hab with that name already exists. ` +
"The .nitro was left in place. Resolve these by hand — the client can only load one of the two.",
);
}
if (totalErrors)
console.log(`\n${totalErrors} error(s); re-run once they are fixed.`);
// Non-zero on a partial migration so a wrapper cannot report success.
if (totalConflicts || totalErrors) process.exitCode = 1;
}
// The settings lookups open a mysql2 pool, which keeps the event loop alive —
// the script prints its whole report and then sits there burning a timeout
// instead of exiting. Closing the pool is not enough on its own here, so the
// exit is explicit, matching scripts/furni-diagnose-now.ts.
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.then(async () => {
await db.$client.end().catch(() => {});
process.exit(process.exitCode ?? 0);
});
@@ -282,17 +282,19 @@ export function UploadFurniClient() {
<div className="text-sm font-medium">Files</div>
<div className="space-y-1.5">
<Label htmlFor="nitro">.nitro file *</Label>
<Label htmlFor="nitro">.hab file *</Label>
<Input
ref={nitroRef}
id="nitro"
type="file"
accept=".nitro"
accept=".hab,.nitro"
onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)}
required
/>
<p className="text-xs text-muted-foreground">
The .nitro bundle file (max 50MB)
The furniture bundle (max 50MB). Stored as{" "}
<code className="font-mono">.hab</code>; a{" "}
<code className="font-mono">.nitro</code> is accepted too.
</p>
</div>
+2 -2
View File
@@ -13,10 +13,10 @@ export default async function StudioUploadPage(_props: {
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture
Upload .hab Furniture
</h1>
<p className="mt-1 text-muted-foreground">
Upload a .nitro bundle and optional icon to import furniture into the
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>
@@ -4,6 +4,10 @@ import { eq } from "drizzle-orm";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db";
import {
bundleExistsInDir,
bundleWritePathInDir,
} from "@/lib/furni/bundle-file";
import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
@@ -103,12 +107,12 @@ export const POST = withAdmin(
starIdx !== -1
? item.classname.substring(0, starIdx)
: item.classname;
const nitroPath = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`,
);
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
if (
bundleExistsInDir(
/*turbopackIgnore: true*/ nitroDir,
baseClassname,
)
) {
skipped++;
send({
type: "regen_progress",
@@ -203,6 +207,10 @@ export const POST = withAdmin(
swfBuffer,
baseClassname,
);
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
baseClassname,
);
await fs.writeFile(nitroPath, conversion.bundle);
// Build FurnitureData entry if missing
@@ -1,10 +1,10 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { promises as fs } from "node:fs";
import { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -25,7 +25,7 @@ function baseClassname(classname: string): string {
return classname.includes("*") ? classname.split("*")[0] : classname;
}
// ── GET: Read .nitro metadata ──────────────────────────────────────
// ── GET: Read bundle metadata ──────────────────────────────────────
export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
@@ -35,13 +35,12 @@ export const GET = withAdmin(
const base = baseClassname(rawClassname);
const { nitroDir } = await getFurniAssetDirs();
const nitroPath = path.join(
const nitroPath = resolveBundleInDir(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
base,
);
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
return apiError("Nitro file not found", 404);
if (!nitroPath) return apiError("Bundle file not found", 404);
try {
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
@@ -67,15 +66,15 @@ export const GET = withAdmin(
return apiOk({ metadata: json, flags });
} catch (err) {
console.error(
"[nitro-editor] Failed to parse .nitro:",
"[nitro-editor] Failed to parse bundle:",
(err as Error).message,
);
return apiError("Failed to parse .nitro file", 500);
return apiError("Failed to parse bundle file", 500);
}
},
);
// ── PUT: Save .nitro metadata ──────────────────────────────────────
// ── PUT: Save bundle metadata ──────────────────────────────────────
export const PUT = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
@@ -94,13 +93,15 @@ export const PUT = withAdmin(
const base = baseClassname(rawClassname);
const { nitroDir } = await getFurniAssetDirs();
const nitroPath = path.join(
// Write back to whichever extension the file already carries, so
// editing metadata never migrates a legacy `.nitro` to `.hab` (or the
// reverse) as a side effect.
const nitroPath = resolveBundleInDir(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
base,
);
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
return apiError("Nitro file not found", 404);
if (!nitroPath) return apiError("Bundle file not found", 404);
const saved = await withFurniDataLock(async () => {
const existingBuffer = await fs.readFile(nitroPath);
+11 -4
View File
@@ -5,6 +5,10 @@ import { invalidateCatalogTotals } from "@/features/catalog/server/catalog-total
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db";
import {
bundleExistsInDir,
bundleWritePathInDir,
} from "@/lib/furni/bundle-file";
import { isSafeAssetName, normalizeClassname } from "@/lib/furni/classname";
import { localFurnitureStatus } from "@/lib/furni/local-presence";
import { PERMS } from "@/lib/permissions";
@@ -723,13 +727,16 @@ export const PATCH = withAdmin(
const starIdx = normalised.indexOf("*");
const baseClassname =
starIdx !== -1 ? normalised.substring(0, starIdx) : normalised;
const nitroPath = path.join(
// Regeneration writes `.hab`; a legacy `.nitro` counts as present too.
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`,
baseClassname,
);
// Skip if .nitro already exists
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
// Skip if a bundle already exists (`.hab` or legacy `.nitro`).
if (
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, baseClassname)
) {
results.push({
classname: item.classname,
ok: true,
@@ -27,7 +27,9 @@ export const POST = withAdmin(
(formData.get("interactionType") as string)?.trim() || "default";
const customparams = (formData.get("customparams") as string)?.trim() || "";
const generateSql = formData.get("generateSql") === "true";
const nitroFile = formData.get("nitro") as File | null;
const nitroFile =
(formData.get("nitro") as File | null) ??
(formData.get("hab") as File | null);
const iconFile = formData.get("icon") as File | null;
if (!classname) return apiError("classname is required", 400);
@@ -35,10 +37,10 @@ export const POST = withAdmin(
if (!itemType || !["s", "i"].includes(itemType))
return apiError("itemType must be 's' or 'i'", 400);
if (!nitroFile || nitroFile.size === 0)
return apiError(".nitro file is required", 400);
return apiError(".hab file is required", 400);
if (nitroFile.size > 50 * 1024 * 1024)
return apiError(".nitro file too large (max 50MB)", 400);
return apiError(".hab file too large (max 50MB)", 400);
const nitroBuffer = Buffer.from(await nitroFile.arrayBuffer());
@@ -17,7 +17,7 @@ export const POST = withAdmin(
file.size > 50 * 1024 * 1024
)
return apiError(
"Select a .nitro file for this furniture (maximum 50 MB)",
"Select a .hab file for this furniture (maximum 50 MB)",
400,
);
try {
@@ -30,7 +30,7 @@ export const POST = withAdmin(
} catch (error) {
return apiJson(
{
error: "Invalid original .nitro file",
error: "Invalid original .hab file",
code:
error instanceof Error &&
error.message.startsWith("This bundle does not belong")
+13 -2
View File
@@ -24,7 +24,12 @@ vi.mock("@/lib/services/furni-asset-dirs", () => ({
iconDir: "/assets/icons",
}),
}));
vi.mock("node:fs", () => ({ promises: { stat: mocks.stat } }));
vi.mock("node:fs", () => ({
// `item-kind` offers both bundle names per classname; existence is still
// decided by the mocked stat below.
existsSync: () => false,
promises: { stat: mocks.stat },
}));
import { emptyRouteContext } from "@/test/route-context";
import { POST } from "./route";
@@ -81,7 +86,13 @@ describe("furniture inspection", () => {
mocks.stat.mock.calls.map((call) =>
String(call[0]).replaceAll("\\", "/"),
),
).toEqual(["/assets/nitro/chair.nitro", "/assets/icons/chair_2_icon.png"]);
).toEqual([
// `.hab` is the live convention, so it is probed before the
// legacy `.nitro` name for the same classname.
"/assets/nitro/chair.hab",
"/assets/nitro/chair.nitro",
"/assets/icons/chair_2_icon.png",
]);
});
it("distinguishes unreadable data and denied assets from missing files", async () => {
mocks.read.mockRejectedValue(new Error("unreadable"));
@@ -5,7 +5,12 @@ import { PERMS } from "@/lib/permission-slugs";
import { createNitroBundle, encodePng } from "@/lib/services/swf/nitro-builder";
const mocks = vi.hoisted(() => ({ read: vi.fn(), guard: vi.fn() }));
vi.mock("node:fs", () => ({ promises: { readFile: mocks.read } }));
// `bundle-file` resolves the live bundle (`.hab`, else legacy `.nitro`)
// through existsSync before the route reads it.
vi.mock("node:fs", () => ({
existsSync: () => true,
promises: { readFile: mocks.read },
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import sharp from "sharp";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import {
buildNitroCompressionReport,
inspectNitroScales,
@@ -22,9 +22,12 @@ export const GET = withAdmin(
const base = String(classname).split("*")[0],
{ nitroDir } = await getFurniAssetDirs();
try {
const parsed = parseNitroBundle(
await fs.readFile(path.join(nitroDir, `${base}.nitro`)),
const bundlePath = resolveBundleInDir(
/*turbopackIgnore: true*/ nitroDir,
base,
);
if (!bundlePath) return apiError("Bundle file not found", 404);
const parsed = parseNitroBundle(await fs.readFile(bundlePath));
const { json, texture } = parsed;
const image = await sharp(texture).metadata();
if (!image.width || !image.height)
@@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({
generate: vi.fn(),
}));
vi.mock("node:fs", () => ({
// `bundle-file` resolves the live bundle (`.hab`, else legacy `.nitro`)
// through existsSync before the route reads it.
existsSync: () => true,
promises: {
readFile: mocks.read,
writeFile: mocks.write,
@@ -3,6 +3,10 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import {
bundleWritePathInDir,
resolveBundleInDir,
} from "@/lib/furni/bundle-file";
import { generateScale32 } from "@/lib/furni/nitro-scale32";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
@@ -33,7 +37,12 @@ export const POST = withAdmin(
return apiError("Invalid backup", 400);
const { nitroDir } = await getFurniAssetDirs(),
base = body.classname.split("*")[0],
file = path.join(nitroDir, `${base}.nitro`);
// Edits are written back in place, so the file keeps whatever
// extension it already has. The `.hab` fallback only exists so a
// missing bundle still surfaces as the usual ENOENT 422.
file =
resolveBundleInDir(/*turbopackIgnore: true*/ nitroDir, base) ??
bundleWritePathInDir(/*turbopackIgnore: true*/ nitroDir, base);
const execute = () =>
withFurniDataLock(async () => {
const original = await fs.readFile(file),
@@ -1,6 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { stripBundleExt } from "@/lib/furni/bundle-file";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
const CORS_HEADERS = {
@@ -17,7 +18,7 @@ export async function GET(
if (
!filename ||
filename !== path.basename(filename) ||
!filename.endsWith(".nitro")
stripBundleExt(filename) === null
) {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
}
@@ -170,7 +170,7 @@ export function FurnitureDetailDrawer({
{!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)]">
This furni is in the DB but the .nitro file is missing.
This furni is in the DB but its bundle file is missing.
</p>
<Button
variant="outline"
@@ -89,13 +89,13 @@ export function ImportAttachmentRecovery({
<details className="my-2 rounded border border-[var(--admin-border)] p-2">
<summary className="cursor-pointer">{t("recover")}</summary>
<p className="my-2 text-xs text-[var(--admin-text-muted)]">
{t("recoveryHint", { file: `${classname.split("*")[0]}.nitro` })}
{t("recoveryHint", { file: `${classname.split("*")[0]}.hab` })}
</p>
<label className="block text-xs">
{t("attach")}
<input
type="file"
accept=".nitro"
accept=".hab,.nitro"
className="my-2 block max-w-full"
disabled={disabled || busy}
onChange={(event) => {
@@ -147,7 +147,7 @@ export function ImportReview({
<strong>Source:</strong> {source}
</p>
<p>
<strong>Destination:</strong> local catalog, furnidata, .nitro and
<strong>Destination:</strong> local catalog, furnidata, bundle and
icons
</p>
<p>
@@ -349,7 +349,7 @@ export function ImportReview({
<input
className="mt-2 block max-w-full"
type="file"
accept=".nitro"
accept=".hab,.nitro"
disabled={busy || uploading !== null}
onChange={(event) => {
const file = event.target.files?.[0];
+110
View File
@@ -0,0 +1,110 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
bundleExistsInDir,
bundleExistsInDirs,
bundleFileNames,
bundleWritePathInDir,
existingBundlePaths,
resolveBundleFileNameInDir,
resolveBundleInDir,
resolveBundleInDirs,
stripBundleExt,
} from "./bundle-file";
let dir: string;
let mirror: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "hab-bundle-"));
mirror = await fs.mkdtemp(path.join(os.tmpdir(), "hab-bundle-mirror-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
await fs.rm(mirror, { recursive: true, force: true });
});
describe("bundleFileNames", () => {
it("prefers `.hab` and keeps `.nitro` as the legacy fallback", () => {
expect(bundleFileNames("sofa_blue")).toEqual([
"sofa_blue.hab",
"sofa_blue.nitro",
]);
});
});
describe("stripBundleExt", () => {
it("strips either extension", () => {
expect(stripBundleExt("sofa_blue.hab")).toBe("sofa_blue");
expect(stripBundleExt("sofa_blue.nitro")).toBe("sofa_blue");
expect(stripBundleExt("SOFA_BLUE.HAB")).toBe("SOFA_BLUE");
});
it("rejects anything else", () => {
expect(stripBundleExt("sofa_blue.png")).toBeNull();
expect(stripBundleExt("sofa_blue")).toBeNull();
});
});
describe("resolution", () => {
it("finds a `.hab` bundle", async () => {
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "x");
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(true);
expect(resolveBundleInDir(dir, "sofa_blue")).toBe(
path.join(dir, "sofa_blue.hab"),
);
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe("sofa_blue.hab");
});
// The whole point of the fallback: an import must not report an item as
// missing just because its bundle predates the `.hab` convention.
it("still finds a legacy `.nitro` bundle", async () => {
await fs.writeFile(path.join(dir, "sofa_blue.nitro"), "x");
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(true);
expect(resolveBundleInDir(dir, "sofa_blue")).toBe(
path.join(dir, "sofa_blue.nitro"),
);
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe(
"sofa_blue.nitro",
);
});
it("reports a missing bundle", async () => {
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(false);
expect(resolveBundleInDir(dir, "sofa_blue")).toBeNull();
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBeNull();
});
it("searches mirrors when the primary has nothing", async () => {
await fs.writeFile(path.join(mirror, "sofa_blue.nitro"), "x");
expect(bundleExistsInDirs([dir, mirror], "sofa_blue")).toBe(true);
expect(resolveBundleInDirs([dir, mirror], "sofa_blue")).toBe(
path.join(mirror, "sofa_blue.nitro"),
);
});
it("prefers `.hab` over a same-stem legacy file", async () => {
await fs.writeFile(path.join(dir, "sofa_blue.nitro"), "old");
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "new");
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe("sofa_blue.hab");
});
});
describe("writing", () => {
it("always writes `.hab`", () => {
expect(bundleWritePathInDir("/assets", "sofa_blue")).toBe(
path.join("/assets", "sofa_blue.hab"),
);
});
it("lists every existing variant so a delete can never leave one behind", async () => {
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "x");
await fs.writeFile(path.join(mirror, "sofa_blue.nitro"), "x");
expect(existingBundlePaths([dir, mirror], "sofa_blue")).toEqual([
path.join(dir, "sofa_blue.hab"),
path.join(mirror, "sofa_blue.nitro"),
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { existsSync } from "node:fs";
import path from "node:path";
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
* 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.
*
* Remote URLs deliberately do NOT go through here: sources still serve `.nitro`.
*/
export function bundleFileNames(base: string): string[] {
return LOCAL_BUNDLE_EXTS.map((ext) => `${base}${ext}`);
}
/** The classname stem of a bundle file name, or null for any other extension. */
export function stripBundleExt(fileName: string): string | null {
for (const ext of LOCAL_BUNDLE_EXTS) {
if (fileName.toLowerCase().endsWith(ext)) {
return fileName.slice(0, -ext.length);
}
}
return null;
}
/** Path of `base`'s bundle inside `dir`, or null when neither extension is there. */
export function resolveBundleInDir(dir: string, base: string): string | null {
for (const fileName of bundleFileNames(base)) {
const candidate = path.join(dir, fileName);
if (existsSync(candidate)) return candidate;
}
return null;
}
/** File name of `base`'s bundle inside `dir`, preferring `.hab`. */
export function resolveBundleFileNameInDir(
dir: string,
base: string,
): string | null {
for (const fileName of bundleFileNames(base)) {
if (existsSync(path.join(dir, fileName))) return fileName;
}
return null;
}
export function bundleExistsInDir(dir: string, base: string): boolean {
return resolveBundleInDir(dir, base) !== null;
}
/** True when the bundle exists in `dir` or in any mirror dir. */
export function bundleExistsInDirs(dirs: string[], base: string): boolean {
return dirs.some((dir) => bundleExistsInDir(dir, base));
}
/** First hit across `dirs`, primary first — callers pass the primary dir first. */
export function resolveBundleInDirs(
dirs: string[],
base: string,
): string | null {
for (const dir of dirs) {
const found = resolveBundleInDir(dir, base);
if (found) return found;
}
return null;
}
/**
* The path a newly imported bundle should be written to. Colliding with an
* existing `.nitro` is reported so the caller can decide — writing `.hab`
* beside a live `.nitro` would leave two competing bundles.
*/
export function bundleWritePathInDir(dir: string, base: string): string {
return path.join(dir, `${base}${LOCAL_BUNDLE_EXT}`);
}
/** Every existing bundle file for `base`, across all dirs — for cleanup/deletes. */
export function existingBundlePaths(dirs: string[], base: string): string[] {
const out: string[] = [];
for (const dir of dirs) {
for (const fileName of bundleFileNames(base)) {
const candidate = path.join(dir, fileName);
if (existsSync(candidate)) out.push(candidate);
}
}
return out;
}
+16 -2
View File
@@ -1,14 +1,17 @@
import { describe, expect, it } from "vitest";
import {
getBaseClassname,
getBundleFileName,
getHabboCdnIconUrl,
getIconFileName,
getLocalBundleFileNames,
getLocalIconUrl,
getNitroFileName,
getSafeClassnameStem,
getSwfFileName,
isValidClassname,
normalizeClassname,
SOURCE_BUNDLE_EXT,
} from "./classname";
describe("getBaseClassname", () => {
@@ -36,8 +39,19 @@ describe("file name helpers", () => {
it("builds swf file names from the base classname", () => {
expect(getSwfFileName("sofa_blue*")).toBe("sofa_blue.swf");
});
it("builds nitro file names from the base classname", () => {
expect(getNitroFileName("sofa_blue*")).toBe("sofa_blue.nitro");
it("builds the primary (`.hab`) bundle name from the base classname", () => {
expect(getNitroFileName("sofa_blue*")).toBe("sofa_blue.hab");
});
it("offers `.hab` first and keeps legacy `.nitro` resolvable", () => {
expect(getLocalBundleFileNames("sofa_blue*")).toEqual([
"sofa_blue.hab",
"sofa_blue.nitro",
]);
});
it("builds a bundle name for an explicit extension", () => {
expect(getBundleFileName("sofa_blue*", SOURCE_BUNDLE_EXT)).toBe(
"sofa_blue.nitro",
);
});
});
+32 -1
View File
@@ -17,8 +17,39 @@ export function getSwfFileName(classname: string): string {
return `${getBaseClassname(classname)}.swf`;
}
/**
* Extension locally stored furniture bundles are written with. This deployment's
* Nitro client requests `<classname>.hab`, so that is what new imports produce.
*/
export const LOCAL_BUNDLE_EXT = ".hab";
/**
* 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";
/** Both extensions a local bundle may carry, newest convention first. */
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const;
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */
export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}.nitro`;
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
}
/** Every local file name this classname's bundle may be stored under. */
export function getLocalBundleFileNames(classname: string): string[] {
const base = getBaseClassname(classname);
return LOCAL_BUNDLE_EXTS.map((ext) => `${base}${ext}`);
}
/** File name for an explicit extension — use `SOURCE_BUNDLE_EXT` for URLs. */
export function getBundleFileName(
classname: string,
ext: string = LOCAL_BUNDLE_EXT,
): string {
return `${getBaseClassname(classname)}${ext}`;
}
export function normalizeClassname(raw: string): string {
+3 -2
View File
@@ -1,5 +1,6 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { bundleExistsInDirs } from "@/lib/furni/bundle-file";
import { getFigureDataPath, readFigureData } from "@/lib/services/figuredata";
import {
getFigureMapPath,
@@ -151,10 +152,10 @@ function existsIn(dirs: string[], fileName: string): boolean {
return dirs.some((dir) => existsSync(path.join(dir, fileName)));
}
/** True when a `.nitro` bundle for this classname exists in any asset dir. */
/** True when a bundle (`.hab`, or legacy `.nitro`) exists in any asset dir. */
export async function hasNitroBundle(classname: string): Promise<boolean> {
const { nitroDirs } = await assetDirs();
return existsIn(nitroDirs, `${classname}.nitro`);
return bundleExistsInDirs(nitroDirs, classname);
}
/** True when a furniture icon for this classname exists in any asset dir. */
+9 -5
View File
@@ -37,6 +37,10 @@ const CONFIG_FILES = [
"ProductData.json",
];
const TABLES = ["items_base", "catalog_pages", "catalog_items"] as const;
// The catalog export must ship both bundle extensions for every asset type:
// `.hab` is what imports write now (furniture, pets, effects and figures all
// go through it), `.nitro` is what the existing asset set is made of.
const BUNDLE_FILE_RE = /\.(?:nitro|hab)$/i;
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
export async function createCatalogSnapshot(
@@ -75,13 +79,13 @@ export async function createCatalogSnapshot(
await addTree(
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled"),
"Gamedata/bundled",
/\.nitro$/i,
BUNDLE_FILE_RE,
);
if (gamedata) {
await addTree(
path.join(gamedata, "bundled"),
"Gamedata/bundled",
/\.nitro$/i,
BUNDLE_FILE_RE,
);
await addTree(
path.join(gamedata, "icons"),
@@ -94,18 +98,18 @@ export async function createCatalogSnapshot(
/\.(png|gif|webp|jpe?g)$/i,
);
}
await addTree(furni.nitroDir, "Gamedata/bundled/furniture", /\.nitro$/i);
await addTree(furni.nitroDir, "Gamedata/bundled/furniture", BUNDLE_FILE_RE);
await addTree(furni.iconDir, "Gamedata/icons", /\.(png|gif|webp|jpe?g)$/i);
for (const type of ["figure", "effect"]) {
const configured = await siteSettings.get(`${type}_nitro_dir`, "");
if (configured?.trim())
await addTree(configured, `Gamedata/bundled/${type}`, /\.nitro$/i);
await addTree(configured, `Gamedata/bundled/${type}`, BUNDLE_FILE_RE);
}
// Pets currently write directly to the CMS directory.
await addTree(
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"),
"Gamedata/bundled/pet",
/\.nitro$/i,
BUNDLE_FILE_RE,
);
const configRoot = gamedata
? path.join(gamedata, "config")
+3 -3
View File
@@ -168,7 +168,7 @@ describe("clone-import", () => {
expect(r.skipped).toBe(true);
});
it("rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects", async () => {
it("rolls back: unlinks the bundle and icon, skips FurnitureData when items_base INSERT rejects", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
downloadFile.mockReset();
downloadFile.mockResolvedValue({ ok: true, size: 200 });
@@ -190,9 +190,9 @@ describe("clone-import", () => {
entry: { ...ENTRY, itemType: "s" },
});
// (a) .nitro must be unlinked.
// (a) the bundle (`.hab`) must be unlinked.
expect(fsUnlink).toHaveBeenCalledWith(
expect.stringContaining("bc_sofa.nitro"),
expect.stringContaining("bc_sofa.hab"),
);
// (b) icon must also be unlinked.
expect(fsUnlink).toHaveBeenCalledWith(
+7 -5
View File
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { bundleWritePathInDir } from "@/lib/furni/bundle-file";
import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
@@ -293,11 +294,12 @@ export async function cloneSingleFurni(params: {
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
// Download .nitro + icon directly from the source hotel.
// Download the bundle + icon directly from the source hotel. Sources serve
// `.nitro`; the local copy is stored as `.hab`.
await onProgress?.("downloading");
const nitroPath = getRuntimePath(
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
`${classname}.nitro`,
classname,
);
const iconPath = getRuntimePath(
/*turbopackIgnore: true*/ iconDir,
@@ -331,8 +333,8 @@ export async function cloneSingleFurni(params: {
clonedNitroJson = parsed.json as Record<string, unknown>;
} catch {
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
logger.warn("[clone-import] invalid .nitro bundle for", { classname });
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
logger.warn("[clone-import] invalid bundle for", { classname });
return { ok: false, classname, warnings, error: "invalid bundle" };
}
// Hotels ship PNG textures, so normalise every clone to WebP Lossless and
// make it match the SWF importer. A texture that will not re-encode is
+17 -8
View File
@@ -1,4 +1,9 @@
import { existsSync, promises as fs } from "node:fs";
import {
bundleExistsInDir,
bundleWritePathInDir,
existingBundlePaths,
} from "@/lib/furni/bundle-file";
import {
listOfficialEffects,
resolveEffectSwfUrl,
@@ -63,10 +68,12 @@ export async function ensureEffectDirs(): Promise<void> {
);
}
// Imports write `.hab`, the extension the client requests; a bundle still on
// disk as legacy `.nitro` keeps counting as present.
async function nitroPathFor(lib: string): Promise<string> {
return getRuntimePath(
return bundleWritePathInDir(
/*turbopackIgnore: true*/ await getEffectNitroDir(),
`${lib}.nitro`,
lib,
);
}
@@ -146,11 +153,15 @@ export async function deleteImportedEffect(
const removed = await removeEffect(idOrLib);
let deletedFile = false;
if (entry) {
const p = await nitroPathFor(entry.lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
// Clear both conventions so a re-import cannot resurrect a stale bundle.
const paths = existingBundlePaths(
[/*turbopackIgnore: true*/ await getEffectNitroDir()],
entry.lib,
);
for (const p of paths) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
deletedFile = true;
}
deletedFile = paths.length > 0;
}
return { removed, deletedFile };
}
@@ -169,9 +180,7 @@ export async function getEffectList(search: string): Promise<EffectListItem[]> {
const merged = base.map((e) => ({
...e,
present: localByLib.has(e.lib),
nitroExists: existsSync(
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${e.lib}.nitro`),
),
nitroExists: bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, e.lib),
}));
return term
? merged.filter(
+19 -12
View File
@@ -1,4 +1,9 @@
import { existsSync, promises as fs } from "node:fs";
import {
bundleExistsInDir,
bundleWritePathInDir,
existingBundlePaths,
} from "@/lib/furni/bundle-file";
import { logServerError } from "@/lib/server-log";
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
import { listFigureLibraries } from "@/lib/services/figuremap";
@@ -69,10 +74,12 @@ export async function ensureFigureDirs(): Promise<void> {
);
}
// Imports write `.hab`, the extension the client requests; a bundle still on
// disk as legacy `.nitro` keeps counting as present.
async function nitroPathFor(lib: string): Promise<string> {
return getRuntimePath(
return bundleWritePathInDir(
/*turbopackIgnore: true*/ await getFigureNitroDir(),
`${lib}.nitro`,
lib,
);
}
@@ -133,14 +140,18 @@ export async function importSingleFigure(params: {
export async function deleteImportedFigure(
lib: string,
): Promise<{ deletedFile: boolean }> {
const p = await nitroPathFor(lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
// Clear both conventions so a re-import cannot resurrect a stale bundle.
const paths = existingBundlePaths(
[/*turbopackIgnore: true*/ await getFigureNitroDir()],
lib,
);
if (paths.length === 0) return { deletedFile: false };
for (const p of paths) {
await fs
.unlink(/*turbopackIgnore: true*/ p)
.catch((error) => logServerError("figure.delete_failed", error, { lib }));
return { deletedFile: true };
}
return { deletedFile: false };
return { deletedFile: true };
}
export async function getFigureList(params: {
@@ -163,9 +174,7 @@ export async function getFigureList(params: {
const items = filtered.slice(start, start + perPage).map((l) => ({
lib: l.id,
revision: l.revision,
nitroExists: existsSync(
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
),
nitroExists: bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, l.id),
previewType: l.parts?.[0]?.type,
previewId: l.parts?.[0]?.id,
}));
@@ -182,9 +191,7 @@ export async function getFigureStats(): Promise<{
getFigureNitroDir(),
]);
const imported = libs.filter((l) =>
existsSync(
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
),
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, l.id),
).length;
return { total: libs.length, imported, missing: libs.length - imported };
}
+3 -4
View File
@@ -12,10 +12,9 @@
* regen/sync) clear everything via `clearFurniImportCache()`.
*/
import { existsSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
import {
getBaseClassname,
getSafeClassnameStem,
@@ -163,12 +162,12 @@ export async function getCleanupValidStems(): Promise<Set<string>> {
return set;
}
/** `existsSync` memoized per (dir + base) — nitro files change rarely. */
/** `existsSync` memoized per (dir + base) — bundle files change rarely. */
export function nitroExistsCached(dir: string, base: string): boolean {
const key = `${dir}/${base}`;
const cached = nitroPresenceCache.get(key);
if (cached !== undefined) return cached;
const present = existsSync(path.join(dir, `${base}.nitro`));
const present = bundleExistsInDir(dir, base);
nitroPresenceCache.set(key, present);
return present;
}
+5 -2
View File
@@ -28,7 +28,10 @@ vi.mock("@/lib/db", async () => ({
CatalogPages: {},
}));
vi.mock("node:fs", () => ({
existsSync: (path: string) => !path.endsWith(".nitro") || mocks.nitroExists,
// Bundle presence covers both conventions: `.hab` is what imports write,
// `.nitro` is what the legacy asset set holds.
existsSync: (path: string) =>
!/\.(?:nitro|hab)$/.test(path) || mocks.nitroExists,
promises: {
stat: mocks.stat,
writeFile: mocks.write,
@@ -113,7 +116,7 @@ describe("completion of existing furniture", () => {
});
expect(recover).toHaveBeenCalledWith(1);
expect(mocks.write).toHaveBeenCalledWith(
expect.stringContaining("recycler_scrapbookA.nitro"),
expect.stringContaining("recycler_scrapbookA.hab"),
expect.any(Buffer),
{ flag: "wx" },
);
+51 -45
View File
@@ -4,7 +4,14 @@ import path from "node:path";
import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
import { isSafeAssetName } from "@/lib/furni/classname";
import {
bundleExistsInDir,
bundleFileNames,
bundleWritePathInDir,
resolveBundleInDir,
stripBundleExt,
} from "@/lib/furni/bundle-file";
import { isSafeAssetName, LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
import { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
@@ -449,8 +456,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
"bundled/furniture",
);
if (nitroDir !== nitroTarget) {
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
entry.endsWith(".nitro"),
const copied = await syncDirMissingFiles(
nitroDir,
nitroTarget,
(entry) => stripBundleExt(entry) !== null,
);
copiedNitros.push(...copied);
}
@@ -721,10 +730,17 @@ export async function importSingleFurni(params: {
/*turbopackIgnore: true*/ swfDir,
`${safeSwfName}.swf`,
);
const nitroPath = getRuntimePath(
// Imports write `.hab`; a pre-existing legacy `.nitro` for the same
// classname still counts as "already present" so a colour variant sharing a
// base classname never overwrites a bundle that is already on disk.
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
`${safeNitroName}.nitro`,
safeNitroName,
);
const nitroExists = () =>
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
const resolveNitro = () =>
resolveBundleInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
const iconPath = getRuntimePath(
/*turbopackIgnore: true*/ iconDir,
`${iconFileName}_icon.png`,
@@ -733,7 +749,7 @@ export async function importSingleFurni(params: {
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
if (params.providedNitro && !existsSync(nitroPath)) {
if (params.providedNitro && !nitroExists()) {
await fs.writeFile(
nitroPath,
await toWebpLosslessOrKeep(params.providedNitro, warnings),
@@ -801,9 +817,9 @@ export async function importSingleFurni(params: {
// disk — this happens for colour variants that share the base classname's
// nitro file. The SWF is only used for Nitro conversion (not needed) and
// icon extraction (the nitro bundle is used as a fallback for that below).
existsSync(/*turbopackIgnore: true*/ nitroPath) ||
nitroExists() ||
params.providedNitro ||
(params.preserveAssets && existsSync(nitroPath))
(params.preserveAssets && nitroExists())
? Promise.resolve(false)
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
? Promise.resolve(true)
@@ -817,11 +833,7 @@ export async function importSingleFurni(params: {
// ── Nitro bundle fallback: try downloading pre-made .nitro ────────
// Only relevant when the SWF download failed, so it runs after SWF.
let nitroDownloadOk = false;
if (
!swfOk &&
sourceNitroBaseUrl &&
!existsSync(/*turbopackIgnore: true*/ nitroPath)
) {
if (!swfOk && sourceNitroBaseUrl && !nitroExists()) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true;
@@ -858,8 +870,8 @@ export async function importSingleFurni(params: {
!swfOk &&
!nitroDownloadOk &&
!params.providedNitro &&
!existsSync(/*turbopackIgnore: true*/ nitroPath) &&
!(params.preserveAssets && existsSync(nitroPath))
!nitroExists() &&
!(params.preserveAssets && nitroExists())
)
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
@@ -867,8 +879,8 @@ export async function importSingleFurni(params: {
await onProgress?.("converting");
let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
warnings.push("Nitro file already exists (shared across color variants)");
if (nitroExists()) {
warnings.push("Bundle file already exists (shared across color variants)");
} else {
try {
if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
@@ -897,7 +909,7 @@ export async function importSingleFurni(params: {
}
}
if (!existsSync(nitroPath) && params.resolveMissingNitro) {
if (!nitroExists() && params.resolveMissingNitro) {
await onProgress?.("checking_sources");
const recovered = await params.resolveMissingNitro(rev);
if (recovered)
@@ -912,9 +924,11 @@ export async function importSingleFurni(params: {
);
}
if (!iconOk && existsSync(nitroPath)) {
if (!iconOk && nitroExists()) {
try {
const icon = extractFurniIconPng(await fs.readFile(nitroPath));
const icon = extractFurniIconPng(
await fs.readFile(resolveNitro() ?? nitroPath),
);
if (icon) {
await fs.writeFile(iconPath, icon);
iconOk = true;
@@ -929,7 +943,7 @@ export async function importSingleFurni(params: {
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare
// Nitro bundle header is larger than that, anything smaller is corrupt/empty).
const nitroStat = await fs
.stat(/*turbopackIgnore: true*/ nitroPath)
.stat(resolveNitro() ?? nitroPath)
.catch(() => null);
const nitroValid = nitroStat && nitroStat.size >= 128;
@@ -939,7 +953,7 @@ export async function importSingleFurni(params: {
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
);
try {
await fs.unlink(nitroPath);
await fs.unlink(resolveNitro() ?? nitroPath);
} catch {
/* ignore */
}
@@ -1003,8 +1017,8 @@ export async function importSingleFurni(params: {
warnings,
),
mirrorFileToDirs(
nitroPath,
`${safeNitroName}.nitro`,
resolveNitro() ?? nitroPath,
`${safeNitroName}${LOCAL_BUNDLE_EXT}`,
mirrorNitroDirs,
warnings,
),
@@ -1077,11 +1091,11 @@ export async function importSingleFurni(params: {
let nitroJsonForModes: Record<string, unknown> | null = null;
try {
const parsed = parseNitroBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
await fs.readFile(resolveNitro() ?? nitroPath),
);
nitroJsonForModes = parsed.json as Record<string, unknown>;
} catch {
// no .nitro JSON available — keyword fallback only
// no bundle JSON available — keyword fallback only
}
const autoInteraction = autoDetectInteraction(classname, bestName, {
...realFlags,
@@ -1257,17 +1271,15 @@ async function realNitroModes(classname: string): Promise<number | undefined> {
const cached = nitroModesCache.get(base);
if (cached !== undefined) return cached;
try {
const nitroPath = getRuntimePath(
const nitroPath = resolveBundleInDir(
/*turbopackIgnore: true*/ FURNI_NITRO_DIR,
`${base}.nitro`,
base,
);
if (!existsSync(nitroPath)) {
if (!nitroPath) {
nitroModesCache.set(base, undefined);
return undefined;
}
const parsed = parseNitroBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
);
const parsed = parseNitroBundle(await fs.readFile(nitroPath));
const modes = nitroAnimationStatesCount(
parsed.json as Record<string, unknown>,
);
@@ -1481,11 +1493,13 @@ export async function deleteImportedItem(classname: string): Promise<{
errors.push(`FurnitureData.json: ${(err as Error).message}`);
}
// 4. Delete files (.nitro, .swf, icon)
// 4. Delete files (bundle — `.hab` and any legacy `.nitro` — plus .swf, icon)
const filesToDelete = [
getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`,
...[nitroDir, ...assetTargets.mirrorDirs.map((d) => d.nitroDir)].flatMap(
(dir) =>
bundleFileNames(baseClassname).map((name) =>
getRuntimePath(/*turbopackIgnore: true*/ dir, name),
),
),
getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
getRuntimePath(
@@ -1493,10 +1507,6 @@ export async function deleteImportedItem(classname: string): Promise<{
`${iconFileName}_icon.png`,
),
...assetTargets.mirrorDirs.flatMap((dirs) => [
getRuntimePath(
/*turbopackIgnore: true*/ dirs.nitroDir,
`${baseClassname}.nitro`,
),
getRuntimePath(
/*turbopackIgnore: true*/ dirs.swfDir,
`${baseClassname}.swf`,
@@ -1541,12 +1551,8 @@ export async function cleanupConvertedSwfs(): Promise<{
for (const swfFile of swfFiles) {
const cn = swfFile.replace(".swf", "");
const nitroFilePath = getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${cn}.nitro`,
);
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
if (bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, cn)) {
try {
await fs.unlink(
getRuntimePath(/*turbopackIgnore: true*/ swfDir, swfFile),
+2 -1
View File
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { queryRows } from "@/lib/db";
import { bundleFileNames } from "@/lib/furni/bundle-file";
import { normalizeClassname } from "@/lib/furni/classname";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -111,7 +112,7 @@ export async function inspectFurniture(
const [nitro, icon] = await Promise.all([
assetCandidates(
dirs.nitroDir,
variants.map((name) => `${name.split("*")[0]}.nitro`),
variants.flatMap((name) => bundleFileNames(name.split("*")[0])),
),
assetCandidates(
dirs.iconDir,
+6 -2
View File
@@ -1,6 +1,8 @@
import { bundleFileNames } from "@/lib/furni/bundle-file";
/**
* Classification of `items_base` rows so the catalog audit and repairs know
* exactly which items are expected to ship normal furni assets (`.nitro`
* exactly which items are expected to ship normal furni assets (`.hab`
* bundles + `_icon.png` files) and which ones are special:
*
* - **badge** — stored with type `b`. Badges use album1584 `.gif` files and
@@ -151,7 +153,9 @@ export function assetNameCandidates(
}
return {
nitro: unique(bases).map((n) => `${n}.nitro`),
// Both bundle extensions per base — `.hab` first, legacy `.nitro` second,
// so an item still resolves when only the older file is on disk.
nitro: unique(bases).flatMap((n) => bundleFileNames(n)),
icon: unique(bases).map((n) => `${n}_icon.png`),
};
}
+36
View File
@@ -680,6 +680,42 @@ describe("repairBrokenNitros", () => {
expect(downloadFileFn).not.toHaveBeenCalled();
});
// `.hab` is accepted as a bundle name now, so it has to clear exactly the
// same traversal guard as `.nitro` — stripping an extension is not a check.
it("rejects unsafe `.hab` file names without downloading", async () => {
const result = await repairBrokenNitros(["../evil.hab"]);
expect(result.repaired).toBe(0);
expect(result.failed).toBe(1);
expect(downloadFileFn).not.toHaveBeenCalled();
});
it("re-downloads a broken `.hab` and writes it back under the same name", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
downloadFileFn.mockResolvedValue({ ok: true, size: 512 });
const result = await repairBrokenNitros(["broken_thing.hab"]);
expect(result.repaired).toBe(1);
// Sources still serve `.nitro`; the local file keeps its own name.
expect(downloadFileFn).toHaveBeenCalledWith(
"https://src.test/nitro/broken_thing.nitro",
expect.any(String),
expect.objectContaining({ validate: "nitro" }),
);
expect(writeFileFn).toHaveBeenCalledWith(
expect.stringContaining("/a/nitro/broken_thing.hab"),
expect.any(Buffer),
);
});
it("fails when no source can provide the bundle", async () => {
downloadFileFn.mockResolvedValue({ ok: false, size: 0, error: "HTTP 404" });
+14 -3
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { stripBundleExt } from "@/lib/furni/bundle-file";
import {
DEFAULT_NITRO_SOURCES,
listSources,
@@ -105,7 +106,11 @@ export interface NitroAutoCleanResult extends NitroCleanupDeleteResult {
skippedBroken: number;
}
const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.nitro$/i;
// Both bundle extensions: `.hab` is what imports write today, `.nitro` is the
// legacy name still covering the bulk of the on-disk asset set. Both must be
// scanned — matching only one would leave the other invisible to the fake /
// broken classification and to the nightly auto-clean.
const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.(?:nitro|hab)$/i;
const SWF_FILE_RE = /^[a-z0-9_*\-.]+\.swf$/i;
const ICON_FILE_RE = /^[a-z0-9_*\-.]+\.(?:gif|png)$/i;
@@ -795,14 +800,20 @@ export async function repairBrokenNitros(
await runPool(uniqueFileNames, 6, async (rawName) => {
const fileName = String(rawName ?? "");
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
// `stripBundleExt` only peels the extension — it does not make the stem
// safe to join into a path. The character-class regex is what rejects
// traversal payloads like `../evil.nitro` before any download runs.
const name =
NITRO_FILE_RE.test(fileName) && stripBundleExt(fileName) !== null
? stripBundleExt(fileName)
: null;
if (name === null) {
const message = `Invalid nitro file name: ${fileName}`;
errors.push(message);
files.push({ fileName, ok: false, error: message });
finish(fileName, false, message);
return;
}
const name = fileName.slice(0, -".nitro".length);
const candidates = [
name,
...(name.includes("*")
+4 -3
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { getRuntimePath } from "@/lib/utils/runtime-path";
@@ -54,8 +55,8 @@ const iconCache = new Map<string, Buffer | null>();
function findNitro(lib: string): string | null {
for (const dir of PET_DIRS) {
const p = getRuntimePath(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
if (existsSync(/*turbopackIgnore: true*/ p)) return p;
const p = resolveBundleInDir(/*turbopackIgnore: true*/ dir, lib);
if (p) return p;
}
return null;
}
+47 -6
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
@@ -17,28 +17,40 @@ vi.mock("@/lib/services/swf-to-nitro", () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}));
// `vi.mock` factories are hoisted, so these handles have to exist before the
// module body runs or the fs factory hits the temporal dead zone.
const { writeFile, unlink, exists } = vi.hoisted(() => ({
writeFile: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
exists: vi.fn((_: string) => false),
}));
vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs");
return {
...real,
existsSync: vi.fn(() => false),
existsSync: exists,
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from("CWS")),
writeFile: vi.fn(async () => {}),
writeFile,
mkdir: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
unlink,
},
};
});
import { importSinglePet } from "./pet-import";
import { deleteImportedPet, importSinglePet } from "./pet-import";
describe("pet-import.importSinglePet", () => {
it("downloads, converts, writes .nitro", async () => {
it("downloads, converts, writes .hab", async () => {
const r = await importSinglePet({ lib: "dog" });
expect(r.ok).toBe(true);
expect(convertSwfToNitro).toHaveBeenCalled();
// Pets follow the same convention as furniture: `.hab` on disk.
expect(writeFile).toHaveBeenCalledWith(
expect.stringContaining("dog.hab"),
expect.any(Buffer),
);
});
it("fails cleanly when conversion throws", async () => {
@@ -50,3 +62,32 @@ describe("pet-import.importSinglePet", () => {
expect(r.error).toContain("conversion");
});
});
describe("pet-import.deleteImportedPet", () => {
beforeEach(() => {
writeFile.mockClear();
unlink.mockClear();
exists.mockReset();
});
it("reports nothing deleted when the pet was never imported", async () => {
exists.mockReturnValue(false);
await expect(deleteImportedPet("dog")).resolves.toEqual({
deletedFile: false,
});
expect(unlink).not.toHaveBeenCalled();
});
// A re-import writes `.hab`; leaving a legacy `.nitro` behind would let the
// client pick up the stale bundle, so both are cleared.
it("clears both the `.hab` and the legacy `.nitro`", async () => {
exists.mockReturnValue(true);
await expect(deleteImportedPet("dog")).resolves.toEqual({
deletedFile: true,
});
expect(unlink).toHaveBeenCalledWith(expect.stringContaining("dog.hab"));
expect(unlink).toHaveBeenCalledWith(expect.stringContaining("dog.nitro"));
});
});
+22 -12
View File
@@ -1,4 +1,9 @@
import { existsSync, promises as fs } from "node:fs";
import {
bundleExistsInDir,
bundleWritePathInDir,
existingBundlePaths,
} from "@/lib/furni/bundle-file";
import { downloadFile } from "@/lib/services/import/core/download";
import { CANONICAL_PETS, resolvePetSwfUrl } from "@/lib/services/pet-source";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -35,11 +40,14 @@ export async function ensurePetDirs(): Promise<void> {
);
}
// Imports write `.hab`, the extension the client requests. A pet whose bundle
// predates that (`.nitro`) still counts as imported until it is re-imported.
function nitroPathFor(lib: string): string {
return getRuntimePath(
/*turbopackIgnore: true*/ PET_NITRO_DIR,
`${lib}.nitro`,
);
return bundleWritePathInDir(/*turbopackIgnore: true*/ PET_NITRO_DIR, lib);
}
function petBundleExists(lib: string): boolean {
return bundleExistsInDir(/*turbopackIgnore: true*/ PET_NITRO_DIR, lib);
}
export async function importSinglePet(params: {
@@ -99,19 +107,23 @@ export async function importSinglePet(params: {
export async function deleteImportedPet(
lib: string,
): Promise<{ deletedFile: boolean }> {
const p = nitroPathFor(lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
// Remove both conventions so a re-import cannot resurrect a stale bundle.
const paths = existingBundlePaths(
[/*turbopackIgnore: true*/ PET_NITRO_DIR],
lib,
);
if (paths.length === 0) return { deletedFile: false };
for (const p of paths) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
return { deletedFile: true };
}
return { deletedFile: false };
return { deletedFile: true };
}
export function getPetList(search: string): PetListItem[] {
const term = search.trim().toLowerCase();
const list = CANONICAL_PETS.map((lib) => ({
lib,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
nitroExists: petBundleExists(lib),
}));
return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list;
}
@@ -121,9 +133,7 @@ export function getPetStats(): {
imported: number;
missing: number;
} {
const imported = CANONICAL_PETS.filter((lib) =>
existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
).length;
const imported = CANONICAL_PETS.filter((lib) => petBundleExists(lib)).length;
return {
total: CANONICAL_PETS.length,
imported,
+4 -3
View File
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { queryRows } from "@/lib/db";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { logServerError } from "@/lib/server-log";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import {
@@ -263,13 +264,13 @@ export async function repairMissingIcons(
}
}
// 2) Extract from a local .nitro bundle (checked across every target).
// 2) Extract from a local bundle (checked across every target).
if (!ok) {
for (const n of names) {
let foundNitro = false;
for (const dir of nitroDirs) {
const nitroPath = getRuntimePath(dir, `${n}.nitro`);
if (!existsSync(nitroPath)) continue;
const nitroPath = resolveBundleInDir(dir, n);
if (!nitroPath) continue;
foundNitro = true;
try {
const nitroBuf = await fs.readFile(nitroPath);
+12 -10
View File
@@ -2,6 +2,8 @@ import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { queryRows } from "@/lib/db";
import { bundleFileNames, stripBundleExt } from "@/lib/furni/bundle-file";
import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
import {
type CloneSource,
DEFAULT_NITRO_SOURCES,
@@ -218,8 +220,9 @@ export async function repairMissingNitros(
return { status: "skipped" } as const;
}
const names = classnameCandidates(item);
const fileNameFor = (n: string) => `${n}.nitro`;
const fileNames = names.map(fileNameFor);
// Locally a bundle is either `.hab` (what repairs write) or a legacy
// `.nitro`; both count as "the asset is already here".
const fileNames = names.flatMap((n) => bundleFileNames(n));
const presentDirs = nitroDirs.filter((dir) =>
fileNames.some((f) => existsSync(getRuntimePath(dir, f))),
@@ -264,11 +267,9 @@ export async function repairMissingNitros(
for (const n of names) {
const nitroCandidates = [
fileNameFor(n),
...(n.includes("*")
? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))]
: []),
...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []),
n,
...(n.includes("*") ? [n.split("*")[0], n.replace(/\*/g, "_")] : []),
...(n.includes(".") ? [n.replace(/\./g, "_")] : []),
];
for (const source of sources) {
@@ -288,10 +289,11 @@ export async function repairMissingNitros(
// anywhere (mirrors clone-import.ts).
const buffer = await fs.readFile(tmpPath);
parseNitroBundle(buffer);
// Repaired bundles are stored as `.hab`.
const writeErrors = await writeNitroToDirs(
buffer,
missingDirs,
fileNameFor(n),
`${n}${LOCAL_BUNDLE_EXT}`,
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
@@ -317,7 +319,7 @@ export async function repairMissingNitros(
}
return {
status: "failed",
message: warnings[0] || "could not obtain .nitro",
message: warnings[0] || "could not obtain a bundle",
} as const;
};
@@ -443,7 +445,7 @@ export async function addSize32AllNitros(
try {
const names = await fs.readdir(dir);
for (const n of names) {
if (n.toLowerCase().endsWith(".nitro")) fileNames.add(n);
if (stripBundleExt(n) !== null) fileNames.add(n);
}
} catch {
/* ignore unreadable dir */
+45 -4
View File
@@ -130,7 +130,7 @@ describe("uploadSingleFurni live asset mirrors", () => {
vi.clearAllMocks();
});
it("copies the Nitro bundle and icon into the directories served by production", async () => {
it("copies the bundle and icon into the directories served by production", async () => {
const nitroBuffer = Buffer.from("nitro-bundle");
const iconBuffer = Buffer.from("png-icon");
@@ -143,11 +143,10 @@ describe("uploadSingleFurni live asset mirrors", () => {
});
expect(result.ok).toBe(true);
// Every upload is normalised to WebP Lossless, whatever the source
// bundle carried, so what lands on disk is not the raw upload.
// Uploads land as `.hab` — the extension the client requests.
await expect(
fs.readFile(
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.hab"),
),
).resolves.toEqual(Buffer.from("webp-normalised", "utf-8"));
await expect(
@@ -156,4 +155,46 @@ describe("uploadSingleFurni live asset mirrors", () => {
),
).resolves.toEqual(iconBuffer);
});
// A `.hab` and a `.nitro` are the same bundle, so the uploaded file's own
// extension only decides the name on disk — never whether it is accepted.
it("stores a `.nitro` upload as `.hab` too", async () => {
const result = await uploadSingleFurni({
classname: "chair",
name: "Chair",
itemType: "s",
nitroBuffer: Buffer.from("nitro-bundle"),
});
expect(result.ok).toBe(true);
await expect(
fs.readFile(getRuntimePath(assetTargets.nitroDir, "chair.hab")),
).resolves.toEqual(Buffer.from("webp-normalised", "utf-8"));
await expect(
fs.stat(getRuntimePath(assetTargets.nitroDir, "chair.nitro")),
).rejects.toMatchObject({ code: "ENOENT" });
});
// Writing `.hab` beside a live `.nitro` would leave the client two bundles
// to choose between, so the import has to refuse instead.
it("refuses to overwrite a legacy `.nitro` already on disk", async () => {
await fs.mkdir(assetTargets.nitroDir, { recursive: true });
await fs.writeFile(
getRuntimePath(assetTargets.nitroDir, "chair.nitro"),
"existing",
);
const result = await uploadSingleFurni({
classname: "chair",
name: "Chair",
itemType: "s",
nitroBuffer: Buffer.from("nitro-bundle"),
});
expect(result.ok).toBe(false);
expect(result.error).toMatch(/chair\.nitro already exists on disk/);
await expect(
fs.readFile(getRuntimePath(assetTargets.nitroDir, "chair.nitro")),
).resolves.toEqual(Buffer.from("existing"));
});
});
+20 -7
View File
@@ -1,8 +1,12 @@
import { existsSync, promises as fs } from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import {
bundleWritePathInDir,
resolveBundleInDir,
} from "@/lib/furni/bundle-file";
import { logServerError } from "@/lib/server-log";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
@@ -293,26 +297,35 @@ export async function uploadSingleFurni(params: {
if (!isLosslessWebp(parsed.texture))
warnings.push("texture converted to WebP Lossless");
} catch {
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
return { ok: false, classname, warnings, error: "invalid bundle" };
}
await ensureDirectories();
const assetTargets = await getFurniAssetWriteTargets();
const { iconDir, nitroDir } = assetTargets;
const nitroFileName = `${classname}.nitro`;
const nitroPath = getRuntimePath(
// Bundles are stored as `<classname>.hab` — the extension this deployment's
// client requests. The uploaded file may arrive named `.hab` or `.nitro`;
// parsing above is format-based, so both are the same bundle. A legacy
// `.nitro` left on disk still counts as a collision, otherwise the item
// would end up served by whichever the client happens to ask for first.
const existingBundle = resolveBundleInDir(
/*turbopackIgnore: true*/ nitroDir,
nitroFileName,
classname,
);
if (existsSync(nitroPath)) {
if (existingBundle) {
return {
ok: false,
classname,
warnings,
error: ".nitro file already exists on disk",
error: `${path.basename(existingBundle)} already exists on disk`,
};
}
const nitroFileName = path.basename(bundleWritePathInDir("", classname));
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
classname,
);
await fs.writeFile(nitroPath, storedNitro);
let iconFileName: string | null = null;
+12 -12
View File
@@ -4740,8 +4740,8 @@
"GenerateSQL": "Generate SQL",
"Customparams": "Custom params",
"Files": "Files",
"nitrofile": ".nitro file *",
"Thenitrobundlefilemax50MB": "The .nitro bundle file (max 50MB)",
"nitrofile": ".hab file *",
"Thenitrobundlefilemax50MB": "The furniture bundle file (max 50MB). Stored as .hab; a .nitro is accepted too.",
"IconPNG": "Icon (PNG)",
"Optional64x64PNGiconmax2MBLeaveemptytoskip": "Optional 64x64 PNG icon (max 2MB). Leave empty to skip.",
"ImportSuccessful": "Import Successful",
@@ -4780,14 +4780,14 @@
"Noitems": "No items.",
"Deleteall": "Delete all",
"errorClassname": "Classname required",
"errorNitroRequired": ".nitro file required",
"errorNitroSize": ".nitro file must be under 50MB"
"errorNitroRequired": ".hab file required",
"errorNitroSize": ".hab file must be under 50MB"
}
}
},
"furniDescription": "Import furniture definitions, SWF files, and icons.",
"upload": "Upload .nitro",
"uploadDescription": "Upload .nitro bundles and icons directly without SWF conversion.",
"upload": "Upload .hab",
"uploadDescription": "Upload .hab bundles and icons directly without SWF conversion.",
"clothing": {
"importClothingClient": {
"libraries": "libraries",
@@ -5598,7 +5598,7 @@
"1Select2Review3Import": "1. Select → 2. Review → 3. Import",
"Source": "Source:",
"Destination": "Destination:",
"localcatalogfurnidatanitroandicons": "local catalog, furnidata, .nitro and icons",
"localcatalogfurnidatanitroandicons": "local catalog, furnidata, bundle and icons",
"Translation`": "Translation:",
"Selection": "Selection:",
"furnitureitems": "furniture items",
@@ -5650,8 +5650,8 @@
"Scanthenitrodirectoriesforfakebundlesnomatchingfurnitureandbrokenbundlesunparseableandremovethem": "Scan the nitro directories for fake bundles (no matching furniture) and broken bundles (unparseable) and remove them.",
"RepairMissingIcons": "Repair Missing Icons",
"ScanallexistingfurnitureanddownloadanymissingiconfilesIconsareextractedfromlocalnitrobundlesorfetchedfromconfiguredclonesources": "Scan all existing furniture and download any missing icon files. Icons are extracted from local .nitro bundles or fetched from configured clone sources.",
"UploadnitroFurniture": "Upload .nitro Furniture",
"UploadanitrobundleandoptionalicontoimportfurnitureintothehotelThedatabaseentrycatalogitemandFurnitureDatajsonwillbeupdatedautomatically": "Upload a .nitro bundle and optional icon to import furniture into the hotel. The database entry, catalog item, and FurnitureData.json will be updated automatically.",
"UploadnitroFurniture": "Upload .hab Furniture",
"UploadanitrobundleandoptionalicontoimportfurnitureintothehotelThedatabaseentrycatalogitemandFurnitureDatajsonwillbeupdatedautomatically": "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.",
"NewBanner": "New Banner",
"Title": "Title",
"Subtitle": "Subtitle",
@@ -5715,7 +5715,7 @@
"Price": "Price",
"credits`": "credits",
"Importaddtocatalog": "Import & add to catalog",
"ThisfurniisintheDBbutthenitrofileismissing": "This furni is in the DB but the .nitro file is missing.",
"ThisfurniisintheDBbutthenitrofileismissing": "This furni is in the DB but its bundle file is missing.",
"Regeneratenitro": "Regenerate .nitro",
"Editnitro": "Edit nitro",
"Deleteimportedfurni": "Delete imported furni",
@@ -6743,11 +6743,11 @@
"available": "File found; content will be verified during import",
"recover": "Attach original file and resume",
"recoveryHint": "Choose {file} for this furniture (maximum 50 MB). Its identity is checked before use. Resuming retries the failed items and preserves completed imports.",
"attach": "Choose the original .nitro file",
"attach": "Choose the original .hab file",
"working": "Validating file or resuming import...",
"attached": "Matching original file attached",
"resume": "Resume failed imports with this file",
"invalidFile": "Could not attach the file. Select a valid original .nitro for this furniture, up to 50 MB, and try again.",
"invalidFile": "Could not attach the file. Select a valid original .hab for this furniture, up to 50 MB, and try again.",
"resumeError": "Could not resume. Refresh the history and use the latest retry; check the original file and try again.",
"mismatchedFile": "This file belongs to different furniture.",
"alreadyStarted": "Recovery already started. Refresh the history and use the latest retry.",