diff --git a/src/app/admin/studio/nitro-cleanup/page.tsx b/src/app/admin/studio/nitro-cleanup/page.tsx new file mode 100644 index 00000000..c8915a4e --- /dev/null +++ b/src/app/admin/studio/nitro-cleanup/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; +import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; + +export default async function StudioNitroCleanupPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { + redirect("/admin"); + } + return ( +
+
+

Nitro cleanup

+

+ Scan the nitro directories for fake bundles (no matching furniture) + and broken bundles (unparseable) and remove them. +

+
+ +
+ ); +} diff --git a/src/app/admin/studio/studio-layout-client.tsx b/src/app/admin/studio/studio-layout-client.tsx index e9ce8fe1..fff510bd 100644 --- a/src/app/admin/studio/studio-layout-client.tsx +++ b/src/app/admin/studio/studio-layout-client.tsx @@ -7,6 +7,7 @@ import { ClipboardCheck, Copy, Dumbbell, + FileX2, Image as ImageIcon, type LucideIcon, Puzzle, @@ -52,6 +53,11 @@ const maintenance: StudioLink[] = [ label: "Catalog maintenance", icon: Wrench, }, + { + href: "/admin/studio/nitro-cleanup", + label: "Nitro cleanup", + icon: FileX2, + }, ]; const activePath = (path: string, href: string) => path === href || path.startsWith(`${href}/`); diff --git a/src/app/api/admin/studio/nitro-cleanup/route.ts b/src/app/api/admin/studio/nitro-cleanup/route.ts new file mode 100644 index 00000000..3875d573 --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/route.ts @@ -0,0 +1,70 @@ +import { apiError, apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { logAudit } from "@/lib/services/audit"; +import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; +import { + deleteNitroCleanupFiles, + scanFakeBrokenNitros, +} from "@/lib/services/nitro-cleanup"; + +/** Scan all nitro directories for fake (orphaned) and broken (unparseable) .nitro files. */ +export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { + try { + const scan = await scanFakeBrokenNitros(); + return apiOk({ + fake: scan.fake, + broken: scan.broken, + total: scan.total, + }); + } catch (err) { + return apiError(`Nitro scan failed: ${(err as Error).message}`, 500); + } +}); + +/** Delete the given .nitro files from every nitro directory (primary + mirrors). */ +export const DELETE = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (request, ctx) => { + let body: { fileNames?: unknown }; + try { + body = await request.json(); + } catch { + return apiError("Invalid JSON body", 400); + } + const fileNames = Array.isArray(body?.fileNames) + ? body.fileNames.filter( + (name): name is string => typeof name === "string", + ) + : []; + if (fileNames.length === 0) { + return apiError("fileNames[] is required", 400); + } + try { + const result = await deleteNitroCleanupFiles(fileNames); + // The deleted .nitro presence is cached by the Studio — drop it so + // the furniture list reflects the removal immediately. + clearFurniImportCache(); + if (result.deleted > 0) { + logAudit({ + userId: ctx.session.user.id, + action: "nitro_cleanup_delete", + target: "NitroFiles", + after: { + deleted: result.deleted, + copiesRemoved: result.copiesRemoved, + fileNames, + errors: result.errors.length > 0 ? result.errors : undefined, + }, + }); + } + return apiOk({ + deleted: result.deleted, + copiesRemoved: result.copiesRemoved, + errors: result.errors.length > 0 ? result.errors : undefined, + }); + } catch (err) { + return apiError(`Nitro cleanup failed: ${(err as Error).message}`, 500); + } + }, +); diff --git a/src/components/admin/studio/nitro-cleanup-panel.tsx b/src/components/admin/studio/nitro-cleanup-panel.tsx new file mode 100644 index 00000000..b908e2cb --- /dev/null +++ b/src/components/admin/studio/nitro-cleanup-panel.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { + AlertTriangle, + CheckCircle2, + FileX2, + Loader2, + ScanSearch, + Trash2, +} from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { adminFetch } from "@/lib/admin-fetch"; +import { formatBytes } from "@/lib/services/disk-usage"; + +interface NitroCleanupEntry { + fileName: string; + base: string; + size: number; + dirs: string[]; + error?: string; +} + +interface NitroCleanupScan { + fake: NitroCleanupEntry[]; + broken: NitroCleanupEntry[]; + total: number; +} + +interface DeleteResponse { + deleted: number; + copiesRemoved: number; + errors?: string[]; +} + +type GroupKey = "fake" | "broken"; + +function CleanupGroup({ + group, + label, + description, + entries, + selected, + onToggle, + onToggleAll, +}: { + group: GroupKey; + label: string; + description: string; + entries: NitroCleanupEntry[]; + selected: Set; + onToggle: (fileName: string) => void; + onToggleAll: (names: string[]) => void; +}) { + const checked = entries.filter((e) => selected.has(e.fileName)); + const allChecked = entries.length > 0 && checked.length === entries.length; + const isError = group === "fake"; + + return ( +
+
0 + ? isError + ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" + : "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" + : "border-[var(--admin-border)] bg-[var(--admin-surface)]" + }`} + > + onToggleAll(entries.map((e) => e.fileName))} + aria-label={`Select all ${label}`} + /> +
+

0 ? "text-[var(--admin-text)]" : "text-[var(--admin-text-muted)]"}`} + > + {label} ({entries.length}) +

+

+ {description} +

+
+ {checked.length > 0 && ( + + {checked.length} selected + + )} +
+ + {entries.length === 0 ? ( +

+ + No {label.toLowerCase()} found +

+ ) : ( + + )} +
+ ); +} + +export function NitroCleanupPanel() { + const [scanning, setScanning] = useState(false); + const [deleting, setDeleting] = useState(false); + const [scan, setScan] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const { confirm, dialog } = useConfirmDialog(); + + const scanNow = useCallback(async () => { + setScanning(true); + setSelected(new Set()); + try { + const res = await adminFetch("/api/admin/studio/nitro-cleanup"); + const data = (await res.json()) as NitroCleanupScan & { + error?: string; + }; + if (!res.ok) throw new Error(data.error || "Scan failed"); + const fake = data.fake ?? []; + const broken = data.broken ?? []; + setScan({ + fake, + broken, + total: data.total ?? 0, + }); + const found = fake.length + broken.length; + if (found === 0) { + toast.success("No fake or broken .nitro files found"); + } else { + toast.info(`Found ${found} removable .nitro file(s)`); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Scan failed"); + } finally { + setScanning(false); + } + }, []); + + const toggle = (fileName: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(fileName)) next.delete(fileName); + else next.add(fileName); + return next; + }); + }; + + const toggleAll = (names: string[]) => { + setSelected((prev) => { + const next = new Set(prev); + const allChecked = names.every((n) => next.has(n)); + for (const name of names) { + if (allChecked) next.delete(name); + else next.add(name); + } + return next; + }); + }; + + const removeSelected = async () => { + const fileNames = [...selected].sort(); + if (fileNames.length === 0) return; + const confirmed = await confirm({ + title: `Delete ${fileNames.length} .nitro file(s)?`, + description: + "The .nitro bundles will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", + confirmLabel: "Delete", + variant: "danger", + }); + if (!confirmed) return; + setDeleting(true); + try { + const res = await adminFetch("/api/admin/studio/nitro-cleanup", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileNames }), + }); + const data = (await res.json()) as DeleteResponse & { + error?: string; + }; + if (!res.ok) throw new Error(data.error || "Delete failed"); + if (data.errors?.length) { + toast.warning( + `Deleted ${data.copiesRemoved} copy/copies, but some files could not be removed`, + ); + } else { + toast.success( + `Deleted ${data.copiesRemoved} .nitro file copy/copies (${data.deleted} file(s))`, + ); + } + setSelected(new Set()); + await scanNow(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Delete failed"); + } finally { + setDeleting(false); + } + }; + + return ( +
+ {dialog} +
+ + {scan && ( + + )} +
+ + {scan && ( +
+
+
+
{scan.total}
+
+ .nitro files scanned +
+
+
+
+ {scan.fake.length} +
+
+ Fake (no matching item) +
+
+
+
+ {scan.broken.length} +
+
+ Broken (unparseable) +
+
+
+ + + + + {(scan.fake.length > 0 || scan.broken.length > 0) && ( +

+ + Deleting a broken .nitro leaves its item in the database without a + bundle — the catalog audit can re-download it later. Database rows + are never removed by this tool. +

+ )} + +
+ + Only .nitro files are touched — icons, SWFs, FurnitureData.json and + database rows stay intact. +
+
+ )} + + {!scan && !scanning && ( +
+ +

+ Scan every nitro directory to find fake bundles that belong to no + furniture and broken bundles that can no longer be parsed. Remove + them with one click. +

+
+ )} +
+ ); +} diff --git a/src/lib/services/nitro-cleanup.test.ts b/src/lib/services/nitro-cleanup.test.ts new file mode 100644 index 00000000..fa64b354 --- /dev/null +++ b/src/lib/services/nitro-cleanup.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type AnyFn = (...args: any[]) => any; + +const { + executeFn, + readdirFn, + readFileFn, + statFn, + unlinkFn, + existsFn, + parseNitroBundle, + getTargetsFn, +} = vi.hoisted(() => ({ + executeFn: vi.fn(async () => [ + [ + { item_name: "chair", public_name: "Chair" }, + { item_name: "broken_thing", public_name: "Broken Thing" }, + { item_name: "xmas_*1", public_name: "Xmas" }, + ], + [], + ]), + readdirFn: vi.fn(), + readFileFn: vi.fn(), + statFn: vi.fn(), + unlinkFn: vi.fn(async () => {}), + existsFn: vi.fn(() => false), + parseNitroBundle: vi.fn(), + getTargetsFn: vi.fn(async () => ({ + nitroDir: "/assets/nitro", + swfDir: "/assets/swf", + iconDir: "/assets/icons", + mirrorDirs: [], + })), +})); + +vi.mock("@/lib/db", () => ({ db: { execute: executeFn } })); +vi.mock("@/lib/services/furni-asset-dirs", () => ({ + getFurniAssetWriteTargets: getTargetsFn, +})); +vi.mock("@/lib/services/swf/nitro-builder", () => ({ parseNitroBundle })); +vi.mock("node:fs", async (orig) => { + const real = (await orig()) as typeof import("node:fs"); + return { + ...real, + promises: { + ...real.promises, + readdir: readdirFn as AnyFn, + readFile: readFileFn as AnyFn, + stat: statFn as AnyFn, + unlink: unlinkFn as AnyFn, + }, + existsSync: existsFn, + }; +}); + +import { + deleteNitroCleanupFiles, + scanFakeBrokenNitros, +} from "@/lib/services/nitro-cleanup"; + +beforeEach(() => { + vi.clearAllMocks(); + readdirFn.mockReset(); + readFileFn.mockReset(); + parseNitroBundle.mockReset(); + existsFn.mockReset(); + getTargetsFn.mockReset(); + getTargetsFn.mockResolvedValue({ + nitroDir: "/assets/nitro", + swfDir: "/assets/swf", + iconDir: "/assets/icons", + mirrorDirs: [], + }); +}); + +describe("scanFakeBrokenNitros", () => { + it("classifies fake (no matching item) and broken (unparseable) bundles", async () => { + readdirFn.mockResolvedValue([ + "chair.nitro", + "fake_stuff.nitro", + "broken_thing.nitro", + "xmas_.nitro", + ]); + readFileFn.mockImplementation(async (filePath: string) => + Buffer.from(filePath.includes("broken") ? "broken-data" : "nitro-data"), + ); + statFn.mockImplementation(async () => ({ size: 512 })); + parseNitroBundle.mockImplementation((buffer: Buffer) => { + if (buffer.toString("utf-8").startsWith("broken")) { + throw new Error("Not a valid .nitro bundle"); + } + return { + json: {}, + jsonFileName: "visualization.json", + png: Buffer.alloc(0), + pngFileName: "texture.png", + }; + }); + + const result = await scanFakeBrokenNitros(); + + expect(result.total).toBe(4); + expect(result.fake.map((e) => e.fileName)).toEqual(["fake_stuff.nitro"]); + expect(result.broken.map((e) => e.fileName)).toEqual([ + "broken_thing.nitro", + ]); + expect(result.broken[0].error).toBe("Not a valid .nitro bundle"); + }); + + it("resolves star classnames to their base nitro stem", async () => { + readdirFn.mockResolvedValue(["xmas_.nitro"]); + readFileFn.mockResolvedValue(Buffer.from("nitro-data")); + statFn.mockImplementation(async () => ({ size: 16 })); + parseNitroBundle.mockReturnValue({ + json: {}, + jsonFileName: "visualization.json", + png: Buffer.alloc(0), + pngFileName: "texture.png", + }); + + const result = await scanFakeBrokenNitros(); + + expect(result.fake).toEqual([]); + expect(result.broken).toEqual([]); + expect(result.total).toBe(1); + }); + + it("marks an unparseable bundle whose item is also missing as fake", async () => { + readdirFn.mockResolvedValue(["ghost.nitro"]); + statFn.mockImplementation(async () => ({ size: 8 })); + + const result = await scanFakeBrokenNitros(); + + expect(result.fake[0].fileName).toBe("ghost.nitro"); + expect(result.broken).toEqual([]); + }); +}); + +describe("deleteNitroCleanupFiles", () => { + beforeEach(() => { + getTargetsFn.mockResolvedValue({ + nitroDir: "/a/nitro", + swfDir: "/a/swf", + iconDir: "/a/icons", + mirrorDirs: [ + { nitroDir: "/b/nitro", swfDir: "/b/swf", iconDir: "/b/icons" }, + ], + }); + existsFn.mockImplementation((filePath: string) => + filePath.includes("chair"), + ); + }); + + it("removes each file from every directory where it exists", async () => { + const result = await deleteNitroCleanupFiles([ + "chair.nitro", + "missing.nitro", + ]); + + expect(result.deleted).toBe(1); + expect(result.copiesRemoved).toBe(2); + expect(unlinkFn).toHaveBeenCalledTimes(2); + expect(unlinkFn).toHaveBeenCalledWith("/a/nitro/chair.nitro"); + expect(unlinkFn).toHaveBeenCalledWith("/b/nitro/chair.nitro"); + expect(result.errors).toEqual([]); + const chair = result.files.find((f) => f.fileName === "chair.nitro"); + expect(chair?.deleted).toBe(true); + expect(chair?.remaining).toEqual([]); + }); + + it("keeps a file in remaining dirs when an unlink fails", async () => { + unlinkFn.mockImplementation(async (filePath: string) => { + if (filePath.startsWith("/b/")) throw new Error("locked"); + }); + + const result = await deleteNitroCleanupFiles(["chair.nitro"]); + + expect(result.deleted).toBe(0); + expect(result.copiesRemoved).toBe(1); + expect(result.errors.some((e) => e.includes("locked"))).toBe(true); + const chair = result.files.find((f) => f.fileName === "chair.nitro"); + expect(chair?.deleted).toBe(false); + expect(chair?.remaining).toEqual(["/b/nitro"]); + }); + + it("rejects unsafe file names", async () => { + const result = await deleteNitroCleanupFiles(["../evil.nitro"]); + + expect(result.deleted).toBe(0); + expect(result.errors[0]).toContain("Invalid nitro file name"); + expect(unlinkFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/services/nitro-cleanup.ts b/src/lib/services/nitro-cleanup.ts new file mode 100644 index 00000000..33807c59 --- /dev/null +++ b/src/lib/services/nitro-cleanup.ts @@ -0,0 +1,228 @@ +import { existsSync, promises as fs } from "node:fs"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { getBaseClassname, getSafeClassnameStem } from "@/lib/furni/classname"; +import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; +import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; +import { getRuntimePath } from "@/lib/utils/runtime-path"; + +/** + * Catalog Studio nitro cleanup. + * + * Scans every .nitro directory (public + mirrored gamedata) and splits the + * found bundles into two removable groups: + * + * - "fake" .nitro files whose stem does not match any item in `items_base` + * (leftover files that belong to no furniture — including items that were + * deleted or imported under a different name); + * - "broken" .nitro files that do belong to an item but cannot be parsed as a + * valid Nitro bundle (truncated, corrupted or renamed files). + * + * Deletion only touches the .nitro files on disk — never database rows. + */ + +export interface NitroCleanupEntry { + fileName: string; + base: string; + size: number; + dirs: string[]; + error?: string; +} + +export interface NitroCleanupScan { + fake: NitroCleanupEntry[]; + broken: NitroCleanupEntry[]; + total: number; +} + +export interface NitroCleanupDeleteResult { + /** Files that were fully removed from every nitro directory. */ + deleted: number; + /** Individual .nitro files removed across all directories (mirrors). */ + copiesRemoved: number; + errors: string[]; + files: Array<{ fileName: string; deleted: boolean; remaining: string[] }>; +} + +const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.nitro$/i; + +function uniqueDirs(dirs: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const dir of dirs) { + const key = process.platform === "win32" ? dir.toLowerCase() : dir; + if (seen.has(key)) continue; + seen.add(key); + out.push(dir); + } + return out; +} + +/** Sliding-window worker pool: keeps up to `concurrency` tasks in flight. */ +async function runPool( + items: T[], + concurrency: number, + worker: (item: T) => Promise, +): Promise { + const limit = Math.min(Math.max(concurrency, 1), items.length); + if (limit === 0) return; + let next = 0; + await Promise.allSettled( + Array.from({ length: limit }, async () => { + while (true) { + const i = next++; + if (i >= items.length) break; + await worker(items[i]); + } + }), + ); +} + +/** Register every .nitro file stem a classname could legitimately use. */ +function addStems(set: Set, classname: string): void { + const normalized = classname.trim().toLowerCase(); + if (!normalized) return; + const base = getBaseClassname(normalized).toLowerCase(); + if (base) set.add(base); + if (normalized.includes("*")) { + const safe = getSafeClassnameStem(normalized).toLowerCase(); + if (safe) set.add(safe); + } +} + +export async function scanFakeBrokenNitros(): Promise { + const targets = await getFurniAssetWriteTargets(); + const nitroDirs = uniqueDirs([ + targets.nitroDir, + ...targets.mirrorDirs.map((d) => d.nitroDir), + ]); + + // name → dirs + largest size seen anywhere. + const byName = new Map(); + for (const dir of nitroDirs) { + const names = await fs.readdir(dir).catch(() => [] as string[]); + for (const name of names) { + if (!NITRO_FILE_RE.test(name)) continue; + const info = byName.get(name) ?? { dirs: [], size: 0 }; + info.dirs.push(dir); + try { + const stat = await fs.stat(getRuntimePath(dir, name)); + info.size = Math.max(info.size, stat.size); + } catch { + /* ignore unreadable entry */ + } + byName.set(name, info); + } + } + + // Authoritative stem set from items_base (item_name + public_name + // fallback), mirroring repair-nitros.ts classname resolution. + let rows: Array<{ item_name: string; public_name: string }> = []; + try { + const [result] = (await db.execute(sql` + SELECT item_name, public_name FROM items_base + `)) as unknown as [ + Array<{ item_name: string; public_name: string }>, + unknown, + ]; + rows = result; + } catch { + /* empty set → every .nitro is treated as fake */ + } + const validStems = new Set(); + for (const row of rows) { + addStems(validStems, row.item_name); + if ( + row.public_name && + row.public_name !== row.item_name && + !row.public_name.includes(" ") && + row.public_name !== "0" + ) { + addStems(validStems, row.public_name); + } + } + + const entries: NitroCleanupEntry[] = [...byName].map(([fileName, info]) => ({ + fileName, + base: fileName.slice(0, -".nitro".length), + size: info.size, + dirs: info.dirs, + })); + + const fake: NitroCleanupEntry[] = []; + const broken: NitroCleanupEntry[] = []; + + await runPool(entries, 8, async (entry) => { + // A .nitro that no DB item maps to is a leftover / fake bundle. + if (!validStems.has(entry.base.toLowerCase())) { + fake.push(entry); + return; + } + // Otherwise validate the bundle content. + let parsed = false; + let firstError = ""; + for (const dir of entry.dirs) { + try { + parseNitroBundle( + await fs.readFile(getRuntimePath(dir, entry.fileName)), + ); + parsed = true; + break; + } catch (err) { + firstError = (err as Error).message; + } + } + if (!parsed) { + broken.push({ ...entry, error: firstError }); + } + }); + + fake.sort((a, b) => a.fileName.localeCompare(b.fileName)); + broken.sort((a, b) => a.fileName.localeCompare(b.fileName)); + + return { fake, broken, total: byName.size }; +} + +/** Delete .nitro files from every nitro directory (primary + mirrors). */ +export async function deleteNitroCleanupFiles( + fileNames: string[], +): Promise { + const targets = await getFurniAssetWriteTargets(); + const nitroDirs = uniqueDirs([ + targets.nitroDir, + ...targets.mirrorDirs.map((d) => d.nitroDir), + ]); + + let deleted = 0; + let copiesRemoved = 0; + const errors: string[] = []; + const files: NitroCleanupDeleteResult["files"] = []; + + for (const rawName of [...new Set(fileNames)]) { + const fileName = String(rawName ?? ""); + if (!NITRO_FILE_RE.test(fileName)) { + errors.push(`Invalid nitro file name: ${fileName}`); + files.push({ fileName, deleted: false, remaining: [] }); + continue; + } + const remaining: string[] = []; + let existed = false; + for (const dir of nitroDirs) { + const filePath = getRuntimePath(dir, fileName); + if (!existsSync(filePath)) continue; + existed = true; + try { + await fs.unlink(filePath); + copiesRemoved++; + } catch (err) { + remaining.push(dir); + errors.push(`${fileName} in ${dir}: ${(err as Error).message}`); + } + } + const fullyDeleted = existed && remaining.length === 0; + if (fullyDeleted) deleted++; + files.push({ fileName, deleted: fullyDeleted, remaining }); + } + + return { deleted, copiesRemoved, errors, files }; +}