feat(studio): expand nitro cleanup with repair, auto-clean, and orphaned assets
CI / check (push) Successful in 4m15s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m59s

Scan distinguishes fake, broken, and orphaned SWF/icon assets with age
metadata, deletes per asset kind, re-downloads broken nitro bundles from
configured sources, auto-cleans old fake leftovers, and exports a JSON
manifest. Adds rebuild and auto-clean API endpoints with audit coverage
and a housekeeping preview route under the hotel domain.

Verified: full vitest suite (2213 tests), typecheck, and biome all pass.
This commit is contained in:
openhands committed 2026-09-15 21:59:21 +02:00
1 parent ea4fd375b4
commit 438277a17a
13 files changed
+1118 -155

No files matched your search

@@ -0,0 +1,5 @@
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
export default function HousekeepingHotelNitroCleanupPage() {
return <NitroCleanupPanel />;
}
@@ -0,0 +1,54 @@
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 { autoCleanFakeNitros } from "@/lib/services/nitro-cleanup";
/** Automatically remove fake .nitro leftovers older than `maxAgeDays`. */
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
let body: { maxAgeDays?: unknown };
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const maxAgeDays =
typeof body?.maxAgeDays === "number" && Number.isFinite(body.maxAgeDays)
? Math.max(0, Math.floor(body.maxAgeDays))
: 30;
try {
const result = await autoCleanFakeNitros(maxAgeDays);
clearFurniImportCache();
if (result.deleted > 0) {
logAudit({
userId: ctx.session.user.id,
action: "nitro_auto_cleanup",
target: "NitroFiles",
after: {
maxAgeDays,
deleted: result.deleted,
copiesRemoved: result.copiesRemoved,
skippedRecent: result.skippedRecent,
skippedBroken: result.skippedBroken,
errors: result.errors.length > 0 ? result.errors : undefined,
},
});
}
return apiOk({
deleted: result.deleted,
copiesRemoved: result.copiesRemoved,
skippedRecent: result.skippedRecent,
skippedBroken: result.skippedBroken,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (err) {
return apiError(
`Nitro auto-cleanup failed: ${(err as Error).message}`,
500,
);
}
},
);
@@ -0,0 +1,51 @@
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 { repairBrokenNitros } from "@/lib/services/nitro-cleanup";
/** Re-download the given broken .nitro bundles from the configured sources. */
export const POST = 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 repairBrokenNitros(fileNames);
clearFurniImportCache();
if (result.repaired > 0) {
logAudit({
userId: ctx.session.user.id,
action: "nitro_cleanup_repair",
target: "NitroFiles",
after: {
repaired: result.repaired,
failed: result.failed,
fileNames,
errors: result.errors.length > 0 ? result.errors : undefined,
},
});
}
return apiOk({
repaired: result.repaired,
failed: result.failed,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (err) {
return apiError(`Nitro repair failed: ${(err as Error).message}`, 500);
}
},
);
@@ -4,29 +4,40 @@ import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import {
type CleanupAssetKind,
deleteNitroCleanupFiles,
scanFakeBrokenNitros,
} from "@/lib/services/nitro-cleanup";
/** Scan all nitro directories for fake (orphaned) and broken (unparseable) .nitro files. */
const VALID_KINDS: readonly CleanupAssetKind[] = ["nitro", "swf", "icon"];
/**
* Scan all asset directories for fake (orphaned) nitro / swf / icon files and
* broken (unparseable) .nitro bundles.
*/
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
try {
const scan = await scanFakeBrokenNitros();
return apiOk({
fake: scan.fake,
broken: scan.broken,
orphanedSwf: scan.orphanedSwf,
orphanedIcon: scan.orphanedIcon,
total: scan.total,
});
} catch (err) {
return apiError(`Nitro scan failed: ${(err as Error).message}`, 500);
return apiError(`Asset scan failed: ${(err as Error).message}`, 500);
}
});
/** Delete the given .nitro files from every nitro directory (primary + mirrors). */
/**
* Delete the given asset files (default .nitro) from every matching directory
* (public + mirrored gamedata).
*/
export const DELETE = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
let body: { fileNames?: unknown };
let body: { fileNames?: unknown; kind?: unknown };
try {
body = await request.json();
} catch {
@@ -37,20 +48,26 @@ export const DELETE = withAdmin(
(name): name is string => typeof name === "string",
)
: [];
const kind: CleanupAssetKind = VALID_KINDS.includes(
body?.kind as CleanupAssetKind,
)
? (body.kind as CleanupAssetKind)
: "nitro";
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
const result = await deleteNitroCleanupFiles(fileNames, kind);
// The deleted asset 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",
target: "AssetFiles",
after: {
kind,
deleted: result.deleted,
copiesRemoved: result.copiesRemoved,
fileNames,
@@ -64,7 +81,7 @@ export const DELETE = withAdmin(
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (err) {
return apiError(`Nitro cleanup failed: ${(err as Error).message}`, 500);
return apiError(`Asset cleanup failed: ${(err as Error).message}`, 500);
}
},
);
@@ -3,30 +3,38 @@
import {
AlertTriangle,
CheckCircle2,
Download,
FileX2,
Loader2,
ScanSearch,
ScrollText,
Sparkles,
Trash2,
Wrench,
} from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useMemo, 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 { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { formatBytes } from "@/lib/services/disk-usage";
interface NitroCleanupEntry {
interface AssetEntry {
fileName: string;
base: string;
size: number;
dirs: string[];
lastModified: number;
error?: string;
}
interface NitroCleanupScan {
fake: NitroCleanupEntry[];
broken: NitroCleanupEntry[];
fake: AssetEntry[];
broken: AssetEntry[];
orphanedSwf: AssetEntry[];
orphanedIcon: AssetEntry[];
total: number;
}
@@ -36,45 +44,84 @@ interface DeleteResponse {
errors?: string[];
}
type GroupKey = "fake" | "broken";
interface RepairResponse {
repaired: number;
failed: number;
errors?: string[];
}
interface AutoCleanResponse {
deleted: number;
copiesRemoved: number;
skippedRecent: number;
skippedBroken: number;
errors?: string[];
}
type GroupTone = "error" | "warning" | "neutral";
type AssetKind = "nitro" | "swf" | "icon";
const DAY_MS = 24 * 60 * 60 * 1000;
function ageLabel(lastModified: number): string {
if (!lastModified) return "age unknown";
const days = Math.floor((Date.now() - lastModified) / DAY_MS);
if (days < 1) return "< 1d";
if (days < 30) return `${days}d`;
if (days < 365) return `${Math.floor(days / 30)}mo`;
return `${Math.floor(days / 365)}y`;
}
function downloadJson(filename: string, data: unknown): void {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function CleanupGroup({
group,
label,
description,
tone,
entries,
selected,
disabled = false,
onToggle,
onToggleAll,
}: {
group: GroupKey;
label: string;
description: string;
entries: NitroCleanupEntry[];
tone: GroupTone;
entries: AssetEntry[];
selected: Set<string>;
disabled?: boolean;
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";
const toneClass =
tone === "error"
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
: tone === "warning"
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]";
return (
<section className="space-y-2">
<div
className={`flex flex-wrap items-center gap-2 rounded-lg border p-3 ${
entries.length > 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)]"
}`}
className={`flex flex-wrap items-center gap-2 rounded-lg border p-3 ${entries.length > 0 ? toneClass : "border-[var(--admin-border)] bg-[var(--admin-surface)]"}`}
>
<Checkbox
style={{ display: "inline-flex", width: 18, height: 18 }}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={allChecked}
disabled={entries.length === 0}
disabled={disabled || entries.length === 0}
onCheckedChange={() => onToggleAll(entries.map((e) => e.fileName))}
aria-label={`Select all ${label}`}
/>
@@ -111,6 +158,7 @@ function CleanupGroup({
style={{ display: "inline-flex", width: 16, height: 16 }}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={selected.has(entry.fileName)}
disabled={disabled}
onCheckedChange={() => onToggle(entry.fileName)}
aria-label={`Select ${entry.fileName}`}
/>
@@ -120,6 +168,9 @@ function CleanupGroup({
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
{formatBytes(entry.size)}
</span>
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
{ageLabel(entry.lastModified)}
</span>
{entry.error && (
<span
title={entry.error}
@@ -144,31 +195,44 @@ function CleanupGroup({
export function NitroCleanupPanel() {
const [scanning, setScanning] = useState(false);
const [deleting, setDeleting] = useState(false);
const [repairing, setRepairing] = useState(false);
const [autoCleaning, setAutoCleaning] = useState(false);
const [autoAge, setAutoAge] = useState(30);
const [minAgeDays, setMinAgeDays] = useState(0);
const [onlyOld, setOnlyOld] = useState(false);
const [scan, setScan] = useState<NitroCleanupScan | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectedSwf, setSelectedSwf] = useState<Set<string>>(new Set());
const [selectedIcon, setSelectedIcon] = useState<Set<string>>(new Set());
const { confirm, dialog } = useConfirmDialog();
const scanNow = useCallback(async () => {
setScanning(true);
setSelected(new Set());
setSelectedSwf(new Set());
setSelectedIcon(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,
fake: data.fake ?? [],
broken: data.broken ?? [],
orphanedSwf: data.orphanedSwf ?? [],
orphanedIcon: data.orphanedIcon ?? [],
total: data.total ?? 0,
});
const found = fake.length + broken.length;
const found =
data.fake.length +
data.broken.length +
data.orphanedSwf.length +
data.orphanedIcon.length;
if (found === 0) {
toast.success("No fake or broken .nitro files found");
toast.success("No orphaned or broken furniture assets found");
} else {
toast.info(`Found ${found} removable .nitro file(s)`);
toast.info(`Found ${found} removable asset file(s)`);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Scan failed");
@@ -177,8 +241,14 @@ export function NitroCleanupPanel() {
}
}, []);
const toggle = (fileName: string) => {
setSelected((prev) => {
const toggle = (kind: AssetKind) => (fileName: string) => {
const setter =
kind === "swf"
? setSelectedSwf
: kind === "icon"
? setSelectedIcon
: setSelected;
setter((prev) => {
const next = new Set(prev);
if (next.has(fileName)) next.delete(fileName);
else next.add(fileName);
@@ -186,8 +256,14 @@ export function NitroCleanupPanel() {
});
};
const toggleAll = (names: string[]) => {
setSelected((prev) => {
const toggleAll = (kind: AssetKind) => (names: string[]) => {
const setter =
kind === "swf"
? setSelectedSwf
: kind === "icon"
? setSelectedIcon
: setSelected;
setter((prev) => {
const next = new Set(prev);
const allChecked = names.every((n) => next.has(n));
for (const name of names) {
@@ -198,38 +274,61 @@ export function NitroCleanupPanel() {
});
};
const totalSelected = selected.size + selectedSwf.size + selectedIcon.size;
const removeSelected = async () => {
const fileNames = [...selected].sort();
if (fileNames.length === 0) return;
const groups: Array<{ kind: AssetKind; names: string[] }> = [];
for (const [kind, set] of [
["nitro", selected],
["swf", selectedSwf],
["icon", selectedIcon],
] as const) {
const names = [...set];
if (names.length > 0) groups.push({ kind, names });
}
if (groups.length === 0) return;
const confirmed = await confirm({
title: `Delete ${fileNames.length} .nitro file(s)?`,
title: `Delete ${totalSelected} asset file(s)?`,
description:
"The .nitro bundles will be removed from every directory (public + mirrored gamedata). Database rows are never touched.",
"The files 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) {
let copiesRemoved = 0;
let deleted = 0;
let hadErrors = false;
for (const group of groups) {
const res = await adminFetch("/api/admin/studio/nitro-cleanup", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileNames: group.names.sort(),
kind: group.kind,
}),
});
const data = (await res.json()) as DeleteResponse & {
error?: string;
};
if (!res.ok) throw new Error(data.error || "Delete failed");
deleted += data.deleted ?? 0;
copiesRemoved += data.copiesRemoved ?? 0;
if (data.errors?.length) hadErrors = true;
}
if (hadErrors) {
toast.warning(
`Deleted ${data.copiesRemoved} copy/copies, but some files could not be removed`,
`Deleted ${copiesRemoved} copy/copies, but some files could not be removed`,
);
} else {
toast.success(
`Deleted ${data.copiesRemoved} .nitro file copy/copies (${data.deleted} file(s))`,
`Deleted ${copiesRemoved} file copy/copies (${deleted} file(s))`,
);
}
setSelected(new Set());
setSelectedSwf(new Set());
setSelectedIcon(new Set());
await scanNow();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
@@ -238,23 +337,148 @@ export function NitroCleanupPanel() {
}
};
const repairSelected = async () => {
const fileNames = [...selected]
.filter((name) => scan?.broken.some((e) => e.fileName === name))
.sort();
if (fileNames.length === 0) return;
const confirmed = await confirm({
title: `Re-download ${fileNames.length} broken .nitro bundle(s)?`,
description:
"The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).",
confirmLabel: "Re-download",
variant: "default",
});
if (!confirmed) return;
setRepairing(true);
try {
const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileNames }),
});
const data = (await res.json()) as RepairResponse & {
error?: string;
};
if (!res.ok) throw new Error(data.error || "Repair failed");
if (data.repaired > 0) {
toast.success(`Repaired ${data.repaired} .nitro bundle(s)`);
} else {
toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`);
}
setSelected(new Set());
await scanNow();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Repair failed");
} finally {
setRepairing(false);
}
};
const autoClean = async () => {
if (scan === null) return;
const confirmed = await confirm({
title: `Auto-clean fake nitros older than ${autoAge} day(s)?`,
description:
"Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.",
confirmLabel: "Auto-clean",
variant: "danger",
});
if (!confirmed) return;
setAutoCleaning(true);
try {
const res = await adminFetch(
"/api/admin/studio/nitro-cleanup/auto-clean",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxAgeDays: autoAge }),
},
);
const data = (await res.json()) as AutoCleanResponse & {
error?: string;
};
if (!res.ok) throw new Error(data.error || "Auto-clean failed");
if (data.deleted > 0) {
toast.success(
`Auto-cleaned ${data.deleted} old fake .nitro file(s) (${data.copiesRemoved} copies). Skipped ${data.skippedRecent} recent + ${data.skippedBroken} broken.`,
);
} else {
toast.info(
`Nothing old enough to remove (skipped ${data.skippedRecent} recent, ${data.skippedBroken} broken)`,
);
}
await scanNow();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Auto-clean failed");
} finally {
setAutoCleaning(false);
}
};
const exportManifest = () => {
if (scan === null) return;
const entries = {
scannedAt: new Date().toISOString(),
total: scan.total,
fake: scan.fake,
broken: scan.broken,
orphanedSwf: scan.orphanedSwf,
orphanedIcon: scan.orphanedIcon,
selected: {
nitro: [...selected].sort(),
swf: [...selectedSwf].sort(),
icon: [...selectedIcon].sort(),
},
};
downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries);
toast.success("Scan manifest downloaded");
};
const visibleFake = useMemo(() => {
if (scan === null) return [];
if (!onlyOld || minAgeDays <= 0) return scan.fake;
const threshold = minAgeDays * DAY_MS;
return scan.fake.filter((e) => e.lastModified <= Date.now() - threshold);
}, [scan, onlyOld, minAgeDays]);
const visibleSwf = useMemo(() => {
if (scan === null) return [];
if (!onlyOld || minAgeDays <= 0) return scan.orphanedSwf;
const threshold = minAgeDays * DAY_MS;
return scan.orphanedSwf.filter(
(e) => e.lastModified <= Date.now() - threshold,
);
}, [scan, onlyOld, minAgeDays]);
const visibleIcon = useMemo(() => {
if (scan === null) return [];
if (!onlyOld || minAgeDays <= 0) return scan.orphanedIcon;
const threshold = minAgeDays * DAY_MS;
return scan.orphanedIcon.filter(
(e) => e.lastModified <= Date.now() - threshold,
);
}, [scan, onlyOld, minAgeDays]);
const busy = scanning || deleting || repairing || autoCleaning;
return (
<div className="space-y-4">
{dialog}
<div className="flex flex-wrap items-center gap-2">
<Button onClick={scanNow} disabled={scanning || deleting} size="lg">
<Button onClick={scanNow} disabled={busy} size="lg">
{scanning ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<ScanSearch className="w-4 h-4 mr-2" />
)}
{scanning ? "Scanning nitro files…" : "Scan for fake & broken nitros"}
{scanning ? "Scanning assets…" : "Scan furniture assets"}
</Button>
{scan && (
<Button
variant="destructive"
size="lg"
disabled={deleting || selected.size === 0}
disabled={busy || totalSelected === 0}
onClick={removeSelected}
>
{deleting ? (
@@ -262,18 +486,102 @@ export function NitroCleanupPanel() {
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
{deleting ? "Deleting…" : `Delete selected (${selected.size})`}
{deleting ? "Deleting…" : `Delete selected (${totalSelected})`}
</Button>
)}
{scan && scan.broken.length > 0 && (
<Button
size="lg"
disabled={
busy ||
![...selected].some((n) =>
scan.broken.some((e) => e.fileName === n),
)
}
onClick={repairSelected}
>
{repairing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Wrench className="w-4 h-4 mr-2" />
)}
{repairing ? "Re-downloading…" : "Re-download broken"}
</Button>
)}
{scan && scan.fake.length > 0 && (
<>
<Button size="lg" disabled={busy} onClick={autoClean}>
{autoCleaning ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{autoCleaning ? "Cleaning…" : "Auto-clean old fakes"}
</Button>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
className="w-20"
value={autoAge}
disabled={busy}
onChange={(e) => setAutoAge(Number(e.target.value))}
aria-label="Minimum age in days for auto-clean"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
days
</span>
</div>
</>
)}
{scan && (
<Button
variant="outline"
size="lg"
disabled={busy}
onClick={exportManifest}
>
<Download className="w-4 h-4 mr-2" />
Export manifest
</Button>
)}
</div>
{scan && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="flex items-center gap-2 text-sm text-[var(--admin-text-muted)]">
<ScrollText size={15} className="shrink-0" />
<span>Only show files older than</span>
</div>
<Input
type="number"
min={0}
className="w-20"
value={minAgeDays}
disabled={busy}
onChange={(e) => setMinAgeDays(Number(e.target.value))}
aria-label="Minimum age in days to display"
/>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
<label
htmlFor="nitro-hide-recent"
className="flex items-center gap-1.5 text-sm"
>
<Checkbox
id="nitro-hide-recent"
checked={onlyOld}
onCheckedChange={(value) => setOnlyOld(value === true)}
/>
<span>Hide recent files</span>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border bg-card p-3 space-y-1">
<div className="text-xl font-bold">{scan.total}</div>
<div className="text-xs text-muted-foreground">
.nitro files scanned
asset files scanned
</div>
</div>
<div className="rounded-xl border bg-card p-3 space-y-1">
@@ -281,7 +589,7 @@ export function NitroCleanupPanel() {
{scan.fake.length}
</div>
<div className="text-xs text-[var(--admin-text-muted)]">
Fake (no matching item)
Fake nitros (no matching item)
</div>
</div>
<div className="rounded-xl border bg-card p-3 space-y-1">
@@ -289,28 +597,47 @@ export function NitroCleanupPanel() {
{scan.broken.length}
</div>
<div className="text-xs text-[var(--admin-text-muted)]">
Broken (unparseable)
Broken nitros (unparseable)
</div>
</div>
</div>
<CleanupGroup
group="fake"
label="Fake nitros"
description="These files match no item in items_base — leftovers from deleted or renamed furniture."
entries={scan.fake}
tone="error"
entries={visibleFake}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
onToggle={toggle("nitro")}
onToggleAll={toggleAll("nitro")}
/>
<CleanupGroup
group="broken"
label="Broken nitros"
description="These files belong to an item but cannot be parsed as a valid Nitro bundle."
entries={scan.broken}
tone="warning"
entries={onlyOld ? [] : scan.broken}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
onToggle={toggle("nitro")}
onToggleAll={toggleAll("nitro")}
disabled={onlyOld}
/>
<CleanupGroup
label="Orphaned SWFs"
description=".swf files that match no item in items_base."
tone="warning"
entries={visibleSwf}
selected={selectedSwf}
onToggle={toggle("swf")}
onToggleAll={toggleAll("swf")}
/>
<CleanupGroup
label="Orphaned icons"
description="Icon files that match no item in items_base."
tone="neutral"
entries={visibleIcon}
selected={selectedIcon}
onToggle={toggle("icon")}
onToggleAll={toggleAll("icon")}
/>
{(scan.fake.length > 0 || scan.broken.length > 0) && (
@@ -320,15 +647,15 @@ export function NitroCleanupPanel() {
className="mt-0.5 shrink-0 text-[var(--admin-warning)]"
/>
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.
bundle — prefer re-downloading it, or let the catalog audit
re-obtain it later. Database rows are never removed by this tool.
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-[var(--admin-text-muted)]">
<FileX2 size={14} className="shrink-0" />
Only .nitro files are touched — icons, SWFs, FurnitureData.json and
database rows stay intact.
Only asset files are touched — FurnitureData.json and database rows
stay intact.
</div>
</div>
)}
@@ -337,9 +664,9 @@ export function NitroCleanupPanel() {
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground">
<ScanSearch className="mx-auto mb-3 h-10 w-10 opacity-40" />
<p>
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.
Scan every furni asset directory to find fake and broken nitro
bundles plus orphaned SWFs and icons. Delete them, re-download the
broken ones, or auto-clean old leftovers.
</p>
</div>
)}
@@ -20,7 +20,14 @@ export const hotelManifest = {
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
),
routes: [],
routes: [
{
id: "nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
href: "/admin-next/hotel/nitro-cleanup",
capability: anyCapability(PERMS.ASSETS_IMPORT),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
@@ -561,7 +561,7 @@ describe("housekeeping foundation completion contracts", () => {
}
});
it("creates the real six-domain registry in locked order without workflows", () => {
it("creates the real six-domain registry in locked order without approved workflows", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
expect(registry.domains.map((domain) => domain.id)).toEqual([
@@ -572,9 +572,20 @@ describe("housekeeping foundation completion contracts", () => {
"hotel",
"system",
]);
expect(registry.domains.every((domain) => domain.routes.length === 0)).toBe(
true,
);
expect(
registry.domains
.filter((domain) => domain.id !== "hotel")
.every((domain) => domain.routes.length === 0),
).toBe(true);
expect(
registry.domains.find((domain) => domain.id === "hotel")?.routes,
).toEqual([
expect.objectContaining({
id: "nitro-cleanup",
href: "/admin-next/hotel/nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
}),
]);
});
it("keeps production preview disabled even when the flag is true", () => {
@@ -77,6 +77,7 @@ const expectedManifests = [
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
routes: [],
},
{
id: "people",
@@ -108,6 +109,7 @@ const expectedManifests = [
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
],
routes: [],
},
{
id: "content",
@@ -131,6 +133,7 @@ const expectedManifests = [
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
],
routes: [],
},
{
id: "economy",
@@ -144,6 +147,7 @@ const expectedManifests = [
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
],
routes: [],
},
{
id: "hotel",
@@ -161,6 +165,14 @@ const expectedManifests = [
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
],
routes: [
{
id: "nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
href: "/admin-next/hotel/nitro-cleanup",
capability: { mode: "any", slugs: [PERMS.ASSETS_IMPORT] },
},
],
},
{
id: "system",
@@ -180,6 +192,7 @@ const expectedManifests = [
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
],
routes: [],
},
] as const;
describe("housekeeping registry", () => {
@@ -324,7 +337,7 @@ describe("housekeeping registry", () => {
labelKey: expected.labelKey,
descriptionKey: expected.descriptionKey,
});
expect(actual.routes).toEqual([]);
expect(actual.routes).toEqual(expected.routes);
expect(actual.searchProviders).toEqual([]);
expect(actual.inboxSources).toEqual([]);
expect(actual.widgets).toEqual([]);
+7 -1
View File
@@ -586,15 +586,21 @@ export const hotelMigrationEntries: readonly MigrationEntry[] = [
dependencies: {
queries: ["GET /api/admin/studio/nitro-cleanup", "scanFakeBrokenNitros"],
mutations: [
"autoCleanFakeNitros",
"DELETE /api/admin/studio/nitro-cleanup",
"clearFurniImportCache",
"deleteNitroCleanupFiles",
"POST /api/admin/studio/nitro-cleanup/auto-clean",
"POST /api/admin/studio/nitro-cleanup/rebuild",
"repairBrokenNitros",
],
},
auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Deletes orphaned or unparseable nitro bundles from asset files"],
notes: [
"Deletes orphaned or unparseable asset files and re-downloads broken nitro bundles from configured sources",
],
}),
plannedHotelEntry({
surface: "admin",
+213 -12
View File
@@ -9,9 +9,13 @@ const {
readFileFn,
statFn,
unlinkFn,
writeFileFn,
mkdirFn,
existsFn,
parseNitroBundle,
getTargetsFn,
listSourcesFn,
downloadFileFn,
} = vi.hoisted(() => ({
executeFn: vi.fn<AnyFn>(async () => [
[
@@ -25,6 +29,8 @@ const {
readFileFn: vi.fn<AnyFn>(),
statFn: vi.fn<AnyFn>(),
unlinkFn: vi.fn<AnyFn>(async () => {}),
writeFileFn: vi.fn<AnyFn>(async () => {}),
mkdirFn: vi.fn<AnyFn>(async () => {}),
existsFn: vi.fn<AnyFn>(() => false),
parseNitroBundle: vi.fn<AnyFn>(),
getTargetsFn: vi.fn<AnyFn>(async () => ({
@@ -33,6 +39,17 @@ const {
iconDir: "/assets/icons",
mirrorDirs: [],
})),
listSourcesFn: vi.fn<AnyFn>(async () => [
{
id: "test",
name: "Test Source",
furnidataUrl: "https://src.test/gamedata/FurnitureData.json",
nitroBaseUrl: "https://src.test/nitro",
iconBaseUrl: "https://src.test/icons",
hotel: "nl",
},
]),
downloadFileFn: vi.fn<AnyFn>(async () => ({ ok: true, size: 100 })),
}));
vi.mock("@/lib/db", () => ({ db: { execute: executeFn } }));
@@ -40,6 +57,13 @@ vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetWriteTargets: getTargetsFn,
}));
vi.mock("@/lib/services/swf/nitro-builder", () => ({ parseNitroBundle }));
vi.mock("@/lib/services/clone-sources", () => ({
listSources: listSourcesFn,
DEFAULT_NITRO_SOURCES: [],
}));
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: downloadFileFn,
}));
vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs");
return {
@@ -50,13 +74,17 @@ vi.mock("node:fs", async (orig) => {
readFile: readFileFn as AnyFn,
stat: statFn as AnyFn,
unlink: unlinkFn as AnyFn,
writeFile: writeFileFn as AnyFn,
mkdir: mkdirFn as AnyFn,
},
existsSync: existsFn,
};
});
import {
autoCleanFakeNitros,
deleteNitroCleanupFiles,
repairBrokenNitros,
scanFakeBrokenNitros,
} from "@/lib/services/nitro-cleanup";
@@ -67,26 +95,51 @@ beforeEach(() => {
parseNitroBundle.mockReset();
existsFn.mockReset();
getTargetsFn.mockReset();
listSourcesFn.mockReset();
downloadFileFn.mockReset();
unlinkFn.mockReset();
unlinkFn.mockImplementation(async () => {});
writeFileFn.mockReset();
writeFileFn.mockImplementation(async () => {});
mkdirFn.mockReset();
mkdirFn.mockImplementation(async () => {});
getTargetsFn.mockResolvedValue({
nitroDir: "/assets/nitro",
swfDir: "/assets/swf",
iconDir: "/assets/icons",
mirrorDirs: [],
});
listSourcesFn.mockResolvedValue([
{
id: "test",
name: "Test Source",
furnidataUrl: "https://src.test/gamedata/FurnitureData.json",
nitroBaseUrl: "https://src.test/nitro",
iconBaseUrl: "https://src.test/icons",
hotel: "nl",
},
]);
downloadFileFn.mockResolvedValue({ ok: true, size: 100 });
statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 }));
});
function mockReaddir(byDir: Record<string, string[]>): void {
readdirFn.mockImplementation(async (dir: string) => byDir[dir] ?? []);
}
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",
]);
mockReaddir({
"/assets/nitro": [
"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");
@@ -101,7 +154,6 @@ describe("scanFakeBrokenNitros", () => {
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",
@@ -110,9 +162,8 @@ describe("scanFakeBrokenNitros", () => {
});
it("resolves star classnames to their base nitro stem", async () => {
readdirFn.mockResolvedValue(["xmas_.nitro"]);
mockReaddir({ "/assets/nitro": ["xmas_.nitro"] });
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
statFn.mockImplementation(async () => ({ size: 16 }));
parseNitroBundle.mockReturnValue({
json: {},
jsonFileName: "visualization.json",
@@ -128,14 +179,57 @@ describe("scanFakeBrokenNitros", () => {
});
it("marks an unparseable bundle whose item is also missing as fake", async () => {
readdirFn.mockResolvedValue(["ghost.nitro"]);
statFn.mockImplementation(async () => ({ size: 8 }));
mockReaddir({ "/assets/nitro": ["ghost.nitro"] });
const result = await scanFakeBrokenNitros();
expect(result.fake[0].fileName).toBe("ghost.nitro");
expect(result.broken).toEqual([]);
});
it("reports orphaned swf and icon files alongside nitro leftovers", async () => {
mockReaddir({
"/assets/nitro": ["chair.nitro", "ghost.nitro"],
"/assets/swf": ["chair.swf", "phantom.swf"],
"/assets/icons": ["chair.gif", "phantom.gif", "phantom.png"],
});
const result = await scanFakeBrokenNitros();
expect(result.fake.map((e) => e.fileName)).toEqual(["ghost.nitro"]);
expect(result.orphanedSwf.map((e) => e.fileName)).toEqual(["phantom.swf"]);
expect(result.orphanedIcon.map((e) => e.fileName)).toEqual([
"phantom.gif",
"phantom.png",
]);
expect(result.total).toBe(7);
});
it("records the largest size and latest mtime across mirrored copies", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [
{ nitroDir: "/b/nitro", swfDir: "/b/swf", iconDir: "/b/icons" },
],
});
mockReaddir({ "/a/nitro": ["ghost.nitro"], "/b/nitro": ["ghost.nitro"] });
statFn.mockImplementation(async (filePath: string) =>
filePath.startsWith("/a/")
? { size: 100, mtimeMs: 1000 }
: { size: 900, mtimeMs: 2000 },
);
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
const result = await scanFakeBrokenNitros();
expect(result.total).toBe(1);
expect(result.fake[0].dirs).toEqual(["/a/nitro", "/b/nitro"]);
expect(result.fake[0].size).toBe(900);
expect(result.fake[0].lastModified).toBe(2000);
});
});
describe("deleteNitroCleanupFiles", () => {
@@ -192,4 +286,111 @@ describe("deleteNitroCleanupFiles", () => {
expect(result.errors[0]).toContain("Invalid nitro file name");
expect(unlinkFn).not.toHaveBeenCalled();
});
it("deletes swf and icon files from their own directories", async () => {
existsFn.mockImplementation((filePath: string) =>
filePath.includes("phantom"),
);
const swfResult = await deleteNitroCleanupFiles(["phantom.swf"], "swf");
const iconResult = await deleteNitroCleanupFiles(["phantom.gif"], "icon");
expect(swfResult.copiesRemoved).toBe(2);
expect(unlinkFn).toHaveBeenCalledWith("/a/swf/phantom.swf");
expect(unlinkFn).toHaveBeenCalledWith("/b/swf/phantom.swf");
expect(iconResult.copiesRemoved).toBe(2);
expect(unlinkFn).toHaveBeenCalledWith("/a/icons/phantom.gif");
expect(unlinkFn).toHaveBeenCalledWith("/b/icons/phantom.gif");
});
it("rejects a swf name for the icon kind", async () => {
const result = await deleteNitroCleanupFiles(["phantom.gif"], "swf");
expect(result.deleted).toBe(0);
expect(result.errors[0]).toContain("Invalid swf file name");
expect(unlinkFn).not.toHaveBeenCalled();
});
});
describe("autoCleanFakeNitros", () => {
it("removes only fake leftovers older than the age threshold", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
mockReaddir({
"/a/nitro": [
"old_fake.nitro",
"chair.nitro",
"recent_fake.nitro",
"broken_thing.nitro",
],
});
const now = Date.now();
statFn.mockImplementation(async (filePath: string) => ({
size: 10,
mtimeMs: filePath.includes("old_fake")
? now - 60 * 24 * 60 * 60 * 1000
: now,
}));
existsFn.mockImplementation(() => true);
readFileFn.mockResolvedValue(Buffer.from("broken-data"));
parseNitroBundle.mockImplementation(() => {
throw new Error("Not a valid .nitro bundle");
});
const result = await autoCleanFakeNitros(30);
expect(result.deleted).toBe(1);
expect(result.copiesRemoved).toBe(1);
expect(unlinkFn).toHaveBeenCalledWith("/a/nitro/old_fake.nitro");
expect(result.skippedRecent).toBe(1);
expect(result.skippedBroken).toBe(2);
});
});
describe("repairBrokenNitros", () => {
it("re-downloads valid bundles and writes them to every nitro dir", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
const result = await repairBrokenNitros(["broken_thing.nitro"]);
expect(result.repaired).toBe(1);
expect(result.failed).toBe(0);
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.nitro"),
expect.any(Buffer),
);
});
it("rejects unsafe file names without downloading", async () => {
const result = await repairBrokenNitros(["../evil.nitro"]);
expect(result.repaired).toBe(0);
expect(result.failed).toBe(1);
expect(downloadFileFn).not.toHaveBeenCalled();
});
it("fails when no source can provide the bundle", async () => {
downloadFileFn.mockResolvedValue({ ok: false, size: 0, error: "HTTP 404" });
const result = await repairBrokenNitros(["broken_thing.nitro"]);
expect(result.repaired).toBe(0);
expect(result.failed).toBe(1);
expect(result.errors[0]).toMatch(/could not re-obtain/);
});
});
+319 -60
View File
@@ -1,50 +1,91 @@
import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getBaseClassname, getSafeClassnameStem } from "@/lib/furni/classname";
import {
DEFAULT_NITRO_SOURCES,
listSources,
} from "@/lib/services/clone-sources";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download";
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:
* Scans every asset directory (public + mirrored gamedata) for furniture
* leftovers and splits the found bundles into 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).
* valid Nitro bundle (truncated, corrupted or renamed files);
* - orphaned `.swf` / icon files whose stem matches no item in `items_base`.
*
* Deletion only touches the .nitro files on disk — never database rows.
* Deletion only touches the asset files on disk — never database rows. Broken
* bundles can be re-downloaded from the configured nitro sources instead of
* being deleted, and fake bundles older than a configurable age can be removed
* automatically for self-cleaning nitro directories.
*/
export interface NitroCleanupEntry {
export type CleanupAssetKind = "nitro" | "swf" | "icon";
export interface AssetCleanupEntry {
fileName: string;
base: string;
size: number;
dirs: string[];
lastModified: number;
}
export interface NitroCleanupEntry extends AssetCleanupEntry {
error?: string;
}
export interface NitroCleanupScan {
fake: NitroCleanupEntry[];
broken: NitroCleanupEntry[];
orphanedSwf: AssetCleanupEntry[];
orphanedIcon: AssetCleanupEntry[];
total: number;
}
export interface NitroCleanupDeleteResult {
/** Files that were fully removed from every nitro directory. */
/** Files that were fully removed from every matching directory. */
deleted: number;
/** Individual .nitro files removed across all directories (mirrors). */
/** Individual files removed across all directories (mirrors). */
copiesRemoved: number;
errors: string[];
files: Array<{ fileName: string; deleted: boolean; remaining: string[] }>;
}
export interface NitroRepairResult {
repaired: number;
failed: number;
errors: string[];
files: Array<{ fileName: string; ok: boolean; error?: string }>;
}
export interface NitroAutoCleanResult extends NitroCleanupDeleteResult {
/** Fake bundles skipped because they are younger than the age threshold. */
skippedRecent: number;
/** Broken bundles skipped — only fake leftovers are auto-removed. */
skippedBroken: number;
}
const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.nitro$/i;
const SWF_FILE_RE = /^[a-z0-9_*\-.]+\.swf$/i;
const ICON_FILE_RE = /^[a-z0-9_*\-.]+\.(?:gif|png)$/i;
const KIND_FILE_RE: Record<CleanupAssetKind, RegExp> = {
nitro: NITRO_FILE_RE,
swf: SWF_FILE_RE,
icon: ICON_FILE_RE,
};
const DAY_MS = 24 * 60 * 60 * 1000;
function uniqueDirs(dirs: string[]): string[] {
const seen = new Set<string>();
@@ -90,34 +131,67 @@ function addStems(set: Set<string>, classname: string): void {
}
}
export async function scanFakeBrokenNitros(): Promise<NitroCleanupScan> {
const targets = await getFurniAssetWriteTargets();
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
interface DirectoryRead {
name: string;
base: string;
size: number;
lastModified: number;
}
// name → dirs + largest size seen anywhere.
const byName = new Map<string, { dirs: string[]; size: number }>();
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);
async function readAssetDir(
dir: string,
regex: RegExp,
): Promise<DirectoryRead[]> {
const names = await fs.readdir(dir).catch(() => [] as string[]);
const entries: DirectoryRead[] = [];
for (const name of names) {
if (!regex.test(name)) continue;
let size = 0;
let mtimeMs = 0;
try {
const stat = await fs.stat(getRuntimePath(dir, name));
size = stat.size;
mtimeMs = stat.mtimeMs;
} catch {
/* ignore unreadable entry */
}
entries.push({
name,
base: name.replace(/\.[a-z0-9]+$/i, ""),
size,
lastModified: mtimeMs,
});
}
return entries;
}
async function collectAssets(
dirs: string[],
regex: RegExp,
): Promise<Map<string, AssetCleanupEntry>> {
const byName = new Map<string, AssetCleanupEntry>();
for (const dir of dirs) {
for (const read of await readAssetDir(dir, regex)) {
const entry = byName.get(read.name) ?? {
fileName: read.name,
base: read.base,
size: 0,
dirs: [],
lastModified: 0,
};
if (!entry.dirs.includes(dir)) entry.dirs.push(dir);
entry.size = Math.max(entry.size, read.size);
entry.lastModified = Math.max(entry.lastModified, read.lastModified);
byName.set(read.name, entry);
}
}
return byName;
}
async function loadValidStems(): Promise<Set<string>> {
// 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 }> = [];
const validStems = new Set<string>();
try {
const [result] = (await db.execute(sql`
SELECT item_name, public_name FROM items_base
@@ -125,30 +199,46 @@ export async function scanFakeBrokenNitros(): Promise<NitroCleanupScan> {
Array<{ item_name: string; public_name: string }>,
unknown,
];
rows = result;
} catch {
/* empty set → every .nitro is treated as fake */
}
const validStems = new Set<string>();
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);
for (const row of result) {
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);
}
}
} catch {
/* empty set → every asset is treated as orphaned */
}
return validStems;
}
const entries: NitroCleanupEntry[] = [...byName].map(([fileName, info]) => ({
fileName,
base: fileName.slice(0, -".nitro".length),
size: info.size,
dirs: info.dirs,
}));
export async function scanFakeBrokenNitros(): Promise<NitroCleanupScan> {
const targets = await getFurniAssetWriteTargets();
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
const swfDirs = uniqueDirs([
targets.swfDir,
...targets.mirrorDirs.map((d) => d.swfDir),
]);
const iconDirs = uniqueDirs([
targets.iconDir,
...targets.mirrorDirs.map((d) => d.iconDir),
]);
const [byNitro, bySwf, byIcon, validStems] = await Promise.all([
collectAssets(nitroDirs, NITRO_FILE_RE),
collectAssets(swfDirs, SWF_FILE_RE),
collectAssets(iconDirs, ICON_FILE_RE),
loadValidStems(),
]);
const entries: NitroCleanupEntry[] = [...byNitro.values()];
const fake: NitroCleanupEntry[] = [];
const broken: NitroCleanupEntry[] = [];
@@ -177,21 +267,46 @@ export async function scanFakeBrokenNitros(): Promise<NitroCleanupScan> {
}
});
fake.sort((a, b) => a.fileName.localeCompare(b.fileName));
broken.sort((a, b) => a.fileName.localeCompare(b.fileName));
const orphanedSwf = [...bySwf.values()].filter(
(entry) => !validStems.has(entry.base.toLowerCase()),
);
const orphanedIcon = [...byIcon.values()].filter(
(entry) => !validStems.has(entry.base.toLowerCase()),
);
return { fake, broken, total: byName.size };
const byName = <T extends { fileName: string }>(entries: T[]): T[] =>
entries.sort((a, b) => a.fileName.localeCompare(b.fileName));
return {
fake: byName(fake),
broken: byName(broken),
orphanedSwf: byName(orphanedSwf),
orphanedIcon: byName(orphanedIcon),
total: byNitro.size + bySwf.size + byIcon.size,
};
}
/** Delete .nitro files from every nitro directory (primary + mirrors). */
function dirsForKind(
targets: Awaited<ReturnType<typeof getFurniAssetWriteTargets>>,
kind: CleanupAssetKind,
): string[] {
const primary = {
nitro: targets.nitroDir,
swf: targets.swfDir,
icon: targets.iconDir,
}[kind];
const mirrors = targets.mirrorDirs.map((d) => d[`${kind}Dir` as const]);
return uniqueDirs([primary, ...mirrors]);
}
/** Delete asset files from every matching directory (primary + mirrors). */
export async function deleteNitroCleanupFiles(
fileNames: string[],
kind: CleanupAssetKind = "nitro",
): Promise<NitroCleanupDeleteResult> {
const targets = await getFurniAssetWriteTargets();
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
const dirs = dirsForKind(targets, kind);
const fileRegex = KIND_FILE_RE[kind];
let deleted = 0;
let copiesRemoved = 0;
@@ -200,14 +315,14 @@ export async function deleteNitroCleanupFiles(
for (const rawName of [...new Set(fileNames)]) {
const fileName = String(rawName ?? "");
if (!NITRO_FILE_RE.test(fileName)) {
errors.push(`Invalid nitro file name: ${fileName}`);
if (!fileRegex.test(fileName)) {
errors.push(`Invalid ${kind} file name: ${fileName}`);
files.push({ fileName, deleted: false, remaining: [] });
continue;
}
const remaining: string[] = [];
let existed = false;
for (const dir of nitroDirs) {
for (const dir of dirs) {
const filePath = getRuntimePath(dir, fileName);
if (!existsSync(filePath)) continue;
existed = true;
@@ -226,3 +341,147 @@ export async function deleteNitroCleanupFiles(
return { deleted, copiesRemoved, errors, files };
}
function ageDays(lastModified: number): number {
if (!lastModified) return 0;
return Math.max(0, Math.floor((Date.now() - lastModified) / DAY_MS));
}
/**
* Automatically remove fake .nitro leftovers that are older than
* `maxAgeDays`. Broken bundles and young fake bundles are never touched —
* only former leftovers whose items are long gone from the database.
*/
export async function autoCleanFakeNitros(
maxAgeDays = 30,
): Promise<NitroAutoCleanResult> {
const scan = await scanFakeBrokenNitros();
const threshold = Math.max(0, Math.floor(maxAgeDays));
const removable = scan.fake.filter(
(entry) => ageDays(entry.lastModified) >= threshold,
);
const result = await deleteNitroCleanupFiles(
removable.map((entry) => entry.fileName),
"nitro",
);
return {
...result,
skippedRecent: scan.fake.length - removable.length,
skippedBroken: scan.broken.length,
};
}
async function copyFileToDirs(
buffer: Buffer,
fileName: string,
dirs: string[],
): Promise<string[]> {
const errors: string[] = [];
for (const dir of dirs) {
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(getRuntimePath(dir, fileName), buffer);
} catch (err) {
errors.push(`write .nitro to ${dir} failed: ${(err as Error).message}`);
}
}
return errors;
}
/**
* Re-download broken .nitro bundles from the configured nitro sources. The
* file stem matched an item in `items_base`, so the same classname candidates
* used by repair-nitros.ts are tried; every downloaded bundle is validated
* before it replaces the broken copies across all nitro directories.
*/
export async function repairBrokenNitros(
fileNames: string[],
): Promise<NitroRepairResult> {
const targets = await getFurniAssetWriteTargets();
const nitroDirs = dirsForKind(targets, "nitro");
let sources = (await listSources().catch(() => [] as never)).filter(
(source: { nitroBaseUrl?: string }) => source.nitroBaseUrl,
);
if (sources.length === 0) sources = DEFAULT_NITRO_SOURCES as never;
let repaired = 0;
let failed = 0;
const errors: string[] = [];
const files: NitroRepairResult["files"] = [];
await runPool([...new Set(fileNames)], 6, async (rawName) => {
const fileName = String(rawName ?? "");
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
failed++;
const message = `Invalid nitro file name: ${fileName}`;
errors.push(message);
files.push({ fileName, ok: false, error: message });
return;
}
const name = fileName.slice(0, -".nitro".length);
const candidates = [
name,
...(name.includes("*")
? [name.split("*")[0], name.replace(/\*/g, "_")]
: []),
...(name.includes(".") ? [name.replace(/\./g, "_")] : []),
];
for (const source of sources as {
name: string;
nitroBaseUrl?: string;
}[]) {
for (const candidate of candidates) {
const base = source.nitroBaseUrl;
if (!base) continue;
const tmpPath = getRuntimePath(
os.tmpdir(),
`.repair_nitro_${name}_${Math.random().toString(36).slice(2, 8)}.nitro`,
);
try {
const dl = await downloadFile(
`${base.replace(/\/+$/, "")}/${encodeURIComponent(candidate)}.nitro`,
tmpPath,
{ maxRetries: 1, validate: "nitro" },
);
if (dl.ok) {
const buffer = await fs.readFile(tmpPath);
parseNitroBundle(buffer);
const writeErrors = await copyFileToDirs(
buffer,
fileName,
nitroDirs,
);
if (writeErrors.length === 0) {
repaired++;
files.push({ fileName, ok: true });
} else {
failed++;
errors.push(...writeErrors);
files.push({
fileName,
ok: false,
error: writeErrors[0],
});
}
return;
}
} catch (err) {
errors.push(
`repair ${fileName} from ${source.name} failed: ${(err as Error).message}`,
);
} finally {
await fs.unlink(tmpPath).catch(() => {});
}
}
}
failed++;
errors.push(`could not re-obtain ${fileName} from any nitro source`);
files.push({
fileName,
ok: false,
error: "could not re-obtain bundle from any nitro source",
});
});
return { repaired, failed, errors, files };
}
+7 -1
View File
@@ -4771,7 +4771,13 @@
},
"hotel": {
"title": "Hotel",
"description": "Rooms, radio, and asset tools"
"description": "Rooms, radio, and asset tools",
"routes": {
"nitroCleanup": {
"title": "Nitro cleanup",
"description": "Scan and remove fake, broken, and orphaned furni asset files"
}
}
},
"system": {
"title": "System",
+7 -1
View File
@@ -4770,7 +4770,13 @@
},
"hotel": {
"title": "Hotel",
"description": "Stanze, radio e strumenti per gli asset"
"description": "Stanze, radio e strumenti per gli asset",
"routes": {
"nitroCleanup": {
"title": "Pulizia nitro",
"description": "Scansiona e rimuovi i file asset furni falsi, danneggiati o orfani"
}
}
},
"system": {
"title": "Sistema",