feat(studio): expand nitro cleanup with repair, auto-clean, and orphaned assets
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:
1 parent
ea4fd375b4
commit
438277a17a
13 files changed
+1118
-155
No files matched your search
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user