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,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 })
},
)