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) ### Requirements (host)
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables). - Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (`.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` ### 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 attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = { const file = {
name: "fixture_chair.nitro", name: "fixture_chair.hab",
mimeType: "application/octet-stream", mimeType: "application/octet-stream",
buffer: Buffer.from("isolated upload fixture"), 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('name="classname"');
expect(route.request().postData()).toContain("fixture_chair"); expect(route.request().postData()).toContain("fixture_chair");
expect(route.request().postData()).toContain( expect(route.request().postData()).toContain(
'filename="fixture_chair.nitro"', 'filename="fixture_chair.hab"',
); );
await route.fulfill({ json: { ok: true, attachmentId } }); 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 new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ json: { ok: true } }); 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( await expect(
page.getByText("Matching original file attached", { exact: true }), page.getByText("Matching original file attached", { exact: true }),
).toBeVisible(); ).toBeVisible();
@@ -94,9 +94,7 @@ for (const scenario of [
}) })
: route.abort("failed"), : route.abort("failed"),
); );
await page await page.getByLabel("Choose the original .hab file").setInputFiles(file);
.getByLabel("Choose the original .nitro file")
.setInputFiles(file);
await expect(page.getByRole("alert")).toContainText(scenario.message); await expect(page.getByRole("alert")).toContainText(scenario.message);
await expect( await expect(
page.getByRole("button", { page.getByRole("button", {
+4 -4
View File
@@ -1,9 +1,10 @@
import "./load-env"; import "./load-env";
import { createHash } from "node:crypto"; 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 path from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
import { readFurniData } from "@/lib/services/furni-data"; import { readFurniData } from "@/lib/services/furni-data";
const ICON_DIR = path.join( const ICON_DIR = path.join(
@@ -191,11 +192,10 @@ async function main(): Promise<void> {
); );
let nitroMissing = 0; let nitroMissing = 0;
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
for (const cls of catalogClasses) { 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(); await db.$client.end();
process.exit(0); 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="text-sm font-medium">Files</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="nitro">.nitro file *</Label> <Label htmlFor="nitro">.hab file *</Label>
<Input <Input
ref={nitroRef} ref={nitroRef}
id="nitro" id="nitro"
type="file" type="file"
accept=".nitro" accept=".hab,.nitro"
onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)} onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)}
required required
/> />
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
+2 -2
View File
@@ -13,10 +13,10 @@ export default async function StudioUploadPage(_props: {
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture Upload .hab Furniture
</h1> </h1>
<p className="mt-1 text-muted-foreground"> <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 hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically. be updated automatically.
</p> </p>
@@ -4,6 +4,10 @@ import { eq } from "drizzle-orm";
import { apiError } from "@/lib/api"; import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import {
bundleExistsInDir,
bundleWritePathInDir,
} from "@/lib/furni/bundle-file";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { import {
@@ -103,12 +107,12 @@ export const POST = withAdmin(
starIdx !== -1 starIdx !== -1
? item.classname.substring(0, starIdx) ? item.classname.substring(0, starIdx)
: item.classname; : item.classname;
const nitroPath = path.join( if (
/*turbopackIgnore: true*/ nitroDir, bundleExistsInDir(
`${baseClassname}.nitro`, /*turbopackIgnore: true*/ nitroDir,
); baseClassname,
)
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { ) {
skipped++; skipped++;
send({ send({
type: "regen_progress", type: "regen_progress",
@@ -203,6 +207,10 @@ export const POST = withAdmin(
swfBuffer, swfBuffer,
baseClassname, baseClassname,
); );
const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir,
baseClassname,
);
await fs.writeFile(nitroPath, conversion.bundle); await fs.writeFile(nitroPath, conversion.bundle);
// Build FurnitureData entry if missing // Build FurnitureData entry if missing
@@ -1,10 +1,10 @@
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 { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -25,7 +25,7 @@ function baseClassname(classname: string): string {
return classname.includes("*") ? classname.split("*")[0] : classname; return classname.includes("*") ? classname.split("*")[0] : classname;
} }
// ── GET: Read .nitro metadata ────────────────────────────────────── // ── GET: Read bundle metadata ──────────────────────────────────────
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
@@ -35,13 +35,12 @@ export const GET = withAdmin(
const base = baseClassname(rawClassname); const base = baseClassname(rawClassname);
const { nitroDir } = await getFurniAssetDirs(); const { nitroDir } = await getFurniAssetDirs();
const nitroPath = path.join( const nitroPath = resolveBundleInDir(
/*turbopackIgnore: true*/ nitroDir, /*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`, base,
); );
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) if (!nitroPath) return apiError("Bundle file not found", 404);
return apiError("Nitro file not found", 404);
try { try {
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath); const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
@@ -67,15 +66,15 @@ export const GET = withAdmin(
return apiOk({ metadata: json, flags }); return apiOk({ metadata: json, flags });
} catch (err) { } catch (err) {
console.error( console.error(
"[nitro-editor] Failed to parse .nitro:", "[nitro-editor] Failed to parse bundle:",
(err as Error).message, (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( export const PUT = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
@@ -94,13 +93,15 @@ export const PUT = withAdmin(
const base = baseClassname(rawClassname); const base = baseClassname(rawClassname);
const { nitroDir } = await getFurniAssetDirs(); 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, /*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`, base,
); );
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) if (!nitroPath) return apiError("Bundle file not found", 404);
return apiError("Nitro file not found", 404);
const saved = await withFurniDataLock(async () => { const saved = await withFurniDataLock(async () => {
const existingBuffer = await fs.readFile(nitroPath); 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 { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import {
bundleExistsInDir,
bundleWritePathInDir,
} from "@/lib/furni/bundle-file";
import { isSafeAssetName, normalizeClassname } from "@/lib/furni/classname"; import { isSafeAssetName, normalizeClassname } from "@/lib/furni/classname";
import { localFurnitureStatus } from "@/lib/furni/local-presence"; import { localFurnitureStatus } from "@/lib/furni/local-presence";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -723,13 +727,16 @@ export const PATCH = withAdmin(
const starIdx = normalised.indexOf("*"); const starIdx = normalised.indexOf("*");
const baseClassname = const baseClassname =
starIdx !== -1 ? normalised.substring(0, starIdx) : normalised; 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, /*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`, baseClassname,
); );
// Skip if .nitro already exists // Skip if a bundle already exists (`.hab` or legacy `.nitro`).
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { if (
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, baseClassname)
) {
results.push({ results.push({
classname: item.classname, classname: item.classname,
ok: true, ok: true,
@@ -27,7 +27,9 @@ export const POST = withAdmin(
(formData.get("interactionType") as string)?.trim() || "default"; (formData.get("interactionType") as string)?.trim() || "default";
const customparams = (formData.get("customparams") as string)?.trim() || ""; const customparams = (formData.get("customparams") as string)?.trim() || "";
const generateSql = formData.get("generateSql") === "true"; 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; const iconFile = formData.get("icon") as File | null;
if (!classname) return apiError("classname is required", 400); if (!classname) return apiError("classname is required", 400);
@@ -35,10 +37,10 @@ export const POST = withAdmin(
if (!itemType || !["s", "i"].includes(itemType)) if (!itemType || !["s", "i"].includes(itemType))
return apiError("itemType must be 's' or 'i'", 400); return apiError("itemType must be 's' or 'i'", 400);
if (!nitroFile || nitroFile.size === 0) 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) 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()); const nitroBuffer = Buffer.from(await nitroFile.arrayBuffer());
@@ -17,7 +17,7 @@ export const POST = withAdmin(
file.size > 50 * 1024 * 1024 file.size > 50 * 1024 * 1024
) )
return apiError( return apiError(
"Select a .nitro file for this furniture (maximum 50 MB)", "Select a .hab file for this furniture (maximum 50 MB)",
400, 400,
); );
try { try {
@@ -30,7 +30,7 @@ export const POST = withAdmin(
} catch (error) { } catch (error) {
return apiJson( return apiJson(
{ {
error: "Invalid original .nitro file", error: "Invalid original .hab file",
code: code:
error instanceof Error && error instanceof Error &&
error.message.startsWith("This bundle does not belong") 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", 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 { emptyRouteContext } from "@/test/route-context";
import { POST } from "./route"; import { POST } from "./route";
@@ -81,7 +86,13 @@ describe("furniture inspection", () => {
mocks.stat.mock.calls.map((call) => mocks.stat.mock.calls.map((call) =>
String(call[0]).replaceAll("\\", "/"), 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 () => { it("distinguishes unreadable data and denied assets from missing files", async () => {
mocks.read.mockRejectedValue(new Error("unreadable")); 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"; import { createNitroBundle, encodePng } from "@/lib/services/swf/nitro-builder";
const mocks = vi.hoisted(() => ({ read: vi.fn(), guard: vi.fn() })); 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", () => ({ vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => { withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options); mocks.guard(options);
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { import {
buildNitroCompressionReport, buildNitroCompressionReport,
inspectNitroScales, inspectNitroScales,
@@ -22,9 +22,12 @@ export const GET = withAdmin(
const base = String(classname).split("*")[0], const base = String(classname).split("*")[0],
{ nitroDir } = await getFurniAssetDirs(); { nitroDir } = await getFurniAssetDirs();
try { try {
const parsed = parseNitroBundle( const bundlePath = resolveBundleInDir(
await fs.readFile(path.join(nitroDir, `${base}.nitro`)), /*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 { json, texture } = parsed;
const image = await sharp(texture).metadata(); const image = await sharp(texture).metadata();
if (!image.width || !image.height) if (!image.width || !image.height)
@@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({
generate: vi.fn(), generate: vi.fn(),
})); }));
vi.mock("node:fs", () => ({ vi.mock("node:fs", () => ({
// `bundle-file` resolves the live bundle (`.hab`, else legacy `.nitro`)
// through existsSync before the route reads it.
existsSync: () => true,
promises: { promises: {
readFile: mocks.read, readFile: mocks.read,
writeFile: mocks.write, writeFile: mocks.write,
@@ -3,6 +3,10 @@ import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import {
bundleWritePathInDir,
resolveBundleInDir,
} from "@/lib/furni/bundle-file";
import { generateScale32 } from "@/lib/furni/nitro-scale32"; import { generateScale32 } from "@/lib/furni/nitro-scale32";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
@@ -33,7 +37,12 @@ export const POST = withAdmin(
return apiError("Invalid backup", 400); return apiError("Invalid backup", 400);
const { nitroDir } = await getFurniAssetDirs(), const { nitroDir } = await getFurniAssetDirs(),
base = body.classname.split("*")[0], 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 = () => const execute = () =>
withFurniDataLock(async () => { withFurniDataLock(async () => {
const original = await fs.readFile(file), const original = await fs.readFile(file),
@@ -1,6 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { stripBundleExt } from "@/lib/furni/bundle-file";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
const CORS_HEADERS = { const CORS_HEADERS = {
@@ -17,7 +18,7 @@ export async function GET(
if ( if (
!filename || !filename ||
filename !== path.basename(filename) || filename !== path.basename(filename) ||
!filename.endsWith(".nitro") stripBundleExt(filename) === null
) { ) {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS }); return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
} }
@@ -170,7 +170,7 @@ export function FurnitureDetailDrawer({
{!detail.nitroExists && ( {!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)]"> <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> </p>
<Button <Button
variant="outline" variant="outline"
@@ -89,13 +89,13 @@ export function ImportAttachmentRecovery({
<details className="my-2 rounded border border-[var(--admin-border)] p-2"> <details className="my-2 rounded border border-[var(--admin-border)] p-2">
<summary className="cursor-pointer">{t("recover")}</summary> <summary className="cursor-pointer">{t("recover")}</summary>
<p className="my-2 text-xs text-[var(--admin-text-muted)]"> <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> </p>
<label className="block text-xs"> <label className="block text-xs">
{t("attach")} {t("attach")}
<input <input
type="file" type="file"
accept=".nitro" accept=".hab,.nitro"
className="my-2 block max-w-full" className="my-2 block max-w-full"
disabled={disabled || busy} disabled={disabled || busy}
onChange={(event) => { onChange={(event) => {
@@ -147,7 +147,7 @@ export function ImportReview({
<strong>Source:</strong> {source} <strong>Source:</strong> {source}
</p> </p>
<p> <p>
<strong>Destination:</strong> local catalog, furnidata, .nitro and <strong>Destination:</strong> local catalog, furnidata, bundle and
icons icons
</p> </p>
<p> <p>
@@ -349,7 +349,7 @@ export function ImportReview({
<input <input
className="mt-2 block max-w-full" className="mt-2 block max-w-full"
type="file" type="file"
accept=".nitro" accept=".hab,.nitro"
disabled={busy || uploading !== null} disabled={busy || uploading !== null}
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; 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 { describe, expect, it } from "vitest";
import { import {
getBaseClassname, getBaseClassname,
getBundleFileName,
getHabboCdnIconUrl, getHabboCdnIconUrl,
getIconFileName, getIconFileName,
getLocalBundleFileNames,
getLocalIconUrl, getLocalIconUrl,
getNitroFileName, getNitroFileName,
getSafeClassnameStem, getSafeClassnameStem,
getSwfFileName, getSwfFileName,
isValidClassname, isValidClassname,
normalizeClassname, normalizeClassname,
SOURCE_BUNDLE_EXT,
} from "./classname"; } from "./classname";
describe("getBaseClassname", () => { describe("getBaseClassname", () => {
@@ -36,8 +39,19 @@ describe("file name helpers", () => {
it("builds swf file names from the base classname", () => { it("builds swf file names from the base classname", () => {
expect(getSwfFileName("sofa_blue*")).toBe("sofa_blue.swf"); expect(getSwfFileName("sofa_blue*")).toBe("sofa_blue.swf");
}); });
it("builds nitro file names from the base classname", () => { it("builds the primary (`.hab`) bundle name from the base classname", () => {
expect(getNitroFileName("sofa_blue*")).toBe("sofa_blue.nitro"); 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`; 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 { 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 { export function normalizeClassname(raw: string): string {
+3 -2
View File
@@ -1,5 +1,6 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { bundleExistsInDirs } from "@/lib/furni/bundle-file";
import { getFigureDataPath, readFigureData } from "@/lib/services/figuredata"; import { getFigureDataPath, readFigureData } from "@/lib/services/figuredata";
import { import {
getFigureMapPath, getFigureMapPath,
@@ -151,10 +152,10 @@ function existsIn(dirs: string[], fileName: string): boolean {
return dirs.some((dir) => existsSync(path.join(dir, fileName))); 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> { export async function hasNitroBundle(classname: string): Promise<boolean> {
const { nitroDirs } = await assetDirs(); 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. */ /** 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", "ProductData.json",
]; ];
const TABLES = ["items_base", "catalog_pages", "catalog_items"] as const; 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("`", "``")}\``; const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
export async function createCatalogSnapshot( export async function createCatalogSnapshot(
@@ -75,13 +79,13 @@ export async function createCatalogSnapshot(
await addTree( await addTree(
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled"), path.join(getPublicAssetRoot(), "public/nitro-assets/bundled"),
"Gamedata/bundled", "Gamedata/bundled",
/\.nitro$/i, BUNDLE_FILE_RE,
); );
if (gamedata) { if (gamedata) {
await addTree( await addTree(
path.join(gamedata, "bundled"), path.join(gamedata, "bundled"),
"Gamedata/bundled", "Gamedata/bundled",
/\.nitro$/i, BUNDLE_FILE_RE,
); );
await addTree( await addTree(
path.join(gamedata, "icons"), path.join(gamedata, "icons"),
@@ -94,18 +98,18 @@ export async function createCatalogSnapshot(
/\.(png|gif|webp|jpe?g)$/i, /\.(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); await addTree(furni.iconDir, "Gamedata/icons", /\.(png|gif|webp|jpe?g)$/i);
for (const type of ["figure", "effect"]) { for (const type of ["figure", "effect"]) {
const configured = await siteSettings.get(`${type}_nitro_dir`, ""); const configured = await siteSettings.get(`${type}_nitro_dir`, "");
if (configured?.trim()) 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. // Pets currently write directly to the CMS directory.
await addTree( await addTree(
path.join(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"), path.join(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"),
"Gamedata/bundled/pet", "Gamedata/bundled/pet",
/\.nitro$/i, BUNDLE_FILE_RE,
); );
const configRoot = gamedata const configRoot = gamedata
? path.join(gamedata, "config") ? path.join(gamedata, "config")
+3 -3
View File
@@ -168,7 +168,7 @@ describe("clone-import", () => {
expect(r.skipped).toBe(true); 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(() => {}); const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
downloadFile.mockReset(); downloadFile.mockReset();
downloadFile.mockResolvedValue({ ok: true, size: 200 }); downloadFile.mockResolvedValue({ ok: true, size: 200 });
@@ -190,9 +190,9 @@ describe("clone-import", () => {
entry: { ...ENTRY, itemType: "s" }, entry: { ...ENTRY, itemType: "s" },
}); });
// (a) .nitro must be unlinked. // (a) the bundle (`.hab`) must be unlinked.
expect(fsUnlink).toHaveBeenCalledWith( expect(fsUnlink).toHaveBeenCalledWith(
expect.stringContaining("bc_sofa.nitro"), expect.stringContaining("bc_sofa.hab"),
); );
// (b) icon must also be unlinked. // (b) icon must also be unlinked.
expect(fsUnlink).toHaveBeenCalledWith( 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 { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { bundleWritePathInDir } from "@/lib/furni/bundle-file";
import { normalizeClassname } from "@/lib/furni/classname"; import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel"; import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -293,11 +294,12 @@ export async function cloneSingleFurni(params: {
await ensureDirectories(); await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs(); 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"); await onProgress?.("downloading");
const nitroPath = getRuntimePath( const nitroPath = bundleWritePathInDir(
/*turbopackIgnore: true*/ nitroDir, /*turbopackIgnore: true*/ nitroDir,
`${classname}.nitro`, classname,
); );
const iconPath = getRuntimePath( const iconPath = getRuntimePath(
/*turbopackIgnore: true*/ iconDir, /*turbopackIgnore: true*/ iconDir,
@@ -331,8 +333,8 @@ export async function cloneSingleFurni(params: {
clonedNitroJson = parsed.json as Record<string, unknown>; clonedNitroJson = parsed.json as Record<string, unknown>;
} catch { } catch {
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {}); await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
logger.warn("[clone-import] invalid .nitro bundle for", { classname }); logger.warn("[clone-import] invalid bundle for", { classname });
return { ok: false, classname, warnings, error: "invalid .nitro bundle" }; return { ok: false, classname, warnings, error: "invalid bundle" };
} }
// Hotels ship PNG textures, so normalise every clone to WebP Lossless and // 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 // 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 { existsSync, promises as fs } from "node:fs";
import {
bundleExistsInDir,
bundleWritePathInDir,
existingBundlePaths,
} from "@/lib/furni/bundle-file";
import { import {
listOfficialEffects, listOfficialEffects,
resolveEffectSwfUrl, 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> { async function nitroPathFor(lib: string): Promise<string> {
return getRuntimePath( return bundleWritePathInDir(
/*turbopackIgnore: true*/ await getEffectNitroDir(), /*turbopackIgnore: true*/ await getEffectNitroDir(),
`${lib}.nitro`, lib,
); );
} }
@@ -146,11 +153,15 @@ export async function deleteImportedEffect(
const removed = await removeEffect(idOrLib); const removed = await removeEffect(idOrLib);
let deletedFile = false; let deletedFile = false;
if (entry) { if (entry) {
const p = await nitroPathFor(entry.lib); // Clear both conventions so a re-import cannot resurrect a stale bundle.
if (existsSync(/*turbopackIgnore: true*/ p)) { const paths = existingBundlePaths(
[/*turbopackIgnore: true*/ await getEffectNitroDir()],
entry.lib,
);
for (const p of paths) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {}); await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
deletedFile = true;
} }
deletedFile = paths.length > 0;
} }
return { removed, deletedFile }; return { removed, deletedFile };
} }
@@ -169,9 +180,7 @@ export async function getEffectList(search: string): Promise<EffectListItem[]> {
const merged = base.map((e) => ({ const merged = base.map((e) => ({
...e, ...e,
present: localByLib.has(e.lib), present: localByLib.has(e.lib),
nitroExists: existsSync( nitroExists: bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, e.lib),
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${e.lib}.nitro`),
),
})); }));
return term return term
? merged.filter( ? merged.filter(
+19 -12
View File
@@ -1,4 +1,9 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import {
bundleExistsInDir,
bundleWritePathInDir,
existingBundlePaths,
} from "@/lib/furni/bundle-file";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { resolveFigureSwfUrl } from "@/lib/services/figure-source"; import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
import { listFigureLibraries } from "@/lib/services/figuremap"; 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> { async function nitroPathFor(lib: string): Promise<string> {
return getRuntimePath( return bundleWritePathInDir(
/*turbopackIgnore: true*/ await getFigureNitroDir(), /*turbopackIgnore: true*/ await getFigureNitroDir(),
`${lib}.nitro`, lib,
); );
} }
@@ -133,14 +140,18 @@ export async function importSingleFigure(params: {
export async function deleteImportedFigure( export async function deleteImportedFigure(
lib: string, lib: string,
): Promise<{ deletedFile: boolean }> { ): Promise<{ deletedFile: boolean }> {
const p = await nitroPathFor(lib); // Clear both conventions so a re-import cannot resurrect a stale bundle.
if (existsSync(/*turbopackIgnore: true*/ p)) { const paths = existingBundlePaths(
[/*turbopackIgnore: true*/ await getFigureNitroDir()],
lib,
);
if (paths.length === 0) return { deletedFile: false };
for (const p of paths) {
await fs await fs
.unlink(/*turbopackIgnore: true*/ p) .unlink(/*turbopackIgnore: true*/ p)
.catch((error) => logServerError("figure.delete_failed", error, { lib })); .catch((error) => logServerError("figure.delete_failed", error, { lib }));
return { deletedFile: true };
} }
return { deletedFile: false }; return { deletedFile: true };
} }
export async function getFigureList(params: { export async function getFigureList(params: {
@@ -163,9 +174,7 @@ export async function getFigureList(params: {
const items = filtered.slice(start, start + perPage).map((l) => ({ const items = filtered.slice(start, start + perPage).map((l) => ({
lib: l.id, lib: l.id,
revision: l.revision, revision: l.revision,
nitroExists: existsSync( nitroExists: bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, l.id),
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
),
previewType: l.parts?.[0]?.type, previewType: l.parts?.[0]?.type,
previewId: l.parts?.[0]?.id, previewId: l.parts?.[0]?.id,
})); }));
@@ -182,9 +191,7 @@ export async function getFigureStats(): Promise<{
getFigureNitroDir(), getFigureNitroDir(),
]); ]);
const imported = libs.filter((l) => const imported = libs.filter((l) =>
existsSync( bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, l.id),
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${l.id}.nitro`),
),
).length; ).length;
return { total: libs.length, imported, missing: libs.length - imported }; return { total: libs.length, imported, missing: libs.length - imported };
} }
+3 -4
View File
@@ -12,10 +12,9 @@
* regen/sync) clear everything via `clearFurniImportCache()`. * regen/sync) clear everything via `clearFurniImportCache()`.
*/ */
import { existsSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { bundleExistsInDir } from "@/lib/furni/bundle-file";
import { import {
getBaseClassname, getBaseClassname,
getSafeClassnameStem, getSafeClassnameStem,
@@ -163,12 +162,12 @@ export async function getCleanupValidStems(): Promise<Set<string>> {
return set; 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 { export function nitroExistsCached(dir: string, base: string): boolean {
const key = `${dir}/${base}`; const key = `${dir}/${base}`;
const cached = nitroPresenceCache.get(key); const cached = nitroPresenceCache.get(key);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const present = existsSync(path.join(dir, `${base}.nitro`)); const present = bundleExistsInDir(dir, base);
nitroPresenceCache.set(key, present); nitroPresenceCache.set(key, present);
return present; return present;
} }
+5 -2
View File
@@ -28,7 +28,10 @@ vi.mock("@/lib/db", async () => ({
CatalogPages: {}, CatalogPages: {},
})); }));
vi.mock("node:fs", () => ({ 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: { promises: {
stat: mocks.stat, stat: mocks.stat,
writeFile: mocks.write, writeFile: mocks.write,
@@ -113,7 +116,7 @@ describe("completion of existing furniture", () => {
}); });
expect(recover).toHaveBeenCalledWith(1); expect(recover).toHaveBeenCalledWith(1);
expect(mocks.write).toHaveBeenCalledWith( expect(mocks.write).toHaveBeenCalledWith(
expect.stringContaining("recycler_scrapbookA.nitro"), expect.stringContaining("recycler_scrapbookA.hab"),
expect.any(Buffer), expect.any(Buffer),
{ flag: "wx" }, { flag: "wx" },
); );
+51 -45
View File
@@ -4,7 +4,14 @@ import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm"; import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db"; 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 { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -449,8 +456,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
"bundled/furniture", "bundled/furniture",
); );
if (nitroDir !== nitroTarget) { if (nitroDir !== nitroTarget) {
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) => const copied = await syncDirMissingFiles(
entry.endsWith(".nitro"), nitroDir,
nitroTarget,
(entry) => stripBundleExt(entry) !== null,
); );
copiedNitros.push(...copied); copiedNitros.push(...copied);
} }
@@ -721,10 +730,17 @@ export async function importSingleFurni(params: {
/*turbopackIgnore: true*/ swfDir, /*turbopackIgnore: true*/ swfDir,
`${safeSwfName}.swf`, `${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, /*turbopackIgnore: true*/ nitroDir,
`${safeNitroName}.nitro`, safeNitroName,
); );
const nitroExists = () =>
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
const resolveNitro = () =>
resolveBundleInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
const iconPath = getRuntimePath( const iconPath = getRuntimePath(
/*turbopackIgnore: true*/ iconDir, /*turbopackIgnore: true*/ iconDir,
`${iconFileName}_icon.png`, `${iconFileName}_icon.png`,
@@ -733,7 +749,7 @@ export async function importSingleFurni(params: {
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir); const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir); const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
if (params.providedNitro && !existsSync(nitroPath)) { if (params.providedNitro && !nitroExists()) {
await fs.writeFile( await fs.writeFile(
nitroPath, nitroPath,
await toWebpLosslessOrKeep(params.providedNitro, warnings), 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 // 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 // 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). // icon extraction (the nitro bundle is used as a fallback for that below).
existsSync(/*turbopackIgnore: true*/ nitroPath) || nitroExists() ||
params.providedNitro || params.providedNitro ||
(params.preserveAssets && existsSync(nitroPath)) (params.preserveAssets && nitroExists())
? Promise.resolve(false) ? Promise.resolve(false)
: (preservingExisting || params.preserveAssets) && existsSync(swfPath) : (preservingExisting || params.preserveAssets) && existsSync(swfPath)
? Promise.resolve(true) ? Promise.resolve(true)
@@ -817,11 +833,7 @@ export async function importSingleFurni(params: {
// ── Nitro bundle fallback: try downloading pre-made .nitro ──────── // ── Nitro bundle fallback: try downloading pre-made .nitro ────────
// Only relevant when the SWF download failed, so it runs after SWF. // Only relevant when the SWF download failed, so it runs after SWF.
let nitroDownloadOk = false; let nitroDownloadOk = false;
if ( if (!swfOk && sourceNitroBaseUrl && !nitroExists()) {
!swfOk &&
sourceNitroBaseUrl &&
!existsSync(/*turbopackIgnore: true*/ nitroPath)
) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" }); const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true; if (dl.ok) nitroDownloadOk = true;
@@ -858,8 +870,8 @@ export async function importSingleFurni(params: {
!swfOk && !swfOk &&
!nitroDownloadOk && !nitroDownloadOk &&
!params.providedNitro && !params.providedNitro &&
!existsSync(/*turbopackIgnore: true*/ nitroPath) && !nitroExists() &&
!(params.preserveAssets && existsSync(nitroPath)) !(params.preserveAssets && nitroExists())
) )
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`); warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
@@ -867,8 +879,8 @@ export async function importSingleFurni(params: {
await onProgress?.("converting"); await onProgress?.("converting");
let conversionResult: ConversionResult | null = null; let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { if (nitroExists()) {
warnings.push("Nitro file already exists (shared across color variants)"); warnings.push("Bundle file already exists (shared across color variants)");
} else { } else {
try { try {
if (existsSync(/*turbopackIgnore: true*/ swfPath)) { 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"); await onProgress?.("checking_sources");
const recovered = await params.resolveMissingNitro(rev); const recovered = await params.resolveMissingNitro(rev);
if (recovered) if (recovered)
@@ -912,9 +924,11 @@ export async function importSingleFurni(params: {
); );
} }
if (!iconOk && existsSync(nitroPath)) { if (!iconOk && nitroExists()) {
try { try {
const icon = extractFurniIconPng(await fs.readFile(nitroPath)); const icon = extractFurniIconPng(
await fs.readFile(resolveNitro() ?? nitroPath),
);
if (icon) { if (icon) {
await fs.writeFile(iconPath, icon); await fs.writeFile(iconPath, icon);
iconOk = true; iconOk = true;
@@ -929,7 +943,7 @@ export async function importSingleFurni(params: {
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare // 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). // Nitro bundle header is larger than that, anything smaller is corrupt/empty).
const nitroStat = await fs const nitroStat = await fs
.stat(/*turbopackIgnore: true*/ nitroPath) .stat(resolveNitro() ?? nitroPath)
.catch(() => null); .catch(() => null);
const nitroValid = nitroStat && nitroStat.size >= 128; const nitroValid = nitroStat && nitroStat.size >= 128;
@@ -939,7 +953,7 @@ export async function importSingleFurni(params: {
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`, `Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
); );
try { try {
await fs.unlink(nitroPath); await fs.unlink(resolveNitro() ?? nitroPath);
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -1003,8 +1017,8 @@ export async function importSingleFurni(params: {
warnings, warnings,
), ),
mirrorFileToDirs( mirrorFileToDirs(
nitroPath, resolveNitro() ?? nitroPath,
`${safeNitroName}.nitro`, `${safeNitroName}${LOCAL_BUNDLE_EXT}`,
mirrorNitroDirs, mirrorNitroDirs,
warnings, warnings,
), ),
@@ -1077,11 +1091,11 @@ export async function importSingleFurni(params: {
let nitroJsonForModes: Record<string, unknown> | null = null; let nitroJsonForModes: Record<string, unknown> | null = null;
try { try {
const parsed = parseNitroBundle( const parsed = parseNitroBundle(
await fs.readFile(/*turbopackIgnore: true*/ nitroPath), await fs.readFile(resolveNitro() ?? nitroPath),
); );
nitroJsonForModes = parsed.json as Record<string, unknown>; nitroJsonForModes = parsed.json as Record<string, unknown>;
} catch { } catch {
// no .nitro JSON available — keyword fallback only // no bundle JSON available — keyword fallback only
} }
const autoInteraction = autoDetectInteraction(classname, bestName, { const autoInteraction = autoDetectInteraction(classname, bestName, {
...realFlags, ...realFlags,
@@ -1257,17 +1271,15 @@ async function realNitroModes(classname: string): Promise<number | undefined> {
const cached = nitroModesCache.get(base); const cached = nitroModesCache.get(base);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
try { try {
const nitroPath = getRuntimePath( const nitroPath = resolveBundleInDir(
/*turbopackIgnore: true*/ FURNI_NITRO_DIR, /*turbopackIgnore: true*/ FURNI_NITRO_DIR,
`${base}.nitro`, base,
); );
if (!existsSync(nitroPath)) { if (!nitroPath) {
nitroModesCache.set(base, undefined); nitroModesCache.set(base, undefined);
return undefined; return undefined;
} }
const parsed = parseNitroBundle( const parsed = parseNitroBundle(await fs.readFile(nitroPath));
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
);
const modes = nitroAnimationStatesCount( const modes = nitroAnimationStatesCount(
parsed.json as Record<string, unknown>, 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}`); 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 = [ const filesToDelete = [
getRuntimePath( ...[nitroDir, ...assetTargets.mirrorDirs.map((d) => d.nitroDir)].flatMap(
/*turbopackIgnore: true*/ nitroDir, (dir) =>
`${baseClassname}.nitro`, bundleFileNames(baseClassname).map((name) =>
getRuntimePath(/*turbopackIgnore: true*/ dir, name),
),
), ),
getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`), getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
getRuntimePath( getRuntimePath(
@@ -1493,10 +1507,6 @@ export async function deleteImportedItem(classname: string): Promise<{
`${iconFileName}_icon.png`, `${iconFileName}_icon.png`,
), ),
...assetTargets.mirrorDirs.flatMap((dirs) => [ ...assetTargets.mirrorDirs.flatMap((dirs) => [
getRuntimePath(
/*turbopackIgnore: true*/ dirs.nitroDir,
`${baseClassname}.nitro`,
),
getRuntimePath( getRuntimePath(
/*turbopackIgnore: true*/ dirs.swfDir, /*turbopackIgnore: true*/ dirs.swfDir,
`${baseClassname}.swf`, `${baseClassname}.swf`,
@@ -1541,12 +1551,8 @@ export async function cleanupConvertedSwfs(): Promise<{
for (const swfFile of swfFiles) { for (const swfFile of swfFiles) {
const cn = swfFile.replace(".swf", ""); 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 { try {
await fs.unlink( await fs.unlink(
getRuntimePath(/*turbopackIgnore: true*/ swfDir, swfFile), 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 path from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { queryRows } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { bundleFileNames } from "@/lib/furni/bundle-file";
import { normalizeClassname } from "@/lib/furni/classname"; import { normalizeClassname } from "@/lib/furni/classname";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection"; import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -111,7 +112,7 @@ export async function inspectFurniture(
const [nitro, icon] = await Promise.all([ const [nitro, icon] = await Promise.all([
assetCandidates( assetCandidates(
dirs.nitroDir, dirs.nitroDir,
variants.map((name) => `${name.split("*")[0]}.nitro`), variants.flatMap((name) => bundleFileNames(name.split("*")[0])),
), ),
assetCandidates( assetCandidates(
dirs.iconDir, 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 * 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: * bundles + `_icon.png` files) and which ones are special:
* *
* - **badge** — stored with type `b`. Badges use album1584 `.gif` files and * - **badge** — stored with type `b`. Badges use album1584 `.gif` files and
@@ -151,7 +153,9 @@ export function assetNameCandidates(
} }
return { 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`), icon: unique(bases).map((n) => `${n}_icon.png`),
}; };
} }
+36
View File
@@ -680,6 +680,42 @@ describe("repairBrokenNitros", () => {
expect(downloadFileFn).not.toHaveBeenCalled(); 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 () => { it("fails when no source can provide the bundle", async () => {
downloadFileFn.mockResolvedValue({ ok: false, size: 0, error: "HTTP 404" }); 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 { existsSync, promises as fs } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { stripBundleExt } from "@/lib/furni/bundle-file";
import { import {
DEFAULT_NITRO_SOURCES, DEFAULT_NITRO_SOURCES,
listSources, listSources,
@@ -105,7 +106,11 @@ export interface NitroAutoCleanResult extends NitroCleanupDeleteResult {
skippedBroken: number; 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 SWF_FILE_RE = /^[a-z0-9_*\-.]+\.swf$/i;
const ICON_FILE_RE = /^[a-z0-9_*\-.]+\.(?:gif|png)$/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) => { await runPool(uniqueFileNames, 6, async (rawName) => {
const fileName = String(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}`; const message = `Invalid nitro file name: ${fileName}`;
errors.push(message); errors.push(message);
files.push({ fileName, ok: false, error: message }); files.push({ fileName, ok: false, error: message });
finish(fileName, false, message); finish(fileName, false, message);
return; return;
} }
const name = fileName.slice(0, -".nitro".length);
const candidates = [ const candidates = [
name, name,
...(name.includes("*") ...(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 { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { getRuntimePath } from "@/lib/utils/runtime-path"; import { getRuntimePath } from "@/lib/utils/runtime-path";
@@ -54,8 +55,8 @@ const iconCache = new Map<string, Buffer | null>();
function findNitro(lib: string): string | null { function findNitro(lib: string): string | null {
for (const dir of PET_DIRS) { for (const dir of PET_DIRS) {
const p = getRuntimePath(/*turbopackIgnore: true*/ dir, `${lib}.nitro`); const p = resolveBundleInDir(/*turbopackIgnore: true*/ dir, lib);
if (existsSync(/*turbopackIgnore: true*/ p)) return p; if (p) return p;
} }
return null; return null;
} }
+47 -6
View File
@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
// @vitest-environment node // @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", () => ({ vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })), downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
@@ -17,28 +17,40 @@ vi.mock("@/lib/services/swf-to-nitro", () => ({
convertSwfToNitro: (...a: unknown[]) => convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)), 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) => { vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs"); const real = (await orig()) as typeof import("node:fs");
return { return {
...real, ...real,
existsSync: vi.fn(() => false), existsSync: exists,
promises: { promises: {
...real.promises, ...real.promises,
readFile: vi.fn(async () => Buffer.from("CWS")), readFile: vi.fn(async () => Buffer.from("CWS")),
writeFile: vi.fn(async () => {}), writeFile,
mkdir: vi.fn(async () => {}), mkdir: vi.fn(async () => {}),
unlink: vi.fn(async () => {}), unlink,
}, },
}; };
}); });
import { importSinglePet } from "./pet-import"; import { deleteImportedPet, importSinglePet } from "./pet-import";
describe("pet-import.importSinglePet", () => { describe("pet-import.importSinglePet", () => {
it("downloads, converts, writes .nitro", async () => { it("downloads, converts, writes .hab", async () => {
const r = await importSinglePet({ lib: "dog" }); const r = await importSinglePet({ lib: "dog" });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
expect(convertSwfToNitro).toHaveBeenCalled(); 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 () => { it("fails cleanly when conversion throws", async () => {
@@ -50,3 +62,32 @@ describe("pet-import.importSinglePet", () => {
expect(r.error).toContain("conversion"); 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 { 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 { downloadFile } from "@/lib/services/import/core/download";
import { CANONICAL_PETS, resolvePetSwfUrl } from "@/lib/services/pet-source"; import { CANONICAL_PETS, resolvePetSwfUrl } from "@/lib/services/pet-source";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; 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 { function nitroPathFor(lib: string): string {
return getRuntimePath( return bundleWritePathInDir(/*turbopackIgnore: true*/ PET_NITRO_DIR, lib);
/*turbopackIgnore: true*/ PET_NITRO_DIR, }
`${lib}.nitro`,
); function petBundleExists(lib: string): boolean {
return bundleExistsInDir(/*turbopackIgnore: true*/ PET_NITRO_DIR, lib);
} }
export async function importSinglePet(params: { export async function importSinglePet(params: {
@@ -99,19 +107,23 @@ export async function importSinglePet(params: {
export async function deleteImportedPet( export async function deleteImportedPet(
lib: string, lib: string,
): Promise<{ deletedFile: boolean }> { ): Promise<{ deletedFile: boolean }> {
const p = nitroPathFor(lib); // Remove both conventions so a re-import cannot resurrect a stale bundle.
if (existsSync(/*turbopackIgnore: true*/ p)) { 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(() => {}); await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
return { deletedFile: true };
} }
return { deletedFile: false }; return { deletedFile: true };
} }
export function getPetList(search: string): PetListItem[] { export function getPetList(search: string): PetListItem[] {
const term = search.trim().toLowerCase(); const term = search.trim().toLowerCase();
const list = CANONICAL_PETS.map((lib) => ({ const list = CANONICAL_PETS.map((lib) => ({
lib, lib,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)), nitroExists: petBundleExists(lib),
})); }));
return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list; return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list;
} }
@@ -121,9 +133,7 @@ export function getPetStats(): {
imported: number; imported: number;
missing: number; missing: number;
} { } {
const imported = CANONICAL_PETS.filter((lib) => const imported = CANONICAL_PETS.filter((lib) => petBundleExists(lib)).length;
existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
).length;
return { return {
total: CANONICAL_PETS.length, total: CANONICAL_PETS.length,
imported, imported,
+4 -3
View File
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
import os from "node:os"; import os from "node:os";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { queryRows } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { resolveBundleInDir } from "@/lib/furni/bundle-file";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { 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) { if (!ok) {
for (const n of names) { for (const n of names) {
let foundNitro = false; let foundNitro = false;
for (const dir of nitroDirs) { for (const dir of nitroDirs) {
const nitroPath = getRuntimePath(dir, `${n}.nitro`); const nitroPath = resolveBundleInDir(dir, n);
if (!existsSync(nitroPath)) continue; if (!nitroPath) continue;
foundNitro = true; foundNitro = true;
try { try {
const nitroBuf = await fs.readFile(nitroPath); 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 os from "node:os";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { queryRows } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { bundleFileNames, stripBundleExt } from "@/lib/furni/bundle-file";
import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
import { import {
type CloneSource, type CloneSource,
DEFAULT_NITRO_SOURCES, DEFAULT_NITRO_SOURCES,
@@ -218,8 +220,9 @@ export async function repairMissingNitros(
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const names = classnameCandidates(item); const names = classnameCandidates(item);
const fileNameFor = (n: string) => `${n}.nitro`; // Locally a bundle is either `.hab` (what repairs write) or a legacy
const fileNames = names.map(fileNameFor); // `.nitro`; both count as "the asset is already here".
const fileNames = names.flatMap((n) => bundleFileNames(n));
const presentDirs = nitroDirs.filter((dir) => const presentDirs = nitroDirs.filter((dir) =>
fileNames.some((f) => existsSync(getRuntimePath(dir, f))), fileNames.some((f) => existsSync(getRuntimePath(dir, f))),
@@ -264,11 +267,9 @@ export async function repairMissingNitros(
for (const n of names) { for (const n of names) {
const nitroCandidates = [ const nitroCandidates = [
fileNameFor(n), n,
...(n.includes("*") ...(n.includes("*") ? [n.split("*")[0], n.replace(/\*/g, "_")] : []),
? [fileNameFor(n.split("*")[0]), fileNameFor(n.replace(/\*/g, "_"))] ...(n.includes(".") ? [n.replace(/\./g, "_")] : []),
: []),
...(n.includes(".") ? [fileNameFor(n.replace(/\./g, "_"))] : []),
]; ];
for (const source of sources) { for (const source of sources) {
@@ -288,10 +289,11 @@ export async function repairMissingNitros(
// anywhere (mirrors clone-import.ts). // anywhere (mirrors clone-import.ts).
const buffer = await fs.readFile(tmpPath); const buffer = await fs.readFile(tmpPath);
parseNitroBundle(buffer); parseNitroBundle(buffer);
// Repaired bundles are stored as `.hab`.
const writeErrors = await writeNitroToDirs( const writeErrors = await writeNitroToDirs(
buffer, buffer,
missingDirs, missingDirs,
fileNameFor(n), `${n}${LOCAL_BUNDLE_EXT}`,
); );
warnings.push(...writeErrors); warnings.push(...writeErrors);
ok = writeErrors.length === 0; ok = writeErrors.length === 0;
@@ -317,7 +319,7 @@ export async function repairMissingNitros(
} }
return { return {
status: "failed", status: "failed",
message: warnings[0] || "could not obtain .nitro", message: warnings[0] || "could not obtain a bundle",
} as const; } as const;
}; };
@@ -443,7 +445,7 @@ export async function addSize32AllNitros(
try { try {
const names = await fs.readdir(dir); const names = await fs.readdir(dir);
for (const n of names) { for (const n of names) {
if (n.toLowerCase().endsWith(".nitro")) fileNames.add(n); if (stripBundleExt(n) !== null) fileNames.add(n);
} }
} catch { } catch {
/* ignore unreadable dir */ /* ignore unreadable dir */
+45 -4
View File
@@ -130,7 +130,7 @@ describe("uploadSingleFurni live asset mirrors", () => {
vi.clearAllMocks(); 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 nitroBuffer = Buffer.from("nitro-bundle");
const iconBuffer = Buffer.from("png-icon"); const iconBuffer = Buffer.from("png-icon");
@@ -143,11 +143,10 @@ describe("uploadSingleFurni live asset mirrors", () => {
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
// Every upload is normalised to WebP Lossless, whatever the source // Uploads land as `.hab` — the extension the client requests.
// bundle carried, so what lands on disk is not the raw upload.
await expect( await expect(
fs.readFile( fs.readFile(
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"), getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.hab"),
), ),
).resolves.toEqual(Buffer.from("webp-normalised", "utf-8")); ).resolves.toEqual(Buffer.from("webp-normalised", "utf-8"));
await expect( await expect(
@@ -156,4 +155,46 @@ describe("uploadSingleFurni live asset mirrors", () => {
), ),
).resolves.toEqual(iconBuffer); ).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 path from "node:path";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import {
bundleWritePathInDir,
resolveBundleInDir,
} from "@/lib/furni/bundle-file";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
@@ -293,26 +297,35 @@ export async function uploadSingleFurni(params: {
if (!isLosslessWebp(parsed.texture)) if (!isLosslessWebp(parsed.texture))
warnings.push("texture converted to WebP Lossless"); warnings.push("texture converted to WebP Lossless");
} catch { } catch {
return { ok: false, classname, warnings, error: "invalid .nitro bundle" }; return { ok: false, classname, warnings, error: "invalid bundle" };
} }
await ensureDirectories(); await ensureDirectories();
const assetTargets = await getFurniAssetWriteTargets(); const assetTargets = await getFurniAssetWriteTargets();
const { iconDir, nitroDir } = assetTargets; const { iconDir, nitroDir } = assetTargets;
const nitroFileName = `${classname}.nitro`; // Bundles are stored as `<classname>.hab` — the extension this deployment's
const nitroPath = getRuntimePath( // 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, /*turbopackIgnore: true*/ nitroDir,
nitroFileName, classname,
); );
if (existsSync(nitroPath)) { if (existingBundle) {
return { return {
ok: false, ok: false,
classname, classname,
warnings, 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); await fs.writeFile(nitroPath, storedNitro);
let iconFileName: string | null = null; let iconFileName: string | null = null;
+12 -12
View File
@@ -4740,8 +4740,8 @@
"GenerateSQL": "Generate SQL", "GenerateSQL": "Generate SQL",
"Customparams": "Custom params", "Customparams": "Custom params",
"Files": "Files", "Files": "Files",
"nitrofile": ".nitro file *", "nitrofile": ".hab file *",
"Thenitrobundlefilemax50MB": "The .nitro bundle file (max 50MB)", "Thenitrobundlefilemax50MB": "The furniture bundle file (max 50MB). Stored as .hab; a .nitro is accepted too.",
"IconPNG": "Icon (PNG)", "IconPNG": "Icon (PNG)",
"Optional64x64PNGiconmax2MBLeaveemptytoskip": "Optional 64x64 PNG icon (max 2MB). Leave empty to skip.", "Optional64x64PNGiconmax2MBLeaveemptytoskip": "Optional 64x64 PNG icon (max 2MB). Leave empty to skip.",
"ImportSuccessful": "Import Successful", "ImportSuccessful": "Import Successful",
@@ -4780,14 +4780,14 @@
"Noitems": "No items.", "Noitems": "No items.",
"Deleteall": "Delete all", "Deleteall": "Delete all",
"errorClassname": "Classname required", "errorClassname": "Classname required",
"errorNitroRequired": ".nitro file required", "errorNitroRequired": ".hab file required",
"errorNitroSize": ".nitro file must be under 50MB" "errorNitroSize": ".hab file must be under 50MB"
} }
} }
}, },
"furniDescription": "Import furniture definitions, SWF files, and icons.", "furniDescription": "Import furniture definitions, SWF files, and icons.",
"upload": "Upload .nitro", "upload": "Upload .hab",
"uploadDescription": "Upload .nitro bundles and icons directly without SWF conversion.", "uploadDescription": "Upload .hab bundles and icons directly without SWF conversion.",
"clothing": { "clothing": {
"importClothingClient": { "importClothingClient": {
"libraries": "libraries", "libraries": "libraries",
@@ -5598,7 +5598,7 @@
"1Select2Review3Import": "1. Select → 2. Review → 3. Import", "1Select2Review3Import": "1. Select → 2. Review → 3. Import",
"Source": "Source:", "Source": "Source:",
"Destination": "Destination:", "Destination": "Destination:",
"localcatalogfurnidatanitroandicons": "local catalog, furnidata, .nitro and icons", "localcatalogfurnidatanitroandicons": "local catalog, furnidata, bundle and icons",
"Translation`": "Translation:", "Translation`": "Translation:",
"Selection": "Selection:", "Selection": "Selection:",
"furnitureitems": "furniture items", "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.", "Scanthenitrodirectoriesforfakebundlesnomatchingfurnitureandbrokenbundlesunparseableandremovethem": "Scan the nitro directories for fake bundles (no matching furniture) and broken bundles (unparseable) and remove them.",
"RepairMissingIcons": "Repair Missing Icons", "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.", "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", "UploadnitroFurniture": "Upload .hab 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.", "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", "NewBanner": "New Banner",
"Title": "Title", "Title": "Title",
"Subtitle": "Subtitle", "Subtitle": "Subtitle",
@@ -5715,7 +5715,7 @@
"Price": "Price", "Price": "Price",
"credits`": "credits", "credits`": "credits",
"Importaddtocatalog": "Import & add to catalog", "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", "Regeneratenitro": "Regenerate .nitro",
"Editnitro": "Edit nitro", "Editnitro": "Edit nitro",
"Deleteimportedfurni": "Delete imported furni", "Deleteimportedfurni": "Delete imported furni",
@@ -6743,11 +6743,11 @@
"available": "File found; content will be verified during import", "available": "File found; content will be verified during import",
"recover": "Attach original file and resume", "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.", "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...", "working": "Validating file or resuming import...",
"attached": "Matching original file attached", "attached": "Matching original file attached",
"resume": "Resume failed imports with this file", "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.", "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.", "mismatchedFile": "This file belongs to different furniture.",
"alreadyStarted": "Recovery already started. Refresh the history and use the latest retry.", "alreadyStarted": "Recovery already started. Refresh the history and use the latest retry.",