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