From adffac7360c4a37589e75d67a6f57d7f848ac4c5 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 10 Oct 2026 17:09:36 +0200 Subject: [PATCH] feat(catalog): store furniture bundles as .hab instead of .nitro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bundle the CMS writes — upload, clone, sync, repair and the pet / effect / figure importers — now lands as `.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. --- README.md | 2 +- e2e/ui/attachment-recovery.spec.ts | 10 +- .../bundled/pet/{dog.nitro => dog.hab} | Bin scripts/furni-diagnose-now.ts | 8 +- scripts/migrate-nitro-to-hab.ts | 303 ++++++++++++++++++ .../furni/upload/upload-furni-client.tsx | 8 +- src/app/admin/studio/upload/page.tsx | 4 +- .../admin/import/furni/batch-regen/route.ts | 20 +- .../admin/import/furni/nitro-editor/route.ts | 29 +- src/app/api/admin/import/furni/route.ts | 15 +- .../api/admin/import/furni/upload/route.ts | 8 +- .../admin/studio/import-attachment/route.ts | 4 +- .../api/admin/studio/inspect/route.test.ts | 15 +- .../admin/studio/nitro-quality/route.test.ts | 7 +- .../api/admin/studio/nitro-quality/route.ts | 9 +- .../admin/studio/nitro-scale32/route.test.ts | 3 + .../api/admin/studio/nitro-scale32/route.ts | 11 +- .../bundled/furniture/[...path]/route.ts | 3 +- .../admin/studio/furniture-detail-drawer.tsx | 2 +- .../studio/import-attachment-recovery.tsx | 4 +- src/components/admin/studio/import-review.tsx | 4 +- src/lib/furni/bundle-file.test.ts | 110 +++++++ src/lib/furni/bundle-file.ts | 91 ++++++ src/lib/furni/classname.test.ts | 18 +- src/lib/furni/classname.ts | 33 +- src/lib/furni/gamedata-dedupe.ts | 5 +- src/lib/services/catalog-git-snapshot.ts | 14 +- src/lib/services/clone-import.test.ts | 6 +- src/lib/services/clone-import.ts | 12 +- src/lib/services/effect-import.ts | 25 +- src/lib/services/figure-import.ts | 31 +- src/lib/services/furni-import-cache.ts | 7 +- src/lib/services/furni-import-repair.test.ts | 7 +- src/lib/services/furni-import.ts | 96 +++--- src/lib/services/furniture-inspection.ts | 3 +- src/lib/services/item-kind.ts | 8 +- src/lib/services/nitro-cleanup.test.ts | 36 +++ src/lib/services/nitro-cleanup.ts | 17 +- src/lib/services/pet-icon.ts | 7 +- src/lib/services/pet-import.test.ts | 53 ++- src/lib/services/pet-import.ts | 34 +- src/lib/services/repair-icons.ts | 7 +- src/lib/services/repair-nitros.ts | 22 +- src/lib/services/upload-import.test.ts | 49 ++- src/lib/services/upload-import.ts | 27 +- src/messages/en.json | 24 +- 46 files changed, 1001 insertions(+), 210 deletions(-) rename public/nitro-assets/bundled/pet/{dog.nitro => dog.hab} (100%) create mode 100644 scripts/migrate-nitro-to-hab.ts create mode 100644 src/lib/furni/bundle-file.test.ts create mode 100644 src/lib/furni/bundle-file.ts diff --git a/README.md b/README.md index de49554e..56829af2 100644 --- a/README.md +++ b/README.md @@ -583,7 +583,7 @@ URLs look like `https:///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` diff --git a/e2e/ui/attachment-recovery.spec.ts b/e2e/ui/attachment-recovery.spec.ts index a52bcadd..15b9b0e2 100644 --- a/e2e/ui/attachment-recovery.spec.ts +++ b/e2e/ui/attachment-recovery.spec.ts @@ -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", { diff --git a/public/nitro-assets/bundled/pet/dog.nitro b/public/nitro-assets/bundled/pet/dog.hab similarity index 100% rename from public/nitro-assets/bundled/pet/dog.nitro rename to public/nitro-assets/bundled/pet/dog.hab diff --git a/scripts/furni-diagnose-now.ts b/scripts/furni-diagnose-now.ts index 43402953..351d1782 100644 --- a/scripts/furni-diagnose-now.ts +++ b/scripts/furni-diagnose-now.ts @@ -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 { ); 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); diff --git a/scripts/migrate-nitro-to-hab.ts b/scripts/migrate-nitro-to-hab.ts new file mode 100644 index 00000000..abaf1b32 --- /dev/null +++ b/scripts/migrate-nitro-to-hab.ts @@ -0,0 +1,303 @@ +#!/usr/bin/env tsx +/** + * Rename on-disk furniture bundles from `.nitro` to `.hab`. + * + * Imports have written `.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 { + 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 { + 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); + }); diff --git a/src/app/admin/import/furni/upload/upload-furni-client.tsx b/src/app/admin/import/furni/upload/upload-furni-client.tsx index 84924ed5..5f7d51c5 100644 --- a/src/app/admin/import/furni/upload/upload-furni-client.tsx +++ b/src/app/admin/import/furni/upload/upload-furni-client.tsx @@ -282,17 +282,19 @@ export function UploadFurniClient() {
Files
- + setNitroFile(e.target.files?.[0] ?? null)} required />

- The .nitro bundle file (max 50MB) + The furniture bundle (max 50MB). Stored as{" "} + .hab; a{" "} + .nitro is accepted too.

diff --git a/src/app/admin/studio/upload/page.tsx b/src/app/admin/studio/upload/page.tsx index 3a8ebddd..b41a24d5 100644 --- a/src/app/admin/studio/upload/page.tsx +++ b/src/app/admin/studio/upload/page.tsx @@ -13,10 +13,10 @@ export default async function StudioUploadPage(_props: {

- Upload .nitro Furniture + Upload .hab Furniture

- 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.

diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 36c8c37b..ecd0fadb 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -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 diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts index b00c35d9..51e08d6c 100644 --- a/src/app/api/admin/import/furni/nitro-editor/route.ts +++ b/src/app/api/admin/import/furni/nitro-editor/route.ts @@ -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); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 332acc83..e5224e0c 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -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, diff --git a/src/app/api/admin/import/furni/upload/route.ts b/src/app/api/admin/import/furni/upload/route.ts index 60f58a1b..fa29c2bf 100644 --- a/src/app/api/admin/import/furni/upload/route.ts +++ b/src/app/api/admin/import/furni/upload/route.ts @@ -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()); diff --git a/src/app/api/admin/studio/import-attachment/route.ts b/src/app/api/admin/studio/import-attachment/route.ts index 2b946ed3..d8ee9e96 100644 --- a/src/app/api/admin/studio/import-attachment/route.ts +++ b/src/app/api/admin/studio/import-attachment/route.ts @@ -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") diff --git a/src/app/api/admin/studio/inspect/route.test.ts b/src/app/api/admin/studio/inspect/route.test.ts index 9d1348e0..1661e6a8 100644 --- a/src/app/api/admin/studio/inspect/route.test.ts +++ b/src/app/api/admin/studio/inspect/route.test.ts @@ -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")); diff --git a/src/app/api/admin/studio/nitro-quality/route.test.ts b/src/app/api/admin/studio/nitro-quality/route.test.ts index 68ff881a..85995246 100644 --- a/src/app/api/admin/studio/nitro-quality/route.test.ts +++ b/src/app/api/admin/studio/nitro-quality/route.test.ts @@ -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); diff --git a/src/app/api/admin/studio/nitro-quality/route.ts b/src/app/api/admin/studio/nitro-quality/route.ts index 6a216b54..f6098631 100644 --- a/src/app/api/admin/studio/nitro-quality/route.ts +++ b/src/app/api/admin/studio/nitro-quality/route.ts @@ -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) diff --git a/src/app/api/admin/studio/nitro-scale32/route.test.ts b/src/app/api/admin/studio/nitro-scale32/route.test.ts index 11270549..b80d3725 100644 --- a/src/app/api/admin/studio/nitro-scale32/route.test.ts +++ b/src/app/api/admin/studio/nitro-scale32/route.test.ts @@ -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, diff --git a/src/app/api/admin/studio/nitro-scale32/route.ts b/src/app/api/admin/studio/nitro-scale32/route.ts index 1aa1b158..347918e6 100644 --- a/src/app/api/admin/studio/nitro-scale32/route.ts +++ b/src/app/api/admin/studio/nitro-scale32/route.ts @@ -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), diff --git a/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts b/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts index d988c29e..4020a09a 100644 --- a/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts +++ b/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts @@ -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 }); } diff --git a/src/components/admin/studio/furniture-detail-drawer.tsx b/src/components/admin/studio/furniture-detail-drawer.tsx index 458f4b4a..37cb63b5 100644 --- a/src/components/admin/studio/furniture-detail-drawer.tsx +++ b/src/components/admin/studio/furniture-detail-drawer.tsx @@ -170,7 +170,7 @@ export function FurnitureDetailDrawer({ {!detail.nitroExists && ( <>

- This furni is in the DB but the .nitro file is missing. + This furni is in the DB but its bundle file is missing.