feat: connect guarded asset import api

This commit is contained in:
Simo committed 2026-07-12 19:12:25 +02:00
1 parent 3497df9dfd
commit eb759f54bf
26 files changed
+2342 -24

No files matched your search

@@ -0,0 +1,72 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { logAudit } from '@/lib/services/audit'
import { cloneSingleFurni, fetchSourceFurnidata } from '@/lib/services/clone-import'
import { getSource } from '@/lib/services/clone-sources'
import { runSseBatch } from '@/lib/services/import/core/sse-batch'
interface BatchItem {
classname: string
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const sourceId: string = body.sourceId || ''
const rawClassnames: string[] = body.classnames || []
if (!sourceId) return apiError('Missing sourceId', 400)
if (rawClassnames.length === 0) return apiError('No classnames to clone', 400)
const source = await getSource(sourceId)
if (!source) return apiError('Source not found', 404)
// Fetch furnidata once so we can resolve entries by classname.
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>
try {
allEntries = await fetchSourceFurnidata(source.furnidataUrl)
} catch (err) {
return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502)
}
const entryMap = new Map(allEntries.map((e) => [e.classname, e]))
// Deduplicate classnames preserving order.
const seen = new Set<string>()
const items: BatchItem[] = []
for (const cn of rawClassnames) {
if (!seen.has(cn)) {
seen.add(cn)
items.push({ classname: cn })
}
}
if (items.length > 500) return apiError('Too many classnames (max 500)', 400)
return runSseBatch<BatchItem>({
items,
concurrency: body.concurrency || 2,
labelOf: (it) => it.classname,
worker: async (it, _index, report) => {
const entry = entryMap.get(it.classname)
if (!entry) {
return { ok: false, error: 'classname not found in source furnidata' }
}
const result = await cloneSingleFurni({ source, entry, onProgress: report })
if (result.ok) {
logAudit({
userId: ctx.session.user.id,
action: 'clone_furni',
target: 'FurniAsset',
targetId: 0,
after: { classname: it.classname, sourceId, sourceName: source.name },
})
}
return { ok: result.ok, warnings: result.warnings, error: result.error }
},
})
})
@@ -0,0 +1,53 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
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>()
// 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)
} 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',
},
})
})
+95
View File
@@ -0,0 +1,95 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError, apiOk } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { logAudit } from '@/lib/services/audit'
import { getClonableClassnames, getCloneList, getCloneStats } from '@/lib/services/clone-import'
import { deleteSource, getSource, listSources, upsertSource } from '@/lib/services/clone-sources'
// GET ?action=sources → list presets
// GET ?source=<id>&search=&page=&perPage= → { items, meta, stats }
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
const sp = request.nextUrl.searchParams
if (sp.get('action') === 'sources') {
return apiOk({ sources: await listSources() })
}
const sourceId = sp.get('source')
if (!sourceId) return apiError('Missing source or action', 400)
const source = await getSource(sourceId)
if (!source) return apiError('Source not found', 404)
// GET ?source=<id>&action=clonable → all not-yet-present classnames (for "clone all")
if (sp.get('action') === 'clonable') {
return apiOk({ classnames: await getClonableClassnames(source) })
}
const search = sp.get('search') || ''
const page = Math.max(Number.parseInt(sp.get('page') || '1', 10) || 1, 1)
const perPage = Math.min(Math.max(Number.parseInt(sp.get('perPage') || '50', 10) || 50, 1), 200)
const filterParam = sp.get('filter')
const filter = filterParam === 'missing' || filterParam === 'present' ? filterParam : 'all'
const [listResult, stats] = await Promise.all([
getCloneList({ source, search, page, perPage, filter }),
getCloneStats(source),
])
return apiOk({ items: listResult.items, meta: listResult.meta, stats })
})
// POST — upsert a source preset
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, id } = body as Record<string, string>
const isHttps = (u: string) => {
try {
return new URL(u).protocol === 'https:'
} catch {
return false
}
}
if (!name?.trim()) return apiError('name is required', 400)
if (!furnidataUrl?.trim()) return apiError('furnidataUrl is required', 400)
if (!nitroBaseUrl?.trim()) return apiError('nitroBaseUrl is required', 400)
if (!iconBaseUrl?.trim()) return apiError('iconBaseUrl is required', 400)
if (!isHttps(furnidataUrl) || !isHttps(nitroBaseUrl) || !isHttps(iconBaseUrl))
return apiError('Source URLs must be valid https:// URLs', 400)
const entry = await upsertSource({ id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl })
logAudit({
userId: ctx.session.user.id,
action: id ? 'clone_source_update' : 'clone_source_create',
target: 'CloneSource',
targetId: 0,
after: { id: entry.id, name: entry.name },
})
return apiOk({ source: entry })
})
// DELETE ?id=<id>
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const id = request.nextUrl.searchParams.get('id')
if (!id) return apiError('Missing id', 400)
const source = await getSource(id)
if (!source) return apiError('Source not found', 404)
await deleteSource(id)
logAudit({
userId: ctx.session.user.id,
action: 'clone_source_delete',
target: 'CloneSource',
targetId: 0,
after: { id, name: source.name },
})
return apiOk({ id })
})