feat: connect guarded asset import api

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

No files matched your search

@@ -0,0 +1,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)
}