From eb759f54bf545fe8cd53e941c61a9955f990981d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 12 Jul 2026 19:12:25 +0200 Subject: [PATCH] feat: connect guarded asset import api --- src/actions/import-furni.ts | 37 +- src/app/admin/import/badges/page.tsx | 2 +- src/app/admin/import/furni/page.tsx | 2 +- src/app/admin/import/repair/page.tsx | 2 +- src/app/api/admin/import/badges/edit/route.ts | 28 + src/app/api/admin/import/badges/route.ts | 80 ++ src/app/api/admin/import/clone/batch/route.ts | 72 ++ src/app/api/admin/import/clone/icon/route.ts | 53 ++ src/app/api/admin/import/clone/route.ts | 95 +++ .../api/admin/import/clothing/batch/route.ts | 46 ++ src/app/api/admin/import/clothing/route.ts | 29 + .../admin/import/clothing/sets/batch/route.ts | 50 ++ .../api/admin/import/clothing/sets/route.ts | 21 + .../api/admin/import/effects/batch/route.ts | 50 ++ src/app/api/admin/import/effects/route.ts | 31 + .../admin/import/furni/batch-regen/route.ts | 231 ++++++ src/app/api/admin/import/furni/batch/route.ts | 198 +++++ .../admin/import/furni/nitro-editor/route.ts | 166 ++++ .../api/admin/import/furni/resync/route.ts | 203 +++++ src/app/api/admin/import/furni/route.ts | 713 ++++++++++++++++++ src/app/api/admin/import/pets/batch/route.ts | 46 ++ src/app/api/admin/import/pets/icon/route.ts | 19 + src/app/api/admin/import/pets/route.ts | 27 + .../api/admin/import/repair/audit/route.ts | 12 + src/app/api/admin/import/repair/route.ts | 128 ++++ .../bundled/furniture/[...path]/route.ts | 25 + 26 files changed, 2342 insertions(+), 24 deletions(-) create mode 100644 src/app/api/admin/import/badges/edit/route.ts create mode 100644 src/app/api/admin/import/badges/route.ts create mode 100644 src/app/api/admin/import/clone/batch/route.ts create mode 100644 src/app/api/admin/import/clone/icon/route.ts create mode 100644 src/app/api/admin/import/clone/route.ts create mode 100644 src/app/api/admin/import/clothing/batch/route.ts create mode 100644 src/app/api/admin/import/clothing/route.ts create mode 100644 src/app/api/admin/import/clothing/sets/batch/route.ts create mode 100644 src/app/api/admin/import/clothing/sets/route.ts create mode 100644 src/app/api/admin/import/effects/batch/route.ts create mode 100644 src/app/api/admin/import/effects/route.ts create mode 100644 src/app/api/admin/import/furni/batch-regen/route.ts create mode 100644 src/app/api/admin/import/furni/batch/route.ts create mode 100644 src/app/api/admin/import/furni/nitro-editor/route.ts create mode 100644 src/app/api/admin/import/furni/resync/route.ts create mode 100644 src/app/api/admin/import/furni/route.ts create mode 100644 src/app/api/admin/import/pets/batch/route.ts create mode 100644 src/app/api/admin/import/pets/icon/route.ts create mode 100644 src/app/api/admin/import/pets/route.ts create mode 100644 src/app/api/admin/import/repair/audit/route.ts create mode 100644 src/app/api/admin/import/repair/route.ts create mode 100644 src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts diff --git a/src/actions/import-furni.ts b/src/actions/import-furni.ts index aa0618fd..5f2a680a 100644 --- a/src/actions/import-furni.ts +++ b/src/actions/import-furni.ts @@ -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> { - 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) +}) -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), +) diff --git a/src/app/admin/import/badges/page.tsx b/src/app/admin/import/badges/page.tsx index d6ce8661..246ec7c2 100644 --- a/src/app/admin/import/badges/page.tsx +++ b/src/app/admin/import/badges/page.tsx @@ -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') } diff --git a/src/app/admin/import/furni/page.tsx b/src/app/admin/import/furni/page.tsx index bf7ccec8..f8fb5718 100644 --- a/src/app/admin/import/furni/page.tsx +++ b/src/app/admin/import/furni/page.tsx @@ -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') } diff --git a/src/app/admin/import/repair/page.tsx b/src/app/admin/import/repair/page.tsx index 8f510512..73513e62 100644 --- a/src/app/admin/import/repair/page.tsx +++ b/src/app/admin/import/repair/page.tsx @@ -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') } diff --git a/src/app/api/admin/import/badges/edit/route.ts b/src/app/api/admin/import/badges/edit/route.ts new file mode 100644 index 00000000..ff2fc528 --- /dev/null +++ b/src/app/api/admin/import/badges/edit/route.ts @@ -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 = 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) + } +}) diff --git a/src/app/api/admin/import/badges/route.ts b/src/app/api/admin/import/badges/route.ts new file mode 100644 index 00000000..9781914a --- /dev/null +++ b/src/app/api/admin/import/badges/route.ts @@ -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() + 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() +}) + diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts new file mode 100644 index 00000000..9151a784 --- /dev/null +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -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> + 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() + 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({ + 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 } + }, + }) +}) + diff --git a/src/app/api/admin/import/clone/icon/route.ts b/src/app/api/admin/import/clone/icon/route.ts new file mode 100644 index 00000000..821e98f3 --- /dev/null +++ b/src/app/api/admin/import/clone/icon/route.ts @@ -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() + +// GET ?source=&classname= +// 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', + }, + }) +}) + diff --git a/src/app/api/admin/import/clone/route.ts b/src/app/api/admin/import/clone/route.ts new file mode 100644 index 00000000..29569b16 --- /dev/null +++ b/src/app/api/admin/import/clone/route.ts @@ -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=&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=&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 + + 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= +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 }) +}) + diff --git a/src/app/api/admin/import/clothing/batch/route.ts b/src/app/api/admin/import/clothing/batch/route.ts new file mode 100644 index 00000000..85fc6439 --- /dev/null +++ b/src/app/api/admin/import/clothing/batch/route.ts @@ -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() + const items = rawItems.filter((it) => { + if (seen.has(it.lib)) return false + seen.add(it.lib) + return true + }) + + await ensureFigureDirs() + + return runSseBatch({ + 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 } + }, + }) +}) + diff --git a/src/app/api/admin/import/clothing/route.ts b/src/app/api/admin/import/clothing/route.ts new file mode 100644 index 00000000..4b3c9e1d --- /dev/null +++ b/src/app/api/admin/import/clothing/route.ts @@ -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 }) +}) + diff --git a/src/app/api/admin/import/clothing/sets/batch/route.ts b/src/app/api/admin/import/clothing/sets/batch/route.ts new file mode 100644 index 00000000..5136fd21 --- /dev/null +++ b/src/app/api/admin/import/clothing/sets/batch/route.ts @@ -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() + const items = rawItems.filter((it) => { + const key = `${it.setType}:${it.setId}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + return runSseBatch({ + 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 } + }, + }) +}) + diff --git a/src/app/api/admin/import/clothing/sets/route.ts b/src/app/api/admin/import/clothing/sets/route.ts new file mode 100644 index 00000000..f39d6ef5 --- /dev/null +++ b/src/app/api/admin/import/clothing/sets/route.ts @@ -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) + } + 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 }) +}) + diff --git a/src/app/api/admin/import/effects/batch/route.ts b/src/app/api/admin/import/effects/batch/route.ts new file mode 100644 index 00000000..dd8afd1b --- /dev/null +++ b/src/app/api/admin/import/effects/batch/route.ts @@ -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() + const items = rawItems.filter((it) => { + if (seen.has(it.lib)) return false + seen.add(it.lib) + return true + }) + + await ensureEffectDirs() + + return runSseBatch({ + 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 } + }, + }) +}) + diff --git a/src/app/api/admin/import/effects/route.ts b/src/app/api/admin/import/effects/route.ts new file mode 100644 index 00000000..81eac7b2 --- /dev/null +++ b/src/app/api/admin/import/effects/route.ts @@ -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 }) +}) + diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts new file mode 100644 index 00000000..19a4701d --- /dev/null +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -0,0 +1,231 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' +import { withAdmin } from '@/lib/api-handler' +import { apiError } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs' +import { appendFurniEntriesBatch, buildFurniEntry, readFurniData } from '@/lib/services/furni-data' +import { downloadFile, ensureDirectories } from '@/lib/services/furni-import' +import { getHabboItFurnidata } from '@/lib/services/habbo-furnidata-cache' +import { rcon } from '@/lib/services/rcon' +import { convertSwfToNitro } from '@/lib/services/swf-to-nitro' + +interface RegenItem { + classname: string + revision: number +} + +/** + * SSE batch regenerate .nitro files. + * Streams progress events for each item. + */ +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => { + const body = await request.json() + const items: RegenItem[] = body.items || [] + const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5) + + if (items.length === 0) return apiError('No items to regenerate', 400) + + await ensureDirectories() + const { swfDir, nitroDir } = await getFurniAssetDirs() + 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 */ + } + } + + const startTime = Date.now() + send({ type: 'regen_start', total: items.length }) + + let succeeded = 0 + let failed = 0 + let skipped = 0 + const furniDataEntries: Array<{ entry: Record; itemType: string }> = [] + + // Load furnidata once for all items + const furnidata = await getHabboItFurnidata() + + // Load existing FurnitureData.json classnames for dedup + const existingFD = (await readFurniData()) as { + roomitemtypes?: { furnitype?: Array<{ classname?: string }> } + wallitemtypes?: { furnitype?: Array<{ classname?: string }> } + } + const fdClassnames = new Set([ + ...(existingFD.roomitemtypes?.furnitype || []).map((e) => e.classname), + ...(existingFD.wallitemtypes?.furnitype || []).map((e) => e.classname), + ]) + + for (let i = 0; i < items.length; i += concurrency) { + const chunk = items.slice(i, i + concurrency) + + const promises = chunk.map(async (item) => { + const starIdx = item.classname.indexOf('*') + const baseClassname = + starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname + const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`) + + if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { + skipped++ + send({ type: 'regen_progress', classname: item.classname, status: 'skipped' }) + return + } + + send({ type: 'regen_progress', classname: item.classname, status: 'downloading' }) + + // Resolve revision + let rev = item.revision || 0 + if (rev === 0) { + const fd = + furnidata.get(item.classname) || + furnidata.get(baseClassname) || + furnidata.get(`${baseClassname}*0`) + if (fd) rev = fd.revision + } + + // Try spriteId as fallback revision + const revsToTry: number[] = [] + if (rev > 0) revsToTry.push(rev) + const dbItem = await prisma.itemsBase.findFirst({ + where: { itemName: item.classname }, + select: { id: true, spriteId: true, publicName: true, type: true }, + }) + if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) { + revsToTry.push(dbItem.spriteId) + } + + if (revsToTry.length === 0) { + failed++ + send({ + type: 'regen_progress', + classname: item.classname, + status: 'failed', + message: 'No revision found', + }) + return + } + + const swfPath = path.join(swfDir, `${baseClassname}.swf`) + + // Download SWF if needed + if (!existsSync(swfPath)) { + let downloaded = false + for (const tryRev of revsToTry) { + const dl = await downloadFile( + `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`, + swfPath, + { validate: 'swf' }, + ) + if (dl.ok) { + rev = tryRev + downloaded = true + break + } + } + if (!downloaded) { + failed++ + send({ + type: 'regen_progress', + classname: item.classname, + status: 'failed', + message: `SWF not found (revs: ${revsToTry.join(', ')})`, + }) + return + } + } + + // Convert + send({ type: 'regen_progress', classname: item.classname, status: 'converting' }) + try { + const swfBuffer = await fs.readFile(swfPath) + const conversion = convertSwfToNitro(swfBuffer, baseClassname) + await fs.writeFile(nitroPath, conversion.bundle) + + // Build FurnitureData entry if missing + if (!fdClassnames.has(baseClassname) && !fdClassnames.has(item.classname)) { + const fd = furnidata.get(item.classname) || furnidata.get(baseClassname) + const itemType = dbItem?.type === 'i' ? 'i' : 's' + const entry = buildFurniEntry({ + id: dbItem?.spriteId || 0, + classname: baseClassname, + revision: rev, + category: fd?.category || '', + name: dbItem?.publicName || baseClassname, + description: fd?.description || '', + spriteId: dbItem?.spriteId || 0, + dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, + metadata: conversion.metadata || null, + habboIt: fd || null, + }) + furniDataEntries.push({ entry, itemType }) + fdClassnames.add(baseClassname) + } + + succeeded++ + send({ type: 'regen_progress', classname: item.classname, status: 'done' }) + } catch (err) { + failed++ + send({ + type: 'regen_progress', + classname: item.classname, + status: 'failed', + message: (err as Error).message, + }) + } + }) + + await Promise.allSettled(promises) + } + + // Batch write FurnitureData.json + if (furniDataEntries.length > 0) { + try { + await appendFurniEntriesBatch(furniDataEntries) + send({ + type: 'regen_info', + message: `Added ${furniDataEntries.length} entries to FurnitureData.json`, + }) + } catch (err) { + send({ + type: 'regen_info', + message: `FurnitureData.json write failed: ${(err as Error).message}`, + }) + } + } + + // RCON refresh + try { + await rcon.updateItems() + await rcon.updateCatalog() + } catch { + /* non-fatal */ + } + + send({ + type: 'regen_complete', + succeeded, + failed, + skipped, + furniDataAdded: furniDataEntries.length, + duration: Date.now() - startTime, + }) + + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +}) + diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts new file mode 100644 index 00000000..d461aead --- /dev/null +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -0,0 +1,198 @@ +import { withAdmin } from '@/lib/api-handler' +import { apiError } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { logAudit } from '@/lib/services/audit' +import { appendFurniEntriesBatch } from '@/lib/services/furni-data' +import { ensureDirectories, importSingleFurni } from '@/lib/services/furni-import' +import { rcon } from '@/lib/services/rcon' +import type { ImportSingleResult } from '@/types/furni' + +interface BatchItem { + id: number + classname: string + name: string + description: string + type: string + revision: number + category: string +} + +/** + * SSE batch import endpoint. + * Processes multiple furniture items with configurable concurrency, + * streaming real-time progress events to the client. + */ +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { + const body = await request.json() + const rawItems: BatchItem[] = body.items || [] + const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5) + + if (rawItems.length === 0) return apiError('No items to import', 400) + + // Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on + // items_base.id when the same item is listed twice in the request body. + const seen = new Set() + const items: BatchItem[] = [] + for (const it of rawItems) { + const key = `${it.id}:${it.classname}` + if (seen.has(key)) continue + seen.add(key) + items.push(it) + } + + await ensureDirectories() + 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 */ + } + } + + const startTime = Date.now() + send({ type: 'batch_start', total: items.length, concurrency }) + + let succeeded = 0 + let failed = 0 + let withWarnings = 0 + const furniDataEntries: Array<{ entry: Record; itemType: string }> = [] + + // Process in chunks of `concurrency` + for (let i = 0; i < items.length; i += concurrency) { + const chunk = items.slice(i, i + concurrency) + + const promises = chunk.map(async (item, chunkIdx) => { + const index = i + chunkIdx + send({ type: 'item_progress', classname: item.classname, status: 'started', index }) + + try { + const result: ImportSingleResult = await importSingleFurni({ + id: item.id ?? 0, + classname: item.classname, + name: item.name, + description: item.description ?? '', + type: item.type ?? 'flooritem', + revision: item.revision ?? 0, + category: item.category ?? 'unknown', + skipFurniDataWrite: true, + onProgress: (status: string) => { + send({ type: 'item_progress', classname: item.classname, status, index }) + }, + }) + + if (result.ok) { + succeeded++ + if (result.warnings.length > 0) withWarnings++ + if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry) + + send({ + type: 'item_progress', + classname: item.classname, + status: 'done', + index, + itemId: result.itemId, + warnings: result.warnings.length > 0 ? result.warnings : undefined, + }) + + logAudit({ + userId: ctx.session.user.id, + action: 'furni_import', + target: 'ItemsBase', + targetId: result.itemId!, + after: { + classname: item.classname, + name: item.name, + type: item.type === 'wallitem' ? 'i' : 's', + catalogItemId: result.catalogItemId, + dimensions: result.dimensions, + batch: true, + }, + }) + } else { + failed++ + send({ + type: 'item_progress', + classname: item.classname, + status: 'failed', + index, + message: result.error, + }) + } + } catch (err) { + failed++ + send({ + type: 'item_progress', + classname: item.classname, + status: 'failed', + index, + message: (err as Error).message, + }) + } + }) + + await Promise.allSettled(promises) + } + + // Batch write FurnitureData.json once at the end — BEFORE RCON so the + // emulator never sees items that the Nitro client can't render. + let furniDataWriteOk = true + if (furniDataEntries.length > 0) { + try { + await appendFurniEntriesBatch(furniDataEntries) + } catch (err) { + furniDataWriteOk = false + send({ + type: 'item_progress', + classname: '_batch_', + status: 'failed', + index: -1, + message: `FurnitureData.json batch write failed: ${(err as Error).message}`, + }) + } + } + + // Refresh game server caches once — skip if FurnitureData write failed + // to avoid an inconsistent state (emulator sees items, client doesn't). + if (furniDataWriteOk) { + try { + const okCat = await rcon.updateCatalog() + const okItems = await rcon.updateItems() + if (!okCat || !okItems) { + send({ + type: 'item_progress', + classname: '_rcon_', + status: 'failed', + index: -1, + message: 'RCON refresh failed — emulator cache may be stale', + }) + } + } catch (err) { + console.warn('[import-furni] RCON update failed after batch:', (err as Error).message) + } + } + + send({ + type: 'batch_complete', + succeeded, + failed, + warnings: withWarnings, + duration: Date.now() - startTime, + }) + + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +}) + diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts new file mode 100644 index 00000000..b67cd3a7 --- /dev/null +++ b/src/app/api/admin/import/furni/nitro-editor/route.ts @@ -0,0 +1,166 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' +import type { NextRequest } from 'next/server' +import { withAdmin } from '@/lib/api-handler' +import { apiError, apiOk } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { logAudit } from '@/lib/services/audit' +import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs' +import { readFurniData, withFurniDataLock, writeFurniData } from '@/lib/services/furni-data' +import { rcon } from '@/lib/services/rcon' +import { + computeDefaultDir, + createNitroBundle, + extractPartColors, + parseNitroBundle, +} from '@/lib/services/swf-to-nitro' + +function baseClassname(classname: string): string { + return classname.includes('*') ? classname.split('*')[0] : classname +} + +// ── GET: Read .nitro metadata ────────────────────────────────────── + +export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest) => { + const rawClassname = request.nextUrl.searchParams.get('classname') + if (!rawClassname) return apiError('Missing classname', 400) + + const base = baseClassname(rawClassname) + const { nitroDir } = await getFurniAssetDirs() + const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`) + + if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError('Nitro file not found', 404) + + try { + const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath) + const { json } = parseNitroBundle(buffer) + + // Fetch interaction flags from DB + const items = await prisma.$queryRaw< + Array<{ + allow_sit: string + allow_lay: string + allow_walk: string + }> + >` + SELECT allow_sit, allow_lay, allow_walk + FROM items_base WHERE item_name = ${rawClassname} LIMIT 1 + ` + + const dbRow = items[0] + const flags = { + canstandon: dbRow?.allow_walk === '1', + cansiton: dbRow?.allow_sit === '1', + canlayon: dbRow?.allow_lay === '1', + } + + return apiOk({ metadata: json, flags }) + } catch (err) { + console.error('[nitro-editor] Failed to parse .nitro:', (err as Error).message) + return apiError('Failed to parse .nitro file', 500) + } +}) + +// ── PUT: Save .nitro metadata ────────────────────────────────────── + +export const PUT = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (request: NextRequest, ctx) => { + const body = await request.json() + const { classname: rawClassname, metadata, canstandon, cansiton, canlayon } = body + + if (!rawClassname || !metadata) return apiError('Missing classname or metadata', 400) + + const base = baseClassname(rawClassname) + const { nitroDir } = await getFurniAssetDirs() + const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`) + + if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) + return apiError('Nitro file not found', 404) + + // 1. Read existing .nitro to extract PNG + const existingBuffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath) + const { png } = parseNitroBundle(existingBuffer) + + // 2. Rebuild .nitro with updated JSON + same PNG + const newBundle = createNitroBundle(metadata, png, base) + await fs.writeFile(nitroPath, newBundle) + + // 3. Extract dimensions from metadata + const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 } + const directions: number[] = metadata.logic?.model?.directions || [0] + const visualizations = metadata.visualizations || [] + + // 4. Update FurnitureData.json (best-effort) + try { + await withFurniDataLock(async () => { + const furniData = (await readFurniData()) as Record< + string, + { furnitype: Array> } + > + + for (const section of ['roomitemtypes', 'wallitemtypes'] as const) { + const types = furniData[section]?.furnitype + if (!Array.isArray(types)) continue + const idx = types.findIndex((e) => e.classname === rawClassname) + if (idx !== -1) { + types[idx].xdim = dims.x + types[idx].ydim = dims.y + types[idx].defaultdir = computeDefaultDir(directions) + types[idx].partcolors = extractPartColors(visualizations) + types[idx].canstandon = canstandon ?? false + types[idx].cansiton = cansiton ?? false + types[idx].canlayon = canlayon ?? false + break + } + } + + await writeFurniData(furniData as Record) + }) + } catch (err) { + console.warn('[nitro-editor] FurnitureData.json update failed:', (err as Error).message) + } + + // 5. Update items_base (best-effort) + try { + await prisma.$executeRaw` + UPDATE items_base + SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z}, + allow_sit = ${cansiton ? '1' : '0'}, + allow_lay = ${canlayon ? '1' : '0'}, + allow_walk = ${canstandon ? '1' : '0'} + WHERE item_name = ${rawClassname} + ` + } catch (err) { + console.warn('[nitro-editor] items_base update failed:', (err as Error).message) + } + + // 6. RCON refresh (best-effort) + try { + await rcon.updateItems() + } catch (err) { + console.warn('[nitro-editor] RCON updateItems failed:', (err as Error).message) + } + + // 7. Audit log + try { + const dbItem = await prisma.itemsBase.findFirst({ + where: { itemName: rawClassname }, + select: { id: true }, + }) + await logAudit({ + userId: ctx.session.user.id, + action: 'furni_edit_nitro', + target: 'ItemsBase', + targetId: dbItem?.id ?? 0, + after: { classname: rawClassname, dimensions: dims, canstandon, cansiton, canlayon }, + }) + } catch (err) { + console.warn('[nitro-editor] Audit log failed:', (err as Error).message) + } + + return apiOk({ dimensions: dims }) + }, +) + diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts new file mode 100644 index 00000000..ce91f504 --- /dev/null +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -0,0 +1,203 @@ +import { existsSync } from 'node:fs' +import { withAdmin } from '@/lib/api-handler' +import { apiOk } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { + appendFurniEntriesBatch, + buildFurniEntry, + getFurnitureDataPath, + readFurniData, +} from '@/lib/services/furni-data' +import { lookupHabboIt } from '@/lib/services/habbo-furnidata-cache' +import { rcon } from '@/lib/services/rcon' + +/** + * POST /api/admin/import/furni/resync + * + * Modes (query params, pick one): + * ?days=N — items imported in the last N days (from admin_audit_log). + * ?missing=1 — every items_base row whose classname is absent from + * FurnitureData.json (authoritative for "what's broken"). + * ?all=1 — every items_base row (rebuilds entries for all imports). + * + * Default: ?days=7 + * + * Entries are upserted by classname so running this repeatedly is safe. + * Triggers RCON updateCatalog + updateItems at the end. + */ +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => { + const qp = request.nextUrl.searchParams + const mode: 'days' | 'missing' | 'broken' | 'all' = + qp.get('all') === '1' + ? 'all' + : qp.get('broken') === '1' + ? 'broken' + : qp.get('missing') === '1' + ? 'missing' + : 'days' + + const days = Math.max(1, Math.min(parseInt(qp.get('days') || '7', 10) || 7, 365)) + + const targetIds = await resolveTargetIds(mode, days) + + if (targetIds.length === 0) { + return apiOk({ + mode, + days: mode === 'days' ? days : undefined, + examined: 0, + resynced: 0, + failed: 0, + errors: [], + }) + } + + // Process in chunks to keep memory flat and make progress visible. + const CHUNK = 500 + let resynced = 0 + const errors: Array<{ classname: string; message: string }> = [] + + for (let i = 0; i < targetIds.length; i += CHUNK) { + const slice = targetIds.slice(i, i + CHUNK) + const rows = await prisma.itemsBase.findMany({ + where: { id: { in: slice } }, + select: { + id: true, + spriteId: true, + itemName: true, + publicName: true, + type: true, + width: true, + length: true, + stackHeight: true, + }, + }) + + const entries: Array<{ entry: Record; itemType: string }> = [] + for (const row of rows) { + try { + const classname = row.itemName + let habboIt = null + try { + habboIt = await lookupHabboIt(classname) + } catch { + /* optional */ + } + + const entry = buildFurniEntry({ + id: row.id, + classname, + revision: habboIt?.revision ?? 0, + category: habboIt?.category || 'unknown', + name: row.publicName || habboIt?.name || classname, + description: habboIt?.description || '', + spriteId: row.spriteId, + dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)) }, + metadata: null, + habboIt, + }) + + entries.push({ entry, itemType: row.type === 'i' ? 'i' : 's' }) + } catch (err) { + errors.push({ classname: row.itemName, message: (err as Error).message }) + } + } + + if (entries.length > 0) { + try { + await appendFurniEntriesBatch(entries) + resynced += entries.length + } catch (err) { + errors.push({ + classname: `_chunk_${i}_${i + slice.length}_`, + message: `FurnitureData.json write failed: ${(err as Error).message}`, + }) + break + } + } + } + + let rconOk = false + try { + const a = await rcon.updateCatalog() + const b = await rcon.updateItems() + rconOk = a && b + } catch { + /* logged in rcon service */ + } + + return apiOk({ + mode, + days: mode === 'days' ? days : undefined, + examined: targetIds.length, + resynced, + failed: errors.length, + rconOk, + errors: errors.slice(0, 50), + }) +}) + +async function resolveTargetIds( + mode: 'days' | 'missing' | 'broken' | 'all', + days: number, +): Promise { + if (mode === 'all') { + const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: 'asc' } }) + return rows.map((r) => r.id) + } + + if (mode === 'missing' || mode === 'broken') { + const furniDataPath = await getFurnitureDataPath() + if (!existsSync(furniDataPath)) { + const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: 'asc' } }) + return rows.map((r) => r.id) + } + const furniData = (await readFurniData()) as { + roomitemtypes?: { furnitype: Array> } + wallitemtypes?: { furnitype: Array> } + } + const byClassname = new Map>() + for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) { + for (const e of section?.furnitype ?? []) { + if (typeof e?.classname === 'string') byClassname.set(e.classname, e) + } + } + const rows = await prisma.itemsBase.findMany({ + select: { id: true, itemName: true, spriteId: true, width: true, length: true }, + orderBy: { id: 'asc' }, + }) + + if (mode === 'missing') { + return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id) + } + + // broken: entry present but inconsistent with items_base + return rows + .filter((r) => { + const e = byClassname.get(r.itemName) + if (!e) return true + const entryId = Number(e.id ?? -1) + if (entryId !== r.id && entryId !== r.spriteId) return true + const xdim = Number(e.xdim ?? 0) + const ydim = Number(e.ydim ?? 0) + if (xdim <= 0 || ydim <= 0) return true + if (xdim !== r.width || ydim !== r.length) return true + return false + }) + .map((r) => r.id) + } + + // mode === 'days' + const sinceIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() + const sinceMysql = sinceIso.slice(0, 19).replace('T', ' ') + const auditRows = await prisma.$queryRaw>` + SELECT DISTINCT target_id + FROM admin_audit_log + WHERE action = 'furni_import' + AND target = 'ItemsBase' + AND target_id IS NOT NULL + AND created_at >= ${sinceMysql} + ORDER BY target_id ASC + ` + return auditRows.map((r) => r.target_id).filter(Boolean) +} diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts new file mode 100644 index 00000000..e939d7c0 --- /dev/null +++ b/src/app/api/admin/import/furni/route.ts @@ -0,0 +1,713 @@ +import { existsSync, 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 { prisma } from '@/lib/prisma' +import { logAudit } from '@/lib/services/audit' +import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs' +import { + autoPriceFurni, + CATEGORY_PAGE, + classifyFurni, + cleanupConvertedSwfs, + deleteImportedItem, + downloadFile, + ensureDirectories, + IMPORTED_PAGE_CAPTION_SAVE, + importSingleFurni, +} from '@/lib/services/furni-import' +import { getHabboItFurnidata } from '@/lib/services/habbo-furnidata-cache' +import { listFurniture } from '@/lib/services/habbofurni' +import { rcon } from '@/lib/services/rcon' +import { convertSwfToNitro } from '@/lib/services/swf-to-nitro' + +// Re-export for batch route backward compatibility +export { ensureDirectories, importSingleFurni } from '@/lib/services/furni-import' +export type { ImportSingleResult } from '@/types/furni' + +// ── Delete imported item or clean up SWF files ────────────────────── +export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { + const classname = request.nextUrl.searchParams.get('classname') + + // ── Delete specific imported item ───────────────────────────────── + if (classname) { + try { + const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname) + + // Refresh game server caches + try { + await rcon.updateCatalog() + await rcon.updateItems() + } catch (err) { + errors.push(`RCON: ${(err as Error).message}`) + } + + logAudit({ + userId: ctx.session.user.id, + action: 'furni_delete', + target: 'ItemsBase', + targetId: spriteId, + after: { classname, deletedFiles, errors: errors.length > 0 ? errors : undefined }, + }) + + return apiOk({ + classname, + deletedFiles, + errors: errors.length > 0 ? errors : undefined, + }) + } catch (err) { + return apiError((err as Error).message, 404) + } + } + + // ── Clean up SWF files that have been converted to Nitro ────────── + const result = await cleanupConvertedSwfs() + + if (result.total === 0 && result.deleted === 0) { + return apiOk({ deleted: 0, message: 'SWF directory does not exist' }) + } + + return apiOk({ + deleted: result.deleted, + total: result.total, + remaining: result.remaining, + errors: result.errors.length > 0 ? result.errors : undefined, + }) +}) + +// ── Search furni via official Habbo furnidata ─────────────────────── +export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => { + const { nitroDir } = await getFurniAssetDirs() + // Stats endpoint + const action = request.nextUrl.searchParams.get('action') + if (action === 'stats') { + const [totalResult, catalogResult, nitroMissingResult] = await Promise.all([ + prisma.$queryRaw<[{ cnt: bigint }]>`SELECT COUNT(*) as cnt FROM items_base`, + prisma.$queryRaw< + [{ cnt: bigint }] + >`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, + prisma.$queryRaw< + [{ cnt: bigint }] + >`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`, + ]) + + const totalInDb = Number(totalResult[0].cnt) + const inCatalog = Number(catalogResult[0].cnt) + const notInCatalog = Number(nitroMissingResult[0].cnt) + + // Count missing .nitro files by checking filesystem + const importedItems = await prisma.$queryRaw>` + SELECT ib.item_name FROM items_base ib + INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name + LIMIT 10000 + ` + + let missingNitro = 0 + for (const item of importedItems) { + const starIdx = item.item_name.indexOf('*') + const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name + const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`) + if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++ + } + + return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro }) + } + + // Missing nitro endpoint: returns only imported items missing .nitro files + if (action === 'missing-nitro') { + const pageNum = parseInt(request.nextUrl.searchParams.get('page') || '1', 10) + const perPage = 50 + + const importedItems = await prisma.$queryRaw< + Array<{ + item_name: string + public_name: string + sprite_id: number + type: string + }> + >` + SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib + INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name + ` + + // Filter to only those missing .nitro on filesystem + const missing = importedItems.filter((item) => { + const starIdx = item.item_name.indexOf('*') + const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name + const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`) + return !existsSync(/*turbopackIgnore: true*/ p) + }) + + const total = missing.length + const lastPage = Math.max(Math.ceil(total / perPage), 1) + const start = (pageNum - 1) * perPage + const pageItems = missing.slice(start, start + perPage) + + // Enrich with revision from official furnidata + const furnidata = await getHabboItFurnidata() + const items = pageItems.map((item) => { + const fd = + furnidata.get(item.item_name) || + furnidata.get(item.item_name.split('*')[0]) || + furnidata.get(`${item.item_name}*0`) + return { + id: item.sprite_id, + classname: item.item_name, + name: item.public_name || item.item_name, + description: fd?.description || '', + type: item.type === 'i' ? 'wallitem' : 'flooritem', + revision: fd?.revision || 0, + category: fd?.category || fd?.furniline || '', + alreadyImported: true, + nitroExists: false, + } + }) + + return apiOk({ + items, + meta: { currentPage: pageNum, lastPage, total, perPage }, + }) + } + + const search = request.nextUrl.searchParams.get('search') || '' + const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10) + + // Pull every imported classname once so we can both (a) sort non-imported + // items to the top *before* pagination and (b) skip the per-page lookup + // below. Single-column indexed query — fast even at 10k+ rows. + const allImported = await prisma.itemsBase.findMany({ + select: { itemName: true }, + }) + const importedSet = new Set(allImported.map((e) => e.itemName).filter((n): n is string => !!n)) + + const result = await listFurniture({ + search: search || undefined, + page, + perPage: 50, + importedSet, + }) + + const enriched = result.items.map((item) => { + const imported = importedSet.has(item.classname) + let nitroExists = false + if (imported) { + const starIdx = item.classname.indexOf('*') + const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname + const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`) + nitroExists = existsSync(/*turbopackIgnore: true*/ p) + } + return { ...item, alreadyImported: imported, nitroExists } + }) + + return apiOk({ items: enriched, meta: result.meta }) +}) + +// ── Import furni (single or batch) ────────────────────────────────── +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { + const body = await request.json() + + await ensureDirectories() + + // ── Batch import ───────────────────────────────────────────────── + if (Array.isArray(body.items)) { + const results: Array<{ + classname: string + ok: boolean + itemId?: number + catalogItemId?: number | null + dimensions?: { x: number; y: number; z: number } + spriteCount?: number + warnings: string[] + error?: string + }> = [] + + for (const item of body.items) { + if (!item.classname || !item.name) { + results.push({ + classname: item.classname || 'unknown', + ok: false, + warnings: [], + error: 'Missing classname or name', + }) + continue + } + + try { + const result = await importSingleFurni({ + id: item.id ?? 0, + classname: item.classname, + name: item.name, + description: item.description ?? '', + type: item.type ?? 'flooritem', + revision: item.revision ?? 0, + category: item.category ?? 'unknown', + }) + + results.push({ classname: item.classname, ...result }) + + if (result.ok) { + logAudit({ + userId: ctx.session.user.id, + action: 'furni_import', + target: 'ItemsBase', + targetId: result.itemId!, + after: { + classname: item.classname, + name: item.name, + type: item.type === 'wallitem' ? 'i' : 's', + catalogItemId: result.catalogItemId, + dimensions: result.dimensions, + batch: true, + warnings: result.warnings.length > 0 ? result.warnings : undefined, + }, + }) + } + } catch (err) { + results.push({ + classname: item.classname, + ok: false, + warnings: [], + error: (err as Error).message, + }) + } + } + + // Refresh caches once after batch + try { + await rcon.updateCatalog() + await rcon.updateItems() + } catch (err) { + console.warn('[import-furni] RCON update failed after batch:', (err as Error).message) + } + + const succeeded = results.filter((r) => r.ok).length + const failed = results.filter((r) => !r.ok).length + + return apiOk({ batch: true, succeeded, failed, results }) + } + + // ── Single import ──────────────────────────────────────────────── + const { id, classname, name, description, type, revision, category } = body + + if (!classname || !name) return apiError('Missing classname or name', 400) + + const result = await importSingleFurni({ + id: id ?? 0, + classname, + name, + description: description ?? '', + type: type ?? 'flooritem', + revision: revision ?? 0, + category: category ?? 'unknown', + }) + + if (!result.ok) return apiError(result.error!, 409) + + // Refresh game server caches + try { + await rcon.updateCatalog() + await rcon.updateItems() + } catch (err) { + console.warn('[import-furni] RCON update failed:', (err as Error).message) + result.warnings.push('RCON cache refresh failed') + } + + logAudit({ + userId: ctx.session.user.id, + action: 'furni_import', + target: 'ItemsBase', + targetId: result.itemId!, + after: { + classname, + name, + spriteId: id ?? 0, + type: type === 'wallitem' ? 'i' : 's', + catalogItemId: result.catalogItemId, + dimensions: result.dimensions, + spriteCount: result.spriteCount, + warnings: result.warnings.length > 0 ? result.warnings : undefined, + }, + }) + + return apiOk({ + id: result.itemId, + catalogItemId: result.catalogItemId, + dimensions: result.dimensions, + spriteCount: result.spriteCount, + warnings: result.warnings.length > 0 ? result.warnings : undefined, + }) +}) + +// ── Re-generate missing .nitro files (PATCH) ───────────────────────── +export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => { + const body = await request.json() + const items: Array<{ classname: string; revision: number }> = body.items || [] + + if (items.length === 0) return apiError('No items provided', 400) + + await ensureDirectories() + const { swfDir, nitroDir } = await getFurniAssetDirs() + + const results: Array<{ classname: string; ok: boolean; warning?: string }> = [] + + for (const item of items) { + const starIdx = item.classname.indexOf('*') + const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname + const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`) + + // Skip if .nitro already exists + if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { + results.push({ classname: item.classname, ok: true, warning: 'Already exists' }) + continue + } + + // Resolve revision: try furnidata first, then spriteId as fallback + let rev = item.revision || 0 + if (rev === 0) { + const furnidata = await getHabboItFurnidata() + const fd = + furnidata.get(item.classname) || + furnidata.get(baseClassname) || + furnidata.get(`${baseClassname}*0`) + if (fd) rev = fd.revision + } + + const swfPath = path.join(swfDir, `${baseClassname}.swf`) + + // Download SWF if not present — try multiple revision candidates + if (!existsSync(swfPath)) { + // Build list of revisions to try: known rev, spriteId, common fallbacks + const spriteId = await prisma.itemsBase.findFirst({ + where: { itemName: item.classname }, + select: { spriteId: true }, + }) + const revsToTry = [...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0))] + + let downloaded = false + for (const tryRev of revsToTry) { + const dl = await downloadFile( + `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`, + swfPath, + { validate: 'swf' }, + ) + if (dl.ok) { + rev = tryRev + downloaded = true + break + } + } + + if (!downloaded) { + results.push({ + classname: item.classname, + ok: false, + warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(', ') || 'none'})`, + }) + continue + } + } + + // Convert SWF → Nitro + update FurnitureData.json + try { + const swfBuffer = await fs.readFile(swfPath) + const conversion = convertSwfToNitro(swfBuffer, baseClassname) + await fs.writeFile(nitroPath, conversion.bundle) + + // Add to FurnitureData.json if not already present + try { + const { buildFurniEntry, appendFurniEntry, readFurniData } = await import( + '@/lib/services/furni-data' + ) + const furniData = (await readFurniData()) as { + roomitemtypes?: { furnitype?: Array<{ classname?: string }> } + wallitemtypes?: { furnitype?: Array<{ classname?: string }> } + } + + // Check if already in FurnitureData + const allEntries = [ + ...(furniData.roomitemtypes?.furnitype || []), + ...(furniData.wallitemtypes?.furnitype || []), + ] + const alreadyInFD = allEntries.some( + (e) => e.classname === baseClassname || e.classname === item.classname, + ) + + if (!alreadyInFD) { + const dbItem = await prisma.itemsBase.findFirst({ + where: { itemName: item.classname }, + select: { id: true, spriteId: true, publicName: true, type: true }, + }) + const furnidata = await getHabboItFurnidata() + const fd = furnidata.get(item.classname) || furnidata.get(baseClassname) + const itemType = dbItem?.type === 'i' ? 'i' : 's' + + const entry = buildFurniEntry({ + id: dbItem?.spriteId || 0, + classname: baseClassname, + revision: rev, + category: fd?.category || '', + name: dbItem?.publicName || baseClassname, + description: fd?.description || '', + spriteId: dbItem?.spriteId || 0, + dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, + metadata: conversion.metadata || null, + habboIt: fd || null, + }) + await appendFurniEntry(entry, itemType) + } + } catch (fdErr) { + console.warn( + `[regen] FurnitureData update skipped for ${item.classname}:`, + (fdErr as Error).message, + ) + } + + results.push({ classname: item.classname, ok: true }) + } catch (err) { + results.push({ classname: item.classname, ok: false, warning: (err as Error).message }) + } + } + + const succeeded = results.filter((r) => r.ok).length + const failed = results.filter((r) => !r.ok).length + + return apiOk({ succeeded, failed, results }) +}) + +// ── Re-classify ALL imported items across all sub-pages + update prices (PUT) ── +// Supports ?dryrun=1 to preview changes without applying them. +export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { + const isDryRun = request.nextUrl.searchParams.get('dryrun') === '1' + + // 1. Find the root "imported_furni" page + const parentPage = await prisma.catalogPages.findFirst({ + where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, + select: { id: true }, + }) + if (!parentPage) { + return apiOk({ + dryRun: isDryRun, + moved: 0, + repriced: 0, + message: 'No imported_furni page found — nothing to reorganize', + }) + } + + // 2. Collect ALL imported page IDs (parent + every imp_* sub-page) + const subPages = await prisma.catalogPages.findMany({ + where: { parentId: parentPage.id }, + select: { id: true, captionSave: true }, + }) + const allImportedPageIds = [String(parentPage.id), ...subPages.map((p) => String(p.id))] + + // 3. Find ALL catalog_items across all imported pages + // page_id is VARCHAR in DB so we use string placeholders + const inPlaceholders = allImportedPageIds.map(() => '?').join(',') + const allItems = await prisma.$queryRawUnsafe< + Array<{ + id: number + item_ids: string + catalog_name: string + page_id: string + cost_credits: number + }> + >( + `SELECT id, item_ids, catalog_name, page_id, cost_credits FROM catalog_items WHERE page_id IN (${inPlaceholders})`, + ...allImportedPageIds, + ) + + if (allItems.length === 0) { + return apiOk({ + dryRun: isDryRun, + moved: 0, + repriced: 0, + message: 'No imported items found — nothing to reorganize', + }) + } + + // 4. Build classname → type map from items_base + const uniqueItemIds = [...new Set(allItems.map((i) => Number(i.item_ids)))].filter( + (n) => !Number.isNaN(n), + ) + const dbItems = + uniqueItemIds.length > 0 + ? await prisma.itemsBase.findMany({ + where: { id: { in: uniqueItemIds } }, + select: { id: true, itemName: true, type: true }, + }) + : [] + const typeMap = new Map() + for (const di of dbItems) { + if (di.itemName) typeMap.set(di.itemName, di.type || 's') + } + + // 5. Pre-load category page map (captionSave → pageId) to avoid N+1 queries + const categoryPageMap = new Map() + for (const sp of subPages) { + if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id) + } + + // Helper: resolve or create category page using the cached map + async function resolvePageId(catKey: string): Promise { + const captionSave = `imp_${catKey}` + const cached = categoryPageMap.get(captionSave) + if (cached !== undefined) return String(cached) + + if (isDryRun) return 'new' + + // Create the page (only happens once per new category) + const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other + const parentId = parentPage!.id + await prisma.$executeRaw` + INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) + VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') + ` + const created = await prisma.catalogPages.findFirst({ + where: { captionSave, parentId }, + select: { id: true }, + }) + categoryPageMap.set(captionSave, created!.id) + return String(created!.id) + } + + // 6. Classify all items (pure computation + cached lookups, no per-item DB queries) + let moved = 0 + let repriced = 0 + let skipped = 0 + const errors: string[] = [] + const categoryCounts: Record = {} + const pagesWithItems = new Set() + + // Collect batched updates: key = "pageId|credits|points|pointsType" → item ids + const updateBatches = new Map() + + // First pass: resolve any new category pages (max ~14 DB inserts) + const neededCategories = new Set() + for (const item of allItems) { + const itemType = typeMap.get(item.catalog_name) || 's' + neededCategories.add(classifyFurni(item.catalog_name, itemType)) + } + for (const catKey of neededCategories) { + try { + await resolvePageId(catKey) + } catch (err) { + errors.push(`create page ${catKey}: ${(err as Error).message}`) + } + } + + // Second pass: classify items (no DB calls) + for (const item of allItems) { + const itemType = typeMap.get(item.catalog_name) || 's' + const catKey = classifyFurni(item.catalog_name, itemType) + categoryCounts[catKey] = (categoryCounts[catKey] || 0) + 1 + + const captionSave = `imp_${catKey}` + const cachedPageId = categoryPageMap.get(captionSave) + const correctPageIdStr = cachedPageId !== undefined ? String(cachedPageId) : 'new' + + const price = autoPriceFurni(item.catalog_name) + const needsMove = item.page_id !== correctPageIdStr + const needsReprice = item.cost_credits !== price.credits + + pagesWithItems.add(correctPageIdStr) + + if (!needsMove && !needsReprice) { + skipped++ + continue + } + + if (needsMove) moved++ + if (needsReprice) repriced++ + + if (!isDryRun) { + const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}` + const batch = updateBatches.get(batchKey) + if (batch) { + batch.push(item.id) + } else { + updateBatches.set(batchKey, [item.id]) + } + } + } + + // 7. Apply batched updates (one UPDATE per category/price combo instead of per item) + if (!isDryRun) { + for (const [batchKey, ids] of updateBatches) { + const [pageId, credits, points, pointsType] = batchKey.split('|') + try { + const idPlaceholders = ids.map(() => '?').join(',') + await prisma.$queryRawUnsafe( + `UPDATE catalog_items SET page_id = ?, cost_credits = ?, cost_points = ?, points_type = ? WHERE id IN (${idPlaceholders})`, + pageId, + Number(credits), + Number(points), + Number(pointsType), + ...ids, + ) + } catch (err) { + errors.push(`batch update: ${(err as Error).message}`) + } + } + } + + // 8. Cleanup empty sub-pages (only if NOT dry-run) + let pagesDeleted = 0 + if (!isDryRun) { + for (const subPage of subPages) { + const subPageIdStr = String(subPage.id) + if (!pagesWithItems.has(subPageIdStr)) { + try { + const remaining = await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) as cnt FROM catalog_items WHERE page_id = ?`, + subPageIdStr, + ) + const count = Number(remaining[0]?.cnt ?? 0) + if (count === 0) { + await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}` + pagesDeleted++ + } + } catch (err) { + errors.push(`cleanup ${subPage.captionSave}: ${(err as Error).message}`) + } + } + } + } + + // 9. Refresh game server caches (only if NOT dry-run and changes were made) + if (!isDryRun && (moved > 0 || repriced > 0 || pagesDeleted > 0)) { + try { + await rcon.updateCatalog() + } catch (err) { + errors.push(`RCON: ${(err as Error).message}`) + } + } + + if (!isDryRun) { + logAudit({ + userId: ctx.session.user.id, + action: 'furni_reorganize', + target: 'CatalogItems', + targetId: parentPage.id, + after: { + moved, + repriced, + skipped, + pagesDeleted, + categoryCounts, + errors: errors.length > 0 ? errors : undefined, + }, + }) + } + + return apiOk({ + dryRun: isDryRun, + moved, + repriced, + skipped, + pagesDeleted, + total: allItems.length, + categoryCounts, + errors: errors.length > 0 ? errors : undefined, + }) +}) diff --git a/src/app/api/admin/import/pets/batch/route.ts b/src/app/api/admin/import/pets/batch/route.ts new file mode 100644 index 00000000..0491892a --- /dev/null +++ b/src/app/api/admin/import/pets/batch/route.ts @@ -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() + const items = rawItems.filter((it) => { + if (seen.has(it.lib)) return false + seen.add(it.lib) + return true + }) + + await ensurePetDirs() + + return runSseBatch({ + 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 } + }, + }) +}) + diff --git a/src/app/api/admin/import/pets/icon/route.ts b/src/app/api/admin/import/pets/icon/route.ts new file mode 100644 index 00000000..76a185d7 --- /dev/null +++ b/src/app/api/admin/import/pets/icon/route.ts @@ -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', + }, + }) +}) + diff --git a/src/app/api/admin/import/pets/route.ts b/src/app/api/admin/import/pets/route.ts new file mode 100644 index 00000000..06cdc2f4 --- /dev/null +++ b/src/app/api/admin/import/pets/route.ts @@ -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 }) +}) + diff --git a/src/app/api/admin/import/repair/audit/route.ts b/src/app/api/admin/import/repair/audit/route.ts new file mode 100644 index 00000000..f454d233 --- /dev/null +++ b/src/app/api/admin/import/repair/audit/route.ts @@ -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) +}) + diff --git a/src/app/api/admin/import/repair/route.ts b/src/app/api/admin/import/repair/route.ts new file mode 100644 index 00000000..2780ba11 --- /dev/null +++ b/src/app/api/admin/import/repair/route.ts @@ -0,0 +1,128 @@ +import { withAdmin } from '@/lib/api-handler' +import { apiError } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { repairItem } from '@/lib/services/furni-repair' +import { rcon } from '@/lib/services/rcon' + +/** + * SSE batch repair route. + * Body: { classnames: string[], concurrency?: number } + * Streams one `data: {...}` event per item. + */ +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => { + const body = await request.json() + const classnames: string[] = Array.isArray(body.classnames) ? body.classnames : [] + // Bumped default to 6 (cap 8). Since downloadFile no longer retries on + // deterministic 404s, misses complete in ~1 request instead of ~4 × 15s. + const concurrency = Math.min(Math.max(body.concurrency || 6, 1), 8) + + if (classnames.length === 0) return apiError('No classnames provided', 400) + + 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 */ + } + } + + const startTime = Date.now() + send({ type: 'repair_start', total: classnames.length }) + + let succeeded = 0 + let failed = 0 + let partial = 0 + let unrepairable = 0 + + for (let i = 0; i < classnames.length; i += concurrency) { + const chunk = classnames.slice(i, i + concurrency) + + const promises = chunk.map(async (classname) => { + send({ type: 'repair_progress', classname, status: 'working' }) + try { + const result = await repairItem(classname) + if (result.unrepairable) unrepairable++ + if (result.ok) { + succeeded++ + send({ + type: 'repair_progress', + classname, + status: 'done', + nitroRepaired: result.nitroRepaired, + iconRepaired: result.iconRepaired, + warnings: result.warnings, + }) + } else { + // Counts as partial if at least one asset was fixed + if (result.iconRepaired || result.nitroRepaired) { + partial++ + send({ + type: 'repair_progress', + classname, + status: 'partial', + nitroRepaired: result.nitroRepaired, + iconRepaired: result.iconRepaired, + warnings: result.warnings, + error: result.error, + unrepairable: result.unrepairable, + }) + } else { + failed++ + send({ + type: 'repair_progress', + classname, + status: 'failed', + warnings: result.warnings, + error: result.error || result.warnings[0] || 'Unknown error', + unrepairable: result.unrepairable, + }) + } + } + } catch (err) { + failed++ + send({ + type: 'repair_progress', + classname, + status: 'failed', + error: (err as Error).message, + }) + } + }) + + await Promise.allSettled(promises) + } + + // RCON refresh so the emulator picks up any items_base changes + try { + await rcon.updateItems() + await rcon.updateCatalog() + } catch { + /* non-fatal */ + } + + send({ + type: 'repair_complete', + succeeded, + partial, + failed, + unrepairable, + duration: Date.now() - startTime, + }) + + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +}) + diff --git a/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts b/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts new file mode 100644 index 00000000..f3b5c2db --- /dev/null +++ b/src/app/api/nitro-assets/bundled/furniture/[...path]/route.ts @@ -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 }) +}