Files
EpicNext-Cms/src/app/api/admin/import/clone/icon/route.ts
T
openhands 8c12fc6c60 refactor(cms): deduplicate shared admin components and fix studio batch completion
- fix(studio): stop markBatchDone infinite recursion so batches complete
- refactor(api): merge api-response into api and drop the duplicate module
- refactor(media): extract shared media loader and URL validator
- refactor(ui): extract shared LoadingSpinner for site and admin groups
- refactor(dates): consolidate raw date formatting into formatDate util
- refactor(logs): share a single generic log-list loader across tables
- refactor(theme): merge both ColorField components and reuse contrast helpers
- refactor(import): extract shared ImportErrorBanner and SearchInput
- chore(): remove dead theme-editor-tabs after inlining tab components
2026-09-05 20:58:42 +02:00

68 lines
2.2 KiB
TypeScript

import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { getSource } from "@/lib/services/clone-sources";
// In-process cache of extracted icons, keyed by source+classname.
// null = known-missing (don't refetch the bundle every render).
const iconCache = new Map<string, Buffer | null>();
const MAX_ICON_CACHE_SIZE = 500;
function pruneIconCache() {
while (iconCache.size > MAX_ICON_CACHE_SIZE) {
const oldest = iconCache.keys().next().value;
if (oldest === undefined) break;
iconCache.delete(oldest);
}
}
// GET ?source=<id>&classname=<cn>
// Fetches the source's .nitro bundle and returns the embedded furni icon as a
// PNG. Used by the clone preview grid for sources that don't serve standalone
// {classname}_icon.png files (icons live inside the .nitro).
export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const sp = request.nextUrl.searchParams;
const sourceId = sp.get("source");
const classname = sp.get("classname");
if (!sourceId || !classname)
return apiError("Missing source or classname", 400);
if (!/^[\w\-.*]+$/.test(classname))
return apiError("Invalid classname", 400);
const cacheKey = `${sourceId}:${classname}`;
let icon = iconCache.get(cacheKey);
if (icon === undefined) {
const source = await getSource(sourceId);
if (!source) return apiError("Source not found", 404);
try {
const res = await fetch(`${source.nitroBaseUrl}/${classname}.nitro`, {
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
iconCache.set(cacheKey, null);
return apiError("Bundle not found", 404);
}
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
iconCache.set(cacheKey, icon);
pruneIconCache();
} catch {
iconCache.set(cacheKey, null);
return apiError("Failed to fetch bundle", 502);
}
}
if (!icon) return apiError("No icon in bundle", 404);
return new Response(new Uint8Array(icon), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400",
},
});
},
);