import { promises as fs } from "node:fs"; import path from "node:path"; import { eq, inArray, sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; import { logger } from "@/lib/logger"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; import type { CloneSource } from "@/lib/services/clone-sources"; import { fetchWithFlareSolver } from "@/lib/services/flare-solver"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntry } from "@/lib/services/furni-data"; import { allocateCatalogItemId, autoPriceFurni, ensureDirectories, getOrCreateCategoryPage, } from "@/lib/services/furni-import"; import { browserHeaders } from "@/lib/services/import/core/browser-headers"; import { downloadFile } from "@/lib/services/import/core/download"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; export interface SourceFurni { id: number; classname: string; name: string; description: string; xdim: number; ydim: number; canstandon: boolean; cansiton: boolean; canlayon: boolean; customparams: string; itemType: "s" | "i"; [k: string]: unknown; } export interface CloneResult { ok: boolean; classname: string; skipped?: boolean; warnings: string[]; error?: string; } interface FurniType { furnitype?: Array>; } interface RemoteFurnidata { roomitemtypes?: FurniType; wallitemtypes?: FurniType; furnitype?: Array>; } // Spread ...raw first so the coerced typed fields always win over raw values. function toSourceFurni( raw: Record, itemType: "s" | "i", ): SourceFurni { return { ...raw, id: Number(raw.id ?? 0), classname: String(raw.classname ?? ""), name: String(raw.name ?? raw.classname ?? ""), description: String(raw.description ?? ""), xdim: Number(raw.xdim ?? 1), ydim: Number(raw.ydim ?? 1), canstandon: raw.canstandon === true, cansiton: raw.cansiton === true, canlayon: raw.canlayon === true, customparams: String(raw.customparams ?? ""), itemType, }; } export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] { const out: SourceFurni[] = []; for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? []) out.push(toSourceFurni(r, "s")); for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, "i")); return out.filter((e) => e.classname); } const cache = new Map(); const TTL = 5 * 60 * 1000; export async function fetchSourceFurnidata( url: string, now = Date.now(), ): Promise { const hit = cache.get(url); if (hit && now && now - hit.ts < TTL) return hit.list; let body: string; try { const res = await fetch(url, { signal: AbortSignal.timeout(30000), headers: browserHeaders(), }); if ( res.ok && res.headers.get("content-type")?.includes("application/json") ) { body = await res.text(); } else { body = await fetchWithFlareSolver(url); } } catch { body = await fetchWithFlareSolver(url); } let list: SourceFurni[]; try { const json = JSON.parse(body); list = parseFurnidata(json); } catch (err) { throw new Error( `furnidata parse failed for ${url}: ${(err as Error).message}`, ); } cache.set(url, { list, ts: now }); return list; } /** * Allocate the next `items_base.id` through a process-level async mutex so * concurrent clones in the same Node process cannot race on MAX(id)+1. * Mirrors the `allocateCatalogItemId` pattern from furni-import.ts. */ let itemsBaseIdAllocChain: Promise = Promise.resolve(); async function allocateItemsBaseId( insertFn: (nextId: number) => Promise, ): Promise { const prev = itemsBaseIdAllocChain; let settle!: () => void; itemsBaseIdAllocChain = new Promise((r) => { settle = r; }); await prev.catch(() => {}); try { const [idRows] = (await db.execute(sql` SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base `)) as unknown as [Array<{ next: number }>, unknown]; const nextId = Number(idRows[0]?.next ?? 1); return await insertFn(nextId); } finally { settle(); } } export async function cloneSingleFurni(params: { source: CloneSource; entry: SourceFurni; onProgress?: (status: string) => void; }): Promise { const { source, entry, onProgress } = params; const { classname, itemType } = entry; const warnings: string[] = []; // Path-traversal guard: classname comes from remote furnidata and is used to // build file paths — reject anything that doesn't look like a safe furni name. if (!/^[\w\-.*]+$/.test(classname)) { return { ok: false, classname, warnings, error: "invalid classname" }; } // Dedup by classname. const existing = await db .select({ id: ItemsBase.id }) .from(ItemsBase) .where(eq(ItemsBase.itemName, classname)) .limit(1); if (existing.length > 0) { return { ok: false, classname, skipped: true, warnings, error: "Already present", }; } await ensureDirectories(); const { iconDir, nitroDir } = await getFurniAssetDirs(); // Download .nitro + icon directly from the source hotel. onProgress?.("downloading"); const nitroPath = path.join( /*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`, ); const iconPath = path.join( /*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`, ); const dl = await downloadFile( `${source.nitroBaseUrl}/${classname}.nitro`, nitroPath, { maxRetries: 2, }, ); if (!dl.ok) { logger.warn("[clone-import] nitro download failed for", { classname }); return { ok: false, classname, warnings, error: "nitro download failed" }; } // Validate it is a real Nitro bundle. try { parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath)); } catch { await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {}); logger.warn("[clone-import] invalid .nitro bundle for", { classname }); return { ok: false, classname, warnings, error: "invalid .nitro bundle" }; } const iconDl = await downloadFile( `${source.iconBaseUrl}/${classname}_icon.png`, iconPath, { maxRetries: 1, validate: "png", }, ); if (!iconDl.ok) { // Source serves no standalone icon (e.g. icons embedded in the .nitro) — // extract the catalog icon from the bundle we just downloaded. try { const icon = extractFurniIconPng( await fs.readFile(/*turbopackIgnore: true*/ nitroPath), ); if (icon) { await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon); } else { warnings.push("no icon (not in source or bundle)"); } } catch { warnings.push("icon extraction failed"); } } onProgress?.("writing_db"); const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0; // allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`). // allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon. const allowStack = stackHeight > 0 ? "1" : "0"; let newId: number; try { newId = await allocateItemsBaseId(async (nextId) => { await db.execute(sql` INSERT INTO items_base (id, sprite_id, public_name, item_name, type, width, length, stack_height, allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) VALUES (${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight}, ${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`); return nextId; }); } catch (err) { // Rollback: remove both downloaded files so we don't leave orphaned assets. await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {}); await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {}); logger.warn("[clone-import] items_base insert failed for", { classname, error: (err as Error).message, }); return { ok: false, classname, warnings, error: `items_base insert failed: ${(err as Error).message}`, }; } // FurnitureData entry — reuse the source's furnitype object, with our id. onProgress?.("writing_furnidata"); try { await appendFurniEntry( { ...entry, id: newId } as Record, itemType, ); } catch (err) { logger.warn("[clone-import] FurnitureData append failed for", { classname, error: (err as Error).message, }); warnings.push(`FurnitureData append failed: ${(err as Error).message}`); } // Catalog entry (category sub-page + auto price), serialized id allocation. try { const pageId = await getOrCreateCategoryPage(classname, itemType); const price = autoPriceFurni(classname); await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`); return nextCatalogId; }); } catch (err) { logger.warn("[clone-import] catalog entry failed for", { classname, error: (err as Error).message, }); warnings.push(`catalog entry failed: ${(err as Error).message}`); } return { ok: true, classname, warnings }; } export async function getCloneList(params: { source: CloneSource; search: string; page: number; perPage: number; filter?: "all" | "missing" | "present"; }): Promise<{ items: Array; meta: { page: number; perPage: number; total: number }; }> { const { source, search, page, perPage, filter = "all" } = params; const all = await fetchSourceFurnidata(source.furnidataUrl); const term = search.trim().toLowerCase(); const searched = term ? all.filter( (e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term), ) : all; // Resolve present status across the whole searched list (not just the page), // so the missing/present filter and the page total stay correct. const allNames = searched.map((e) => e.classname); const present = new Set(); if (allNames.length) { const rows = await db .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .where(inArray(ItemsBase.itemName, allNames)); for (const r of rows) present.add(r.itemName); } const filtered = filter === "missing" ? searched.filter((e) => !present.has(e.classname)) : filter === "present" ? searched.filter((e) => present.has(e.classname)) : searched; const total = filtered.length; const start = (Math.max(page, 1) - 1) * perPage; const slice = filtered.slice(start, start + perPage); return { items: slice.map((e) => ({ ...e, present: present.has(e.classname) })), meta: { page: Math.max(page, 1), perPage, total }, }; } export async function getCloneStats( source: CloneSource, ): Promise<{ total: number; present: number; clonable: number }> { const all = await fetchSourceFurnidata(source.furnidataUrl); const classnames = all.map((e) => e.classname); const have = new Set(); if (classnames.length) { const rows = await db .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .where(inArray(ItemsBase.itemName, classnames)); for (const r of rows) have.add(r.itemName); } const present = all.filter((e) => have.has(e.classname)).length; return { total: all.length, present, clonable: all.length - present }; } /** * Return the classnames of every furni in the source that is NOT yet present * in our items_base — used by the "clone all" action (cloned in chunks by the UI). */ export async function getClonableClassnames( source: CloneSource, ): Promise { const all = await fetchSourceFurnidata(source.furnidataUrl); const classnames = all.map((e) => e.classname); const have = new Set(); if (classnames.length) { const rows = await db .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .where(inArray(ItemsBase.itemName, classnames)); for (const r of rows) have.add(r.itemName); } return all.filter((e) => !have.has(e.classname)).map((e) => e.classname); }