172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
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 {
|
|
ageDays,
|
|
type CleanupAssetKind,
|
|
type CleanupProgress,
|
|
deleteNitroCleanupFiles,
|
|
scanFakeBrokenNitros,
|
|
} from "@/lib/services/nitro-cleanup";
|
|
|
|
const VALID_KINDS: readonly CleanupAssetKind[] = ["nitro", "swf", "icon"];
|
|
|
|
function intParam(value: string | null): number | null {
|
|
if (value === null) return null;
|
|
const n = Number(value);
|
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
}
|
|
|
|
/**
|
|
* Scan all asset directories for fake (orphaned) nitro / swf / icon files and
|
|
* broken (unparseable) .nitro bundles.
|
|
*
|
|
* Query params:
|
|
* - `force=1` skips the on-disk scan cache (fresh re-validation);
|
|
* - `minAgeDays=N` only returns entries older than N days;
|
|
* - `limit=N` caps each returned list (use `full=1` to override);
|
|
* - `full=1` never applies the limit (used by the manifest export).
|
|
*
|
|
* When the client asks for `text/event-stream` the scan is streamed as
|
|
* `progress` / `result` / `error` events, keeping the connection alive while a
|
|
* large nitro directory is walked so reverse proxies don't time the request out.
|
|
*/
|
|
export const GET = withAdmin(
|
|
{ permission: PERMS.ASSETS_IMPORT },
|
|
async (request) => {
|
|
const wantsSse = request.headers
|
|
.get("accept")
|
|
?.includes("text/event-stream");
|
|
const force = request.nextUrl.searchParams.get("force") === "1";
|
|
const full = request.nextUrl.searchParams.get("full") === "1";
|
|
const minAgeDays = intParam(request.nextUrl.searchParams.get("minAgeDays"));
|
|
const limit = intParam(request.nextUrl.searchParams.get("limit"));
|
|
|
|
const run = async (onProgress?: (p: CleanupProgress) => void) => {
|
|
const scan = await scanFakeBrokenNitros({ force, onProgress });
|
|
const ageFilter = <T extends { lastModified: number }>(
|
|
entries: T[],
|
|
): T[] =>
|
|
minAgeDays !== null && minAgeDays > 0
|
|
? entries.filter((entry) => ageDays(entry.lastModified) >= minAgeDays)
|
|
: entries;
|
|
const cap = <T>(entries: T[]): T[] =>
|
|
full || limit === null || limit <= 0
|
|
? entries
|
|
: entries.slice(0, limit);
|
|
return {
|
|
fake: cap(ageFilter(scan.fake)),
|
|
broken: cap(ageFilter(scan.broken)),
|
|
orphanedSwf: cap(ageFilter(scan.orphanedSwf)),
|
|
orphanedIcon: cap(ageFilter(scan.orphanedIcon)),
|
|
total: scan.total,
|
|
cached: scan.cached ?? false,
|
|
};
|
|
};
|
|
|
|
try {
|
|
if (!wantsSse) {
|
|
return apiOk(await run());
|
|
}
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const send = (data: unknown) => {
|
|
try {
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
|
);
|
|
} catch {
|
|
/* stream closed by client */
|
|
}
|
|
};
|
|
try {
|
|
const result = await run((progress) =>
|
|
send({ type: "progress", ...progress }),
|
|
);
|
|
send({ type: "result", ...result });
|
|
} catch (err) {
|
|
send({
|
|
type: "error",
|
|
message: `Asset scan failed: ${(err as Error).message}`,
|
|
});
|
|
}
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
return apiError(`Asset scan failed: ${(err as Error).message}`, 500);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 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; kind?: 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",
|
|
)
|
|
: [];
|
|
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, 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: "AssetFiles",
|
|
after: {
|
|
kind,
|
|
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(`Asset cleanup failed: ${(err as Error).message}`, 500);
|
|
}
|
|
},
|
|
);
|