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

+16 -21
View File
@@ -1,24 +1,19 @@
"use server";
'use server'
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
import { cleanupConvertedSwfs, deleteImportedItem } from '@/lib/services/furni-import'
export async function cleanSwfFiles(): Promise<ActionResult<{ deleted: number; remaining: number }>> {
try {
await requireStaff();
return { ok: true, data: { deleted: 0, remaining: 0 } };
} catch {
return { ok: false, error: "Failed to clean SWF files" };
}
}
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
const result = await cleanupConvertedSwfs()
return actionOk(result as unknown as Record<string, unknown>)
})
export async function deleteImportedFurni({ classname }: { classname: string }) {
await requireStaff();
try {
await prisma.itemsBase.deleteMany({ where: { itemName: classname } });
return { ok: true as const, data: { deletedFiles: [classname], errors: [] as string[] } };
} catch {
return { ok: false as const, error: `Failed to delete ${classname}` };
}
}
const deleteSchema = z.object({ classname: z.string().trim().min(1) })
export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) => actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
)
+1 -1
View File
@@ -4,7 +4,7 @@ import { ImportBadgesClient } from './import-badges-client'
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)) {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { ImportFurniClient } from './import-furni-client'
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)) {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { RepairClient } from './repair-client'
export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)) {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
@@ -0,0 +1,28 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { withAdmin } from '@/lib/api-handler'
import { apiError, apiOk } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
const EXTERNAL_TEXTS_PATH = path.join(
process.cwd(),
'public/nitro-assets/gamedata/ExternalTexts.json',
)
/** GET - Read badge name/desc from ExternalTexts.json */
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
const code = request.nextUrl.searchParams.get('code')?.toUpperCase()
if (!code) return apiError('Missing code', 400)
try {
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
const texts: Record<string, string> = JSON.parse(raw)
const name = texts[`badge_name_${code}`] ?? texts[`${code}_badge_name`] ?? ''
const desc = texts[`badge_desc_${code}`] ?? texts[`${code}_badge_desc`] ?? ''
return apiOk({ name, desc })
} catch {
return apiError('Failed to read ExternalTexts.json', 500)
}
})
+80
View File
@@ -0,0 +1,80 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
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 { searchBadges } from '@/lib/services/habboassets'
const EXTERNAL_TEXTS_PATH = path.join(
process.cwd(),
'public/nitro-assets/gamedata/ExternalTexts.json',
)
// ── Search badges via official Habbo external texts ────────────────
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
const search = request.nextUrl.searchParams.get('search') || ''
const offset = parseInt(request.nextUrl.searchParams.get('offset') || '0', 10)
const allHotels = request.nextUrl.searchParams.get('allHotels') === '1'
const badges = await searchBadges(search, 100, offset, allHotels ? null : undefined)
// Check which badges already exist in ExternalTexts.json
const existingCodes = new Set<string>()
try {
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
const texts = JSON.parse(raw)
for (const badge of badges) {
if (texts[`badge_name_${badge.code}`] !== undefined) {
existingCodes.add(badge.code)
}
}
} catch {
// File not found or invalid - treat all as not imported
}
const enriched = badges.map((badge) => ({
...badge,
alreadyImported: existingCodes.has(badge.code),
}))
return apiOk({ badges: enriched })
})
// ── Import a single badge ───────────────────────────────────────────
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const { code, name, description } = body
if (!code || !name) return apiError('Missing code or name', 400)
try {
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
const texts = JSON.parse(raw)
const nameKey = `badge_name_${code}`
const descKey = `badge_desc_${code}`
if (texts[nameKey] !== undefined) {
return apiError('Badge already exists in ExternalTexts.json', 409)
}
texts[nameKey] = name
texts[descKey] = description || ''
await fs.writeFile(EXTERNAL_TEXTS_PATH, JSON.stringify(texts, null, 4), 'utf-8')
} catch (err) {
console.warn('[import-badges] Failed to update ExternalTexts.json:', (err as Error).message)
return apiError('Failed to update ExternalTexts.json', 500)
}
logAudit({
userId: ctx.session.user.id,
action: 'badge_import',
target: 'ExternalTexts',
after: { code, name, description },
})
return apiOk()
})
@@ -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 })
})
@@ -0,0 +1,46 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { logAudit } from '@/lib/services/audit'
import { ensureFigureDirs, importSingleFigure } from '@/lib/services/figure-import'
import { runSseBatch } from '@/lib/services/import/core/sse-batch'
interface FigureBatchItem {
lib: string
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const rawItems: FigureBatchItem[] = body.items || []
const concurrency = body.concurrency || 3
if (rawItems.length === 0) return apiError('No libraries to import', 400)
const seen = new Set<string>()
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false
seen.add(it.lib)
return true
})
await ensureFigureDirs()
return runSseBatch<FigureBatchItem>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSingleFigure({ lib: it.lib, onProgress: report })
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: 'figure_import',
target: 'FigureAsset',
targetId: 0,
after: { lib: it.lib, batch: true },
})
}
return { ok: r.ok, warnings: r.warnings, error: r.error }
},
})
})
@@ -0,0 +1,29 @@
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 { deleteImportedFigure, getFigureList, getFigureStats } from '@/lib/services/figure-import'
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
if (request.nextUrl.searchParams.get('action') === 'stats') return apiOk(await getFigureStats())
const search = request.nextUrl.searchParams.get('search') || ''
const page = Number.parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
const { items, meta } = await getFigureList({ search, page: page || 1, perPage: 100 })
return apiOk({ items, meta })
})
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const lib = request.nextUrl.searchParams.get('lib')
if (!lib) return apiError('Missing lib', 400)
const { deletedFile } = await deleteImportedFigure(lib)
if (!deletedFile) return apiError(`Library "${lib}" not found`, 404)
logAudit({
userId: ctx.session.user.id,
action: 'figure_delete',
target: 'FigureAsset',
targetId: 0,
after: { lib },
})
return apiOk({ lib, deletedFile })
})
@@ -0,0 +1,50 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { logAudit } from '@/lib/services/audit'
import { importClothingSet } from '@/lib/services/clothing-set-import'
import { runSseBatch } from '@/lib/services/import/core/sse-batch'
interface ClothingSetBatchItem {
setType: string
setId: number
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const rawItems: ClothingSetBatchItem[] = body.items || []
const concurrency = body.concurrency || 3
if (rawItems.length === 0) return apiError('No clothing sets to import', 400)
const seen = new Set<string>()
const items = rawItems.filter((it) => {
const key = `${it.setType}:${it.setId}`
if (seen.has(key)) return false
seen.add(key)
return true
})
return runSseBatch<ClothingSetBatchItem>({
items,
concurrency,
labelOf: (it) => `${it.setType}-${it.setId}`,
worker: async (it, _index, report) => {
const r = await importClothingSet({
setType: it.setType,
setId: it.setId,
onProgress: report,
})
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: 'clothing_set_import',
target: 'ClothingSet',
targetId: it.setId,
after: { setType: it.setType, setId: it.setId, batch: true },
})
}
return { ok: r.ok, warnings: r.warnings, error: r.error }
},
})
})
@@ -0,0 +1,21 @@
import { withAdmin } from '@/lib/api-handler'
import { apiOk } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { getClothingSetList, getClothingSetStats } from '@/lib/services/clothing-set-import'
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
if (request.nextUrl.searchParams.get('action') === 'stats') {
return apiOk((await getClothingSetStats()) as unknown as Record<string, unknown>)
}
const search = request.nextUrl.searchParams.get('search') || ''
const page = Number.parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
const setType = request.nextUrl.searchParams.get('setType') || undefined
const { items, meta } = await getClothingSetList({
search,
page: page || 1,
perPage: 100,
setType,
})
return apiOk({ items, meta })
})
@@ -0,0 +1,50 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { logAudit } from '@/lib/services/audit'
import { ensureEffectDirs, importSingleEffect } from '@/lib/services/effect-import'
import { runSseBatch } from '@/lib/services/import/core/sse-batch'
import type { EffectMapEntry } from '@/types/effects'
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const rawItems: EffectMapEntry[] = body.items || []
const concurrency = body.concurrency || 3
if (rawItems.length === 0) return apiError('No effects to import', 400)
// Dedupe by lib (the .nitro filename) to avoid double work in one request.
const seen = new Set<string>()
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false
seen.add(it.lib)
return true
})
await ensureEffectDirs()
return runSseBatch<EffectMapEntry>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSingleEffect({
id: it.id,
lib: it.lib,
type: it.type,
revision: it.revision,
onProgress: report,
})
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: 'effect_import',
target: 'EffectMap',
targetId: 0,
after: { id: it.id, lib: it.lib, type: it.type, batch: true },
})
}
return { ok: r.ok, warnings: r.warnings, error: r.error }
},
})
})
+31
View File
@@ -0,0 +1,31 @@
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 { deleteImportedEffect, getEffectList, getEffectStats } from '@/lib/services/effect-import'
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
const action = request.nextUrl.searchParams.get('action')
if (action === 'stats') {
return apiOk(await getEffectStats())
}
const search = request.nextUrl.searchParams.get('search') || ''
const effects = await getEffectList(search)
return apiOk({ effects })
})
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const idOrLib = request.nextUrl.searchParams.get('lib') || request.nextUrl.searchParams.get('id')
if (!idOrLib) return apiError('Missing lib/id', 400)
const { removed, deletedFile } = await deleteImportedEffect(idOrLib)
if (!removed && !deletedFile) return apiError(`Effect "${idOrLib}" not found`, 404)
logAudit({
userId: ctx.session.user.id,
action: 'effect_delete',
target: 'EffectMap',
targetId: 0,
after: { idOrLib, removed, deletedFile },
})
return apiOk({ idOrLib, removed, deletedFile })
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { logAudit } from '@/lib/services/audit'
import { runSseBatch } from '@/lib/services/import/core/sse-batch'
import { ensurePetDirs, importSinglePet } from '@/lib/services/pet-import'
interface PetBatchItem {
lib: string
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const body = await request.json()
const rawItems: PetBatchItem[] = body.items || []
const concurrency = body.concurrency || 3
if (rawItems.length === 0) return apiError('No pets to import', 400)
const seen = new Set<string>()
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false
seen.add(it.lib)
return true
})
await ensurePetDirs()
return runSseBatch<PetBatchItem>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSinglePet({ lib: it.lib, onProgress: report })
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: 'pet_import',
target: 'PetAsset',
targetId: 0,
after: { lib: it.lib, batch: true },
})
}
return { ok: r.ok, warnings: r.warnings, error: r.error }
},
})
})
@@ -0,0 +1,19 @@
import { withAdmin } from '@/lib/api-handler'
import { PERMS } from '@/lib/permissions'
import { getPetIconPng } from '@/lib/services/pet-icon'
// Serves a cropped pet preview PNG extracted from the pet's .nitro spritesheet.
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
const lib = request.nextUrl.searchParams.get('lib') || ''
const png = getPetIconPng(lib)
if (!png) return new Response(null, { status: 404 })
return new Response(new Uint8Array(png), {
headers: {
'Content-Type': 'image/png',
// short cache so generator tweaks show up quickly; the in-process icon
// cache keeps regeneration cheap. Client bumps a ?v= when output changes.
'Cache-Control': 'public, max-age=300',
},
})
})
+27
View File
@@ -0,0 +1,27 @@
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 { deleteImportedPet, getPetList, getPetStats } from '@/lib/services/pet-import'
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
if (request.nextUrl.searchParams.get('action') === 'stats') return apiOk(getPetStats())
const search = request.nextUrl.searchParams.get('search') || ''
return apiOk({ pets: getPetList(search) })
})
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
const lib = request.nextUrl.searchParams.get('lib')
if (!lib) return apiError('Missing lib', 400)
const { deletedFile } = await deleteImportedPet(lib)
if (!deletedFile) return apiError(`Pet "${lib}" not found`, 404)
logAudit({
userId: ctx.session.user.id,
action: 'pet_delete',
target: 'PetAsset',
targetId: 0,
after: { lib },
})
return apiOk({ lib, deletedFile })
})
@@ -0,0 +1,12 @@
import { withAdmin } from '@/lib/api-handler'
import { apiOk } from '@/lib/api-response'
import { PERMS } from '@/lib/permissions'
import { auditImportedAssets } from '@/lib/services/furni-repair'
// Audit is expensive (scans every imported item's files on disk).
// Using POST keeps it out of any cache layer that might front GET.
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const summary = await auditImportedAssets()
return apiOk(summary as unknown as Record<string, unknown>)
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,25 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { type NextRequest, NextResponse } from 'next/server'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS' }
export async function GET(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const segments = (await params).path
const filename = segments.at(-1)
if (!filename || filename !== path.basename(filename) || !filename.endsWith('.nitro')) {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS })
}
try {
const { nitroDir } = await getFurniAssetDirs()
const file = await fs.readFile(path.join(nitroDir, filename))
return new NextResponse(file, { headers: { ...CORS_HEADERS, 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=86400, immutable' } })
} catch {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS })
}
}
export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS })
}