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);
File diff suppressed because it is too large. Load diff
@@ -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];
File diff suppressed because it is too large. Load diff
+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" },
); );
File diff suppressed because it is too large. Load diff
+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.",