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 = ( entries: T[], ): T[] => minAgeDays !== null && minAgeDays > 0 ? entries.filter((entry) => ageDays(entry.lastModified) >= minAgeDays) : entries; const cap = (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); } }, );