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 { z } from 'zod'
import { prisma } from "@/lib/prisma"; import { PERMS } from '@/lib/permissions'
import type { ActionResult } from "@/lib/safe-action-shared"; 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 }>> { export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
try { const result = await cleanupConvertedSwfs()
await requireStaff(); return actionOk(result as unknown as Record<string, unknown>)
return { ok: true, data: { deleted: 0, remaining: 0 } }; })
} catch {
return { ok: false, error: "Failed to clean SWF files" };
}
}
export async function deleteImportedFurni({ classname }: { classname: string }) { const deleteSchema = z.object({ classname: z.string().trim().min(1) })
await requireStaff();
try { export const deleteImportedFurni = adminAction(
await prisma.itemsBase.deleteMany({ where: { itemName: classname } }); { permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
return { ok: true as const, data: { deletedFiles: [classname], errors: [] as string[] } }; async (ctx) => actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
} catch { )
return { ok: false as const, error: `Failed to delete ${classname}` };
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { ImportBadgesClient } from './import-badges-client'
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) { export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext() 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') 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 }> }) { export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext() 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') redirect('/admin')
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { RepairClient } from './repair-client'
export default async function RepairImportedPage() { export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext() 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') 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 })
})
@@ -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<string, unknown>; 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',
},
})
})
@@ -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<string>()
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<string, unknown>; 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',
},
})
})
@@ -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<Record<string, unknown>> }
>
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<string, unknown>)
})
} 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 })
},
)
@@ -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<string, unknown>; 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<number[]> {
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<Record<string, unknown>> }
wallitemtypes?: { furnitype: Array<Record<string, unknown>> }
}
const byClassname = new Map<string, Record<string, unknown>>()
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<Array<{ target_id: number }>>`
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)
}
+713
View File
@@ -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<Array<{ item_name: string }>>`
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<string, string>()
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<string, number>()
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<string> {
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<string, number> = {}
const pagesWithItems = new Set<string>()
// Collect batched updates: key = "pageId|credits|points|pointsType" → item ids
const updateBatches = new Map<string, number[]>()
// First pass: resolve any new category pages (max ~14 DB inserts)
const neededCategories = new Set<string>()
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<Array<{ cnt: bigint }>>(
`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,
})
})
@@ -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>)
})
+128
View File
@@ -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',
},
})
})
@@ -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 })
}