style: clean up code with prettier and eslint
This commit is contained in:
1 parent
60278b9e71
commit
fb8e77bb68
378 files changed
+22407
-20689
No files matched your search
@@ -1,21 +1,21 @@
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { apiError } from '@/lib/api-response'
|
||||
import { PERMS } from '@/lib/permission-slugs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const VALID_REPORTS = new Set(['registrations', 'online-by-hour', 'economy'])
|
||||
const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]);
|
||||
|
||||
function csvEscape(value: unknown): string {
|
||||
if (value == null) return ''
|
||||
const str = String(value)
|
||||
if (value == null) return "";
|
||||
const str = String(value);
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str
|
||||
return str;
|
||||
}
|
||||
|
||||
function csvRow(values: unknown[]): string {
|
||||
return `${values.map(csvEscape).join(',')}\n`
|
||||
return `${values.map(csvEscape).join(",")}\n`;
|
||||
}
|
||||
|
||||
async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> {
|
||||
@@ -25,17 +25,17 @@ async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> {
|
||||
WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`
|
||||
const encoder = new TextEncoder()
|
||||
`;
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(['day', 'registrations'])))
|
||||
controller.enqueue(encoder.encode(csvRow(["day", "registrations"])));
|
||||
for (const r of rows) {
|
||||
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])))
|
||||
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])));
|
||||
}
|
||||
controller.close()
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
|
||||
@@ -45,57 +45,53 @@ async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
|
||||
WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
`
|
||||
const encoder = new TextEncoder()
|
||||
`;
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(['hour', 'active_users'])))
|
||||
controller.enqueue(encoder.encode(csvRow(["hour", "active_users"])));
|
||||
for (const r of rows) {
|
||||
controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])))
|
||||
controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])));
|
||||
}
|
||||
controller.close()
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async function streamEconomy(): Promise<ReadableStream<Uint8Array>> {
|
||||
const [totals] = await prisma.$queryRaw<{ total_credits: bigint; total_pixels: bigint }[]>`
|
||||
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
|
||||
`
|
||||
const encoder = new TextEncoder()
|
||||
`;
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(csvRow(['metric', 'value'])))
|
||||
controller.enqueue(
|
||||
encoder.encode(csvRow(['total_credits', Number(totals?.total_credits ?? 0)])),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(csvRow(['total_pixels', Number(totals?.total_pixels ?? 0)])),
|
||||
)
|
||||
controller.close()
|
||||
controller.enqueue(encoder.encode(csvRow(["metric", "value"])));
|
||||
controller.enqueue(encoder.encode(csvRow(["total_credits", Number(totals?.total_credits ?? 0)])));
|
||||
controller.enqueue(encoder.encode(csvRow(["total_pixels", Number(totals?.total_pixels ?? 0)])));
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ANALYTICS_EXPORT }, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const report = url.searchParams.get('report') ?? ''
|
||||
const url = new URL(request.url);
|
||||
const report = url.searchParams.get("report") ?? "";
|
||||
if (!VALID_REPORTS.has(report)) {
|
||||
return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(', ')}`, 400)
|
||||
return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(", ")}`, 400);
|
||||
}
|
||||
|
||||
const stream =
|
||||
report === 'registrations'
|
||||
report === "registrations"
|
||||
? await streamRegistrations()
|
||||
: report === 'online-by-hour'
|
||||
: report === "online-by-hour"
|
||||
? await streamOnlineByHour()
|
||||
: await streamEconomy()
|
||||
: await streamEconomy();
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${report}.csv"`,
|
||||
'Cache-Control': 'no-store',
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${report}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,53 @@
|
||||
import net from 'node:net'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { env } from '@/env'
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import net from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function checkRcon(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const host = env.RCON_HOST
|
||||
const port = env.RCON_PORT
|
||||
const host = env.RCON_HOST;
|
||||
const port = env.RCON_PORT;
|
||||
|
||||
const socket = new net.Socket()
|
||||
const socket = new net.Socket();
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
}, 3000)
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 3000);
|
||||
|
||||
socket.connect(port, host, () => {
|
||||
clearTimeout(timeout)
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timeout)
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
|
||||
// DB health
|
||||
let dbOk = false
|
||||
let dbLatency = 0
|
||||
let dbOk = false;
|
||||
let dbLatency = 0;
|
||||
try {
|
||||
const start = Date.now()
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
dbLatency = Date.now() - start
|
||||
dbOk = true
|
||||
const start = Date.now();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
dbLatency = Date.now() - start;
|
||||
dbOk = true;
|
||||
} catch {
|
||||
dbOk = false
|
||||
dbOk = false;
|
||||
}
|
||||
|
||||
// Emulator health (RCON TCP check)
|
||||
const emulator = await checkRcon()
|
||||
const emulator = await checkRcon();
|
||||
|
||||
// Online users
|
||||
const onlineUsers = await prisma.user.count({ where: { online: '1' } })
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
|
||||
return NextResponse.json({
|
||||
database: dbOk,
|
||||
@@ -55,5 +55,5 @@ export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
|
||||
emulator,
|
||||
onlineUsers,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,25 @@
|
||||
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 { 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',
|
||||
)
|
||||
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)
|
||||
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 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`] ?? ''
|
||||
const name = texts[`badge_name_${code}`] ?? texts[`${code}_badge_name`] ?? "";
|
||||
const desc = texts[`badge_desc_${code}`] ?? texts[`${code}_badge_desc`] ?? "";
|
||||
|
||||
return apiOk({ name, desc })
|
||||
return apiOk({ name, desc });
|
||||
} catch {
|
||||
return apiError('Failed to read ExternalTexts.json', 500)
|
||||
return apiError("Failed to read ExternalTexts.json", 500);
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -1,32 +1,29 @@
|
||||
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'
|
||||
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',
|
||||
)
|
||||
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 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)
|
||||
const badges = await searchBadges(search, 100, offset, allHotels ? null : undefined);
|
||||
|
||||
// Check which badges already exist in ExternalTexts.json
|
||||
const existingCodes = new Set<string>()
|
||||
const existingCodes = new Set<string>();
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
|
||||
const texts = JSON.parse(raw)
|
||||
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)
|
||||
existingCodes.add(badge.code);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -36,44 +33,44 @@ export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
const enriched = badges.map((badge) => ({
|
||||
...badge,
|
||||
alreadyImported: existingCodes.has(badge.code),
|
||||
}))
|
||||
}));
|
||||
|
||||
return apiOk({ badges: enriched })
|
||||
})
|
||||
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
|
||||
const body = await request.json();
|
||||
const { code, name, description } = body;
|
||||
|
||||
if (!code || !name) return apiError('Missing code or name', 400)
|
||||
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 raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw);
|
||||
|
||||
const nameKey = `badge_name_${code}`
|
||||
const descKey = `badge_desc_${code}`
|
||||
const nameKey = `badge_name_${code}`;
|
||||
const descKey = `badge_desc_${code}`;
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return apiError('Badge already exists in ExternalTexts.json', 409)
|
||||
return apiError("Badge already exists in ExternalTexts.json", 409);
|
||||
}
|
||||
|
||||
texts[nameKey] = name
|
||||
texts[descKey] = description || ''
|
||||
texts[nameKey] = name;
|
||||
texts[descKey] = description || "";
|
||||
|
||||
await fs.writeFile(EXTERNAL_TEXTS_PATH, JSON.stringify(texts, null, 4), 'utf-8')
|
||||
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)
|
||||
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',
|
||||
action: "badge_import",
|
||||
target: "ExternalTexts",
|
||||
after: { code, name, description },
|
||||
})
|
||||
});
|
||||
|
||||
return apiOk()
|
||||
})
|
||||
return apiOk();
|
||||
});
|
||||
@@ -1,71 +1,71 @@
|
||||
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'
|
||||
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
|
||||
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 || []
|
||||
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)
|
||||
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)
|
||||
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>>
|
||||
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
|
||||
try {
|
||||
allEntries = await fetchSourceFurnidata(source.furnidataUrl)
|
||||
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
} catch (err) {
|
||||
return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502)
|
||||
return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502);
|
||||
}
|
||||
|
||||
const entryMap = new Map(allEntries.map((e) => [e.classname, e]))
|
||||
const entryMap = new Map(allEntries.map((e) => [e.classname, e]));
|
||||
|
||||
// Deduplicate classnames preserving order.
|
||||
const seen = new Set<string>()
|
||||
const items: BatchItem[] = []
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const cn of rawClassnames) {
|
||||
if (!seen.has(cn)) {
|
||||
seen.add(cn)
|
||||
items.push({ classname: cn })
|
||||
seen.add(cn);
|
||||
items.push({ classname: cn });
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 500) return apiError('Too many classnames (max 500)', 400)
|
||||
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)
|
||||
const entry = entryMap.get(it.classname);
|
||||
if (!entry) {
|
||||
return { ok: false, error: 'classname not found in source furnidata' }
|
||||
return { ok: false, error: "classname not found in source furnidata" };
|
||||
}
|
||||
|
||||
const result = await cloneSingleFurni({ source, entry, onProgress: report })
|
||||
const result = await cloneSingleFurni({ source, entry, onProgress: report });
|
||||
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'clone_furni',
|
||||
target: 'FurniAsset',
|
||||
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 }
|
||||
return { ok: result.ok, warnings: result.warnings, error: result.error };
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,52 @@
|
||||
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'
|
||||
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>()
|
||||
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 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)
|
||||
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)
|
||||
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)
|
||||
iconCache.set(cacheKey, null);
|
||||
return apiError("Bundle not found", 404);
|
||||
}
|
||||
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()))
|
||||
iconCache.set(cacheKey, icon)
|
||||
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
|
||||
iconCache.set(cacheKey, icon);
|
||||
} catch {
|
||||
iconCache.set(cacheKey, null)
|
||||
return apiError('Failed to fetch bundle', 502)
|
||||
iconCache.set(cacheKey, null);
|
||||
return apiError("Failed to fetch bundle", 502);
|
||||
}
|
||||
}
|
||||
|
||||
if (!icon) return apiError('No icon in bundle', 404)
|
||||
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',
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,94 +1,94 @@
|
||||
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'
|
||||
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
|
||||
const sp = request.nextUrl.searchParams;
|
||||
|
||||
if (sp.get('action') === 'sources') {
|
||||
return apiOk({ sources: await listSources() })
|
||||
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 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)
|
||||
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) })
|
||||
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 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 })
|
||||
})
|
||||
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 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:'
|
||||
return new URL(u).protocol === "https:";
|
||||
} catch {
|
||||
return false
|
||||
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 (!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)
|
||||
return apiError("Source URLs must be valid https:// URLs", 400);
|
||||
|
||||
const entry = await upsertSource({ id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl })
|
||||
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',
|
||||
action: id ? "clone_source_update" : "clone_source_create",
|
||||
target: "CloneSource",
|
||||
targetId: 0,
|
||||
after: { id: entry.id, name: entry.name },
|
||||
})
|
||||
});
|
||||
|
||||
return apiOk({ source: entry })
|
||||
})
|
||||
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 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)
|
||||
const source = await getSource(id);
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
|
||||
await deleteSource(id)
|
||||
await deleteSource(id);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'clone_source_delete',
|
||||
target: 'CloneSource',
|
||||
action: "clone_source_delete",
|
||||
target: "CloneSource",
|
||||
targetId: 0,
|
||||
after: { id, name: source.name },
|
||||
})
|
||||
});
|
||||
|
||||
return apiOk({ id })
|
||||
})
|
||||
return apiOk({ id });
|
||||
});
|
||||
@@ -1,45 +1,45 @@
|
||||
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'
|
||||
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
|
||||
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 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 seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false
|
||||
seen.add(it.lib)
|
||||
return true
|
||||
})
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ensureFigureDirs()
|
||||
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 })
|
||||
const r = await importSingleFigure({ lib: it.lib, onProgress: report });
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'figure_import',
|
||||
target: 'FigureAsset',
|
||||
action: "figure_import",
|
||||
target: "FigureAsset",
|
||||
targetId: 0,
|
||||
after: { lib: it.lib, batch: true },
|
||||
})
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error }
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,28 @@
|
||||
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'
|
||||
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 })
|
||||
})
|
||||
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)
|
||||
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',
|
||||
action: "figure_delete",
|
||||
target: "FigureAsset",
|
||||
targetId: 0,
|
||||
after: { lib },
|
||||
})
|
||||
return apiOk({ lib, deletedFile })
|
||||
})
|
||||
});
|
||||
return apiOk({ lib, deletedFile });
|
||||
});
|
||||
@@ -1,28 +1,28 @@
|
||||
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'
|
||||
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
|
||||
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 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 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
|
||||
})
|
||||
const key = `${it.setType}:${it.setId}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return runSseBatch<ClothingSetBatchItem>({
|
||||
items,
|
||||
@@ -33,17 +33,17 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
setType: it.setType,
|
||||
setId: it.setId,
|
||||
onProgress: report,
|
||||
})
|
||||
});
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'clothing_set_import',
|
||||
target: 'ClothingSet',
|
||||
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 }
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,20 @@
|
||||
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'
|
||||
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>)
|
||||
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 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 })
|
||||
})
|
||||
});
|
||||
return apiOk({ items, meta });
|
||||
});
|
||||
@@ -1,26 +1,26 @@
|
||||
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'
|
||||
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)
|
||||
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 seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false
|
||||
seen.add(it.lib)
|
||||
return true
|
||||
})
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ensureEffectDirs()
|
||||
await ensureEffectDirs();
|
||||
|
||||
return runSseBatch<EffectMapEntry>({
|
||||
items,
|
||||
@@ -33,17 +33,17 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
type: it.type,
|
||||
revision: it.revision,
|
||||
onProgress: report,
|
||||
})
|
||||
});
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'effect_import',
|
||||
target: 'EffectMap',
|
||||
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 }
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,30 @@
|
||||
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'
|
||||
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 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 })
|
||||
})
|
||||
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)
|
||||
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',
|
||||
action: "effect_delete",
|
||||
target: "EffectMap",
|
||||
targetId: 0,
|
||||
after: { idOrLib, removed, deletedFile },
|
||||
})
|
||||
return apiOk({ idOrLib, removed, deletedFile })
|
||||
})
|
||||
});
|
||||
return apiOk({ idOrLib, removed, deletedFile });
|
||||
});
|
||||
@@ -1,19 +1,19 @@
|
||||
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'
|
||||
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
|
||||
classname: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,210 +21,209 @@ interface RegenItem {
|
||||
* 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)
|
||||
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)
|
||||
if (items.length === 0) return apiError("No items to regenerate", 400);
|
||||
|
||||
await ensureDirectories()
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs()
|
||||
const encoder = new TextEncoder()
|
||||
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`))
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now()
|
||||
send({ type: 'regen_start', total: items.length })
|
||||
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 }> = []
|
||||
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()
|
||||
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 }> }
|
||||
}
|
||||
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 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`)
|
||||
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
|
||||
skipped++;
|
||||
send({ type: "regen_progress", classname: item.classname, status: "skipped" });
|
||||
return;
|
||||
}
|
||||
|
||||
send({ type: 'regen_progress', classname: item.classname, status: 'downloading' })
|
||||
send({ type: "regen_progress", classname: item.classname, status: "downloading" });
|
||||
|
||||
// Resolve revision
|
||||
let rev = item.revision || 0
|
||||
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
|
||||
furnidata.get(`${baseClassname}*0`);
|
||||
if (fd) rev = fd.revision;
|
||||
}
|
||||
|
||||
// Try spriteId as fallback revision
|
||||
const revsToTry: number[] = []
|
||||
if (rev > 0) revsToTry.push(rev)
|
||||
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)
|
||||
revsToTry.push(dbItem.spriteId);
|
||||
}
|
||||
|
||||
if (revsToTry.length === 0) {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'regen_progress',
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: 'failed',
|
||||
message: 'No revision found',
|
||||
})
|
||||
return
|
||||
status: "failed",
|
||||
message: "No revision found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`)
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
|
||||
|
||||
// Download SWF if needed
|
||||
if (!existsSync(swfPath)) {
|
||||
let downloaded = false
|
||||
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' },
|
||||
)
|
||||
{ validate: "swf" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
rev = tryRev
|
||||
downloaded = true
|
||||
break
|
||||
rev = tryRev;
|
||||
downloaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!downloaded) {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'regen_progress',
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: 'failed',
|
||||
message: `SWF not found (revs: ${revsToTry.join(', ')})`,
|
||||
})
|
||||
return
|
||||
status: "failed",
|
||||
message: `SWF not found (revs: ${revsToTry.join(", ")})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert
|
||||
send({ type: 'regen_progress', classname: item.classname, status: 'converting' })
|
||||
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)
|
||||
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 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 || '',
|
||||
category: fd?.category || "",
|
||||
name: dbItem?.publicName || baseClassname,
|
||||
description: fd?.description || '',
|
||||
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)
|
||||
});
|
||||
furniDataEntries.push({ entry, itemType });
|
||||
fdClassnames.add(baseClassname);
|
||||
}
|
||||
|
||||
succeeded++
|
||||
send({ type: 'regen_progress', classname: item.classname, status: 'done' })
|
||||
succeeded++;
|
||||
send({ type: "regen_progress", classname: item.classname, status: "done" });
|
||||
} catch (err) {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'regen_progress',
|
||||
type: "regen_progress",
|
||||
classname: item.classname,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
message: (err as Error).message,
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// Batch write FurnitureData.json
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries)
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
send({
|
||||
type: 'regen_info',
|
||||
type: "regen_info",
|
||||
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: 'regen_info',
|
||||
type: "regen_info",
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// RCON refresh
|
||||
try {
|
||||
await rcon.updateItems()
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
send({
|
||||
type: 'regen_complete',
|
||||
type: "regen_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
furniDataAdded: furniDataEntries.length,
|
||||
duration: Date.now() - startTime,
|
||||
})
|
||||
});
|
||||
|
||||
controller.close()
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,20 @@
|
||||
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'
|
||||
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
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,135 +23,135 @@ interface BatchItem {
|
||||
* 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)
|
||||
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)
|
||||
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[] = []
|
||||
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)
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
|
||||
await ensureDirectories()
|
||||
const encoder = new TextEncoder()
|
||||
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`))
|
||||
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 })
|
||||
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 }> = []
|
||||
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 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 })
|
||||
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',
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? 'unknown',
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({ type: 'item_progress', classname: item.classname, status, index })
|
||||
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)
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry);
|
||||
|
||||
send({
|
||||
type: 'item_progress',
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: 'done',
|
||||
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',
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === 'wallitem' ? 'i' : 's',
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
} else {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'item_progress',
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'item_progress',
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
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
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries)
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: 'item_progress',
|
||||
classname: '_batch_',
|
||||
status: 'failed',
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,39 +159,39 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog()
|
||||
const okItems = await rcon.updateItems()
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: 'item_progress',
|
||||
classname: '_rcon_',
|
||||
status: 'failed',
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: 'RCON refresh failed — emulator cache may be stale',
|
||||
})
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[import-furni] RCON update failed after batch:', (err as Error).message)
|
||||
console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
send({
|
||||
type: 'batch_complete',
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
})
|
||||
});
|
||||
|
||||
controller.close()
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,165 +1,161 @@
|
||||
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 { 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'
|
||||
} from "@/lib/services/swf-to-nitro";
|
||||
|
||||
function baseClassname(classname: string): string {
|
||||
return classname.includes('*') ? classname.split('*')[0] : classname
|
||||
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 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`)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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 dbRow = items[0];
|
||||
const flags = {
|
||||
canstandon: dbRow?.allow_walk === '1',
|
||||
cansiton: dbRow?.allow_sit === '1',
|
||||
canlayon: dbRow?.allow_lay === '1',
|
||||
}
|
||||
canstandon: dbRow?.allow_walk === "1",
|
||||
cansiton: dbRow?.allow_sit === "1",
|
||||
canlayon: dbRow?.allow_lay === "1",
|
||||
};
|
||||
|
||||
return apiOk({ metadata: json, flags })
|
||||
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)
|
||||
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
|
||||
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)
|
||||
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`)
|
||||
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)
|
||||
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)
|
||||
// 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)
|
||||
// 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 || []
|
||||
// 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>> }
|
||||
>
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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`
|
||||
// 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'}
|
||||
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)
|
||||
}
|
||||
`;
|
||||
} 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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// 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 })
|
||||
},
|
||||
)
|
||||
return apiOk({ dimensions: dims });
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
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 { 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'
|
||||
} 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
|
||||
@@ -27,38 +27,38 @@ import { rcon } from '@/lib/services/rcon'
|
||||
* 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 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 days = Math.max(1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365));
|
||||
|
||||
const targetIds = await resolveTargetIds(mode, days)
|
||||
const targetIds = await resolveTargetIds(mode, days);
|
||||
|
||||
if (targetIds.length === 0) {
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === 'days' ? days : undefined,
|
||||
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 }> = []
|
||||
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 slice = targetIds.slice(i, i + CHUNK);
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
where: { id: { in: slice } },
|
||||
select: {
|
||||
@@ -71,15 +71,15 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
length: true,
|
||||
stackHeight: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const entries: Array<{ entry: Record<string, unknown>; itemType: string }> = []
|
||||
const entries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const classname = row.itemName
|
||||
let habboIt = null
|
||||
const classname = row.itemName;
|
||||
let habboIt = null;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname)
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
@@ -88,108 +88,108 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || 'unknown',
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
description: habboIt?.description || '',
|
||||
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' })
|
||||
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
||||
} catch (err) {
|
||||
errors.push({ classname: row.itemName, message: (err as Error).message })
|
||||
errors.push({ classname: row.itemName, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(entries)
|
||||
resynced += entries.length
|
||||
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
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rconOk = false
|
||||
let rconOk = false;
|
||||
try {
|
||||
const a = await rcon.updateCatalog()
|
||||
const b = await rcon.updateItems()
|
||||
rconOk = a && b
|
||||
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,
|
||||
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',
|
||||
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 === "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 (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 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>>()
|
||||
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)
|
||||
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' },
|
||||
})
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
if (mode === 'missing') {
|
||||
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id)
|
||||
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
|
||||
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)
|
||||
.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 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
|
||||
@@ -198,6 +198,6 @@ async function resolveTargetIds(
|
||||
AND target_id IS NOT NULL
|
||||
AND created_at >= ${sinceMysql}
|
||||
ORDER BY target_id ASC
|
||||
`
|
||||
return auditRows.map((r) => r.target_id).filter(Boolean)
|
||||
`;
|
||||
return auditRows.map((r) => r.target_id).filter(Boolean);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { 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,
|
||||
@@ -16,56 +16,56 @@ import {
|
||||
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'
|
||||
} 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'
|
||||
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')
|
||||
const classname = request.nextUrl.searchParams.get("classname");
|
||||
|
||||
// ── Delete specific imported item ─────────────────────────────────
|
||||
if (classname) {
|
||||
try {
|
||||
const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname)
|
||||
const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname);
|
||||
|
||||
// Refresh game server caches
|
||||
try {
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems()
|
||||
await rcon.updateCatalog();
|
||||
await rcon.updateItems();
|
||||
} catch (err) {
|
||||
errors.push(`RCON: ${(err as Error).message}`)
|
||||
errors.push(`RCON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'furni_delete',
|
||||
target: 'ItemsBase',
|
||||
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)
|
||||
return apiError((err as Error).message, 404);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clean up SWF files that have been converted to Nitro ──────────
|
||||
const result = await cleanupConvertedSwfs()
|
||||
const result = await cleanupConvertedSwfs();
|
||||
|
||||
if (result.total === 0 && result.deleted === 0) {
|
||||
return apiOk({ deleted: 0, message: 'SWF directory does not exist' })
|
||||
return apiOk({ deleted: 0, message: "SWF directory does not exist" });
|
||||
}
|
||||
|
||||
return apiOk({
|
||||
@@ -73,164 +73,162 @@ export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (requ
|
||||
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()
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
// Stats endpoint
|
||||
const action = request.nextUrl.searchParams.get('action')
|
||||
if (action === 'stats') {
|
||||
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(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)
|
||||
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
|
||||
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++
|
||||
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 })
|
||||
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
|
||||
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
|
||||
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 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)
|
||||
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 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`)
|
||||
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',
|
||||
description: fd?.description || "",
|
||||
type: item.type === "i" ? "wallitem" : "flooritem",
|
||||
revision: fd?.revision || 0,
|
||||
category: fd?.category || fd?.furniline || '',
|
||||
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)
|
||||
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 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
|
||||
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)
|
||||
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 { ...item, alreadyImported: imported, nitroExists };
|
||||
});
|
||||
|
||||
return apiOk({ items: enriched, meta: result.meta })
|
||||
})
|
||||
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()
|
||||
const body = await request.json();
|
||||
|
||||
await ensureDirectories()
|
||||
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
|
||||
}> = []
|
||||
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',
|
||||
classname: item.classname || "unknown",
|
||||
ok: false,
|
||||
warnings: [],
|
||||
error: 'Missing classname or name',
|
||||
})
|
||||
continue
|
||||
error: "Missing classname or name",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -238,30 +236,30 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? '',
|
||||
type: item.type ?? 'flooritem',
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? 'unknown',
|
||||
})
|
||||
category: item.category ?? "unknown",
|
||||
});
|
||||
|
||||
results.push({ classname: item.classname, ...result })
|
||||
results.push({ classname: item.classname, ...result });
|
||||
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'furni_import',
|
||||
target: 'ItemsBase',
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === 'wallitem' ? 'i' : 's',
|
||||
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({
|
||||
@@ -269,66 +267,66 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
ok: false,
|
||||
warnings: [],
|
||||
error: (err as Error).message,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh caches once after batch
|
||||
try {
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems()
|
||||
await rcon.updateCatalog();
|
||||
await rcon.updateItems();
|
||||
} catch (err) {
|
||||
console.warn('[import-furni] RCON update failed after batch:', (err as Error).message)
|
||||
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
|
||||
const succeeded = results.filter((r) => r.ok).length;
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
|
||||
return apiOk({ batch: true, succeeded, failed, results })
|
||||
return apiOk({ batch: true, succeeded, failed, results });
|
||||
}
|
||||
|
||||
// ── Single import ────────────────────────────────────────────────
|
||||
const { id, classname, name, description, type, revision, category } = body
|
||||
const { id, classname, name, description, type, revision, category } = body;
|
||||
|
||||
if (!classname || !name) return apiError('Missing classname or name', 400)
|
||||
if (!classname || !name) return apiError("Missing classname or name", 400);
|
||||
|
||||
const result = await importSingleFurni({
|
||||
id: id ?? 0,
|
||||
classname,
|
||||
name,
|
||||
description: description ?? '',
|
||||
type: type ?? 'flooritem',
|
||||
description: description ?? "",
|
||||
type: type ?? "flooritem",
|
||||
revision: revision ?? 0,
|
||||
category: category ?? 'unknown',
|
||||
})
|
||||
category: category ?? "unknown",
|
||||
});
|
||||
|
||||
if (!result.ok) return apiError(result.error!, 409)
|
||||
if (!result.ok) return apiError(result.error!, 409);
|
||||
|
||||
// Refresh game server caches
|
||||
try {
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems()
|
||||
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')
|
||||
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',
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname,
|
||||
name,
|
||||
spriteId: id ?? 0,
|
||||
type: type === 'wallitem' ? 'i' : 's',
|
||||
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,
|
||||
@@ -336,44 +334,42 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
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 || []
|
||||
const body = await request.json();
|
||||
const items: Array<{ classname: string; revision: number }> = body.items || [];
|
||||
|
||||
if (items.length === 0) return apiError('No items provided', 400)
|
||||
if (items.length === 0) return apiError("No items provided", 400);
|
||||
|
||||
await ensureDirectories()
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs()
|
||||
await ensureDirectories();
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs();
|
||||
|
||||
const results: Array<{ classname: string; ok: boolean; warning?: string }> = []
|
||||
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`)
|
||||
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
|
||||
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
|
||||
let rev = item.revision || 0;
|
||||
if (rev === 0) {
|
||||
const furnidata = await getHabboItFurnidata()
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
const fd =
|
||||
furnidata.get(item.classname) ||
|
||||
furnidata.get(baseClassname) ||
|
||||
furnidata.get(`${baseClassname}*0`)
|
||||
if (fd) rev = fd.revision
|
||||
furnidata.get(item.classname) || furnidata.get(baseClassname) || furnidata.get(`${baseClassname}*0`);
|
||||
if (fd) rev = fd.revision;
|
||||
}
|
||||
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`)
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
|
||||
|
||||
// Download SWF if not present — try multiple revision candidates
|
||||
if (!existsSync(swfPath)) {
|
||||
@@ -381,20 +377,20 @@ export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reque
|
||||
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))]
|
||||
});
|
||||
const revsToTry = [...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0))];
|
||||
|
||||
let downloaded = false
|
||||
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' },
|
||||
)
|
||||
{ validate: "swf" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
rev = tryRev
|
||||
downloaded = true
|
||||
break
|
||||
rev = tryRev;
|
||||
downloaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,231 +398,225 @@ export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reque
|
||||
results.push({
|
||||
classname: item.classname,
|
||||
ok: false,
|
||||
warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(', ') || 'none'})`,
|
||||
})
|
||||
continue
|
||||
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)
|
||||
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 { buildFurniEntry, appendFurniEntry, readFurniData } =
|
||||
await import("@/lib/services/furni-data");
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> }
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> }
|
||||
}
|
||||
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 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 || '',
|
||||
category: fd?.category || "",
|
||||
name: dbItem?.publicName || baseClassname,
|
||||
description: fd?.description || '',
|
||||
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)
|
||||
});
|
||||
await appendFurniEntry(entry, itemType);
|
||||
}
|
||||
} catch (fdErr) {
|
||||
console.warn(
|
||||
`[regen] FurnitureData update skipped for ${item.classname}:`,
|
||||
(fdErr as Error).message,
|
||||
)
|
||||
console.warn(`[regen] FurnitureData update skipped for ${item.classname}:`, (fdErr as Error).message);
|
||||
}
|
||||
|
||||
results.push({ classname: item.classname, ok: true })
|
||||
results.push({ classname: item.classname, ok: true });
|
||||
} catch (err) {
|
||||
results.push({ classname: item.classname, ok: false, warning: (err as Error).message })
|
||||
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
|
||||
const succeeded = results.filter((r) => r.ok).length;
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
|
||||
return apiOk({ succeeded, failed, results })
|
||||
})
|
||||
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'
|
||||
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',
|
||||
})
|
||||
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))]
|
||||
});
|
||||
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 inPlaceholders = allImportedPageIds.map(() => "?").join(",");
|
||||
const allItems = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: number
|
||||
item_ids: string
|
||||
catalog_name: string
|
||||
page_id: string
|
||||
cost_credits: number
|
||||
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',
|
||||
})
|
||||
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 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>()
|
||||
: [];
|
||||
const typeMap = new Map<string, string>();
|
||||
for (const di of dbItems) {
|
||||
if (di.itemName) typeMap.set(di.itemName, di.type || 's')
|
||||
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>()
|
||||
const categoryPageMap = new Map<string, number>();
|
||||
for (const sp of subPages) {
|
||||
if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id)
|
||||
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)
|
||||
const captionSave = `imp_${catKey}`;
|
||||
const cached = categoryPageMap.get(captionSave);
|
||||
if (cached !== undefined) return String(cached);
|
||||
|
||||
if (isDryRun) return 'new'
|
||||
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
|
||||
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)
|
||||
});
|
||||
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>()
|
||||
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[]>()
|
||||
const updateBatches = new Map<string, number[]>();
|
||||
|
||||
// First pass: resolve any new category pages (max ~14 DB inserts)
|
||||
const neededCategories = new Set<string>()
|
||||
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))
|
||||
const itemType = typeMap.get(item.catalog_name) || "s";
|
||||
neededCategories.add(classifyFurni(item.catalog_name, itemType));
|
||||
}
|
||||
for (const catKey of neededCategories) {
|
||||
try {
|
||||
await resolvePageId(catKey)
|
||||
await resolvePageId(catKey);
|
||||
} catch (err) {
|
||||
errors.push(`create page ${catKey}: ${(err as Error).message}`)
|
||||
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 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 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
|
||||
const price = autoPriceFurni(item.catalog_name);
|
||||
const needsMove = item.page_id !== correctPageIdStr;
|
||||
const needsReprice = item.cost_credits !== price.credits;
|
||||
|
||||
pagesWithItems.add(correctPageIdStr)
|
||||
pagesWithItems.add(correctPageIdStr);
|
||||
|
||||
if (!needsMove && !needsReprice) {
|
||||
skipped++
|
||||
continue
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (needsMove) moved++
|
||||
if (needsReprice) repriced++
|
||||
if (needsMove) moved++;
|
||||
if (needsReprice) repriced++;
|
||||
|
||||
if (!isDryRun) {
|
||||
const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`
|
||||
const batch = updateBatches.get(batchKey)
|
||||
const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`;
|
||||
const batch = updateBatches.get(batchKey);
|
||||
if (batch) {
|
||||
batch.push(item.id)
|
||||
batch.push(item.id);
|
||||
} else {
|
||||
updateBatches.set(batchKey, [item.id])
|
||||
updateBatches.set(batchKey, [item.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -634,9 +624,9 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
// 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('|')
|
||||
const [pageId, credits, points, pointsType] = batchKey.split("|");
|
||||
try {
|
||||
const idPlaceholders = ids.map(() => '?').join(',')
|
||||
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,
|
||||
@@ -644,31 +634,31 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
Number(points),
|
||||
Number(pointsType),
|
||||
...ids,
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`batch update: ${(err as Error).message}`)
|
||||
errors.push(`batch update: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Cleanup empty sub-pages (only if NOT dry-run)
|
||||
let pagesDeleted = 0
|
||||
let pagesDeleted = 0;
|
||||
if (!isDryRun) {
|
||||
for (const subPage of subPages) {
|
||||
const subPageIdStr = String(subPage.id)
|
||||
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)
|
||||
);
|
||||
const count = Number(remaining[0]?.cnt ?? 0);
|
||||
if (count === 0) {
|
||||
await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`
|
||||
pagesDeleted++
|
||||
await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`;
|
||||
pagesDeleted++;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`cleanup ${subPage.captionSave}: ${(err as Error).message}`)
|
||||
errors.push(`cleanup ${subPage.captionSave}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,17 +667,17 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
// 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()
|
||||
await rcon.updateCatalog();
|
||||
} catch (err) {
|
||||
errors.push(`RCON: ${(err as Error).message}`)
|
||||
errors.push(`RCON: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDryRun) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'furni_reorganize',
|
||||
target: 'CatalogItems',
|
||||
action: "furni_reorganize",
|
||||
target: "CatalogItems",
|
||||
targetId: parentPage.id,
|
||||
after: {
|
||||
moved,
|
||||
@@ -697,7 +687,7 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
categoryCounts,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return apiOk({
|
||||
@@ -709,5 +699,5 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
total: allItems.length,
|
||||
categoryCounts,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,45 @@
|
||||
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'
|
||||
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
|
||||
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 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 seen = new Set<string>();
|
||||
const items = rawItems.filter((it) => {
|
||||
if (seen.has(it.lib)) return false
|
||||
seen.add(it.lib)
|
||||
return true
|
||||
})
|
||||
if (seen.has(it.lib)) return false;
|
||||
seen.add(it.lib);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ensurePetDirs()
|
||||
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 })
|
||||
const r = await importSinglePet({ lib: it.lib, onProgress: report });
|
||||
if (r.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: 'pet_import',
|
||||
target: 'PetAsset',
|
||||
action: "pet_import",
|
||||
target: "PetAsset",
|
||||
targetId: 0,
|
||||
after: { lib: it.lib, batch: true },
|
||||
})
|
||||
});
|
||||
}
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error }
|
||||
return { ok: r.ok, warnings: r.warnings, error: r.error };
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { getPetIconPng } from '@/lib/services/pet-icon'
|
||||
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 })
|
||||
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',
|
||||
"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',
|
||||
"Cache-Control": "public, max-age=300",
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,26 @@
|
||||
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'
|
||||
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) })
|
||||
})
|
||||
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)
|
||||
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',
|
||||
action: "pet_delete",
|
||||
target: "PetAsset",
|
||||
targetId: 0,
|
||||
after: { lib },
|
||||
})
|
||||
return apiOk({ lib, deletedFile })
|
||||
})
|
||||
});
|
||||
return apiOk({ lib, deletedFile });
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { apiOk } from '@/lib/api-response'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { auditImportedAssets } from '@/lib/services/furni-repair'
|
||||
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>)
|
||||
})
|
||||
const summary = await auditImportedAssets();
|
||||
return apiOk(summary as unknown as Record<string, unknown>);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
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'
|
||||
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.
|
||||
@@ -10,118 +10,118 @@ import { rcon } from '@/lib/services/rcon'
|
||||
* 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 : []
|
||||
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)
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 6, 1), 8);
|
||||
|
||||
if (classnames.length === 0) return apiError('No classnames provided', 400)
|
||||
if (classnames.length === 0) return apiError("No classnames provided", 400);
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
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`))
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now()
|
||||
send({ type: 'repair_start', total: classnames.length })
|
||||
const startTime = Date.now();
|
||||
send({ type: "repair_start", total: classnames.length });
|
||||
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
let partial = 0
|
||||
let unrepairable = 0
|
||||
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 chunk = classnames.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async (classname) => {
|
||||
send({ type: 'repair_progress', classname, status: 'working' })
|
||||
send({ type: "repair_progress", classname, status: "working" });
|
||||
try {
|
||||
const result = await repairItem(classname)
|
||||
if (result.unrepairable) unrepairable++
|
||||
const result = await repairItem(classname);
|
||||
if (result.unrepairable) unrepairable++;
|
||||
if (result.ok) {
|
||||
succeeded++
|
||||
succeeded++;
|
||||
send({
|
||||
type: 'repair_progress',
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: 'done',
|
||||
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++
|
||||
partial++;
|
||||
send({
|
||||
type: 'repair_progress',
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: 'partial',
|
||||
status: "partial",
|
||||
nitroRepaired: result.nitroRepaired,
|
||||
iconRepaired: result.iconRepaired,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
unrepairable: result.unrepairable,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'repair_progress',
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
warnings: result.warnings,
|
||||
error: result.error || result.warnings[0] || 'Unknown error',
|
||||
error: result.error || result.warnings[0] || "Unknown error",
|
||||
unrepairable: result.unrepairable,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
failed++;
|
||||
send({
|
||||
type: 'repair_progress',
|
||||
type: "repair_progress",
|
||||
classname,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
error: (err as Error).message,
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// RCON refresh so the emulator picks up any items_base changes
|
||||
try {
|
||||
await rcon.updateItems()
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
send({
|
||||
type: 'repair_complete',
|
||||
type: "repair_complete",
|
||||
succeeded,
|
||||
partial,
|
||||
failed,
|
||||
unrepairable,
|
||||
duration: Date.now() - startTime,
|
||||
})
|
||||
});
|
||||
|
||||
controller.close()
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,25 @@
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { apiError } from '@/lib/api-response'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => {
|
||||
const body = await request.json()
|
||||
const userId = Number(body.userId)
|
||||
const roleId = Number(body.roleId)
|
||||
const body = await request.json();
|
||||
const userId = Number(body.userId);
|
||||
const roleId = Number(body.roleId);
|
||||
if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(roleId) || roleId <= 0) {
|
||||
return apiError('Valid userId and roleId required', 400)
|
||||
return apiError("Valid userId and roleId required", 400);
|
||||
}
|
||||
if (body.action === 'remove') {
|
||||
await prisma.aclModelRole.deleteMany({ where: { modelType: 'User', modelId: userId, roleId } })
|
||||
if (body.action === "remove") {
|
||||
await prisma.aclModelRole.deleteMany({ where: { modelType: "User", modelId: userId, roleId } });
|
||||
} else {
|
||||
const existing = await prisma.aclModelRole.findFirst({ where: { modelType: 'User', modelId: userId, roleId } })
|
||||
if (!existing) await prisma.aclModelRole.create({ data: { modelType: 'User', modelId: userId, roleId } })
|
||||
const existing = await prisma.aclModelRole.findFirst({
|
||||
where: { modelType: "User", modelId: userId, roleId },
|
||||
});
|
||||
if (!existing) await prisma.aclModelRole.create({ data: { modelType: "User", modelId: userId, roleId } });
|
||||
}
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return NextResponse.json({ success: true })
|
||||
})
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ success: true });
|
||||
});
|
||||
@@ -1,31 +1,34 @@
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { apiError } from '@/lib/api-response'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => {
|
||||
const body = await request.json()
|
||||
const slug = String(body.slug ?? '').trim().toLowerCase()
|
||||
const title = String(body.title ?? '').trim()
|
||||
if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) return apiError('Valid slug and title required', 400)
|
||||
const role = await prisma.aclRole.create({ data: { slug, title } })
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return NextResponse.json({ role })
|
||||
})
|
||||
const body = await request.json();
|
||||
const slug = String(body.slug ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const title = String(body.title ?? "").trim();
|
||||
if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) return apiError("Valid slug and title required", 400);
|
||||
const role = await prisma.aclRole.create({ data: { slug, title } });
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ role });
|
||||
});
|
||||
|
||||
export const DELETE = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => {
|
||||
const id = Number(request.nextUrl.searchParams.get('id'))
|
||||
if (!Number.isInteger(id) || id <= 0) return apiError('Valid ID required', 400)
|
||||
const role = await prisma.aclRole.findUnique({ where: { id }, select: { slug: true } })
|
||||
if (!role) return apiError('Role not found', 404)
|
||||
if (role.slug.startsWith('rank_')) return apiError('Rank roles must be managed through emulator ranks', 409)
|
||||
const id = Number(request.nextUrl.searchParams.get("id"));
|
||||
if (!Number.isInteger(id) || id <= 0) return apiError("Valid ID required", 400);
|
||||
const role = await prisma.aclRole.findUnique({ where: { id }, select: { slug: true } });
|
||||
if (!role) return apiError("Role not found", 404);
|
||||
if (role.slug.startsWith("rank_"))
|
||||
return apiError("Rank roles must be managed through emulator ranks", 409);
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({ where: { modelType: 'Role', modelId: id } }),
|
||||
prisma.aclModelPermission.deleteMany({ where: { modelType: "Role", modelId: id } }),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: id } }),
|
||||
prisma.aclRole.delete({ where: { id } }),
|
||||
])
|
||||
revalidateTag('permissions', { expire: 0 })
|
||||
return NextResponse.json({ success: true })
|
||||
})
|
||||
]);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return NextResponse.json({ success: true });
|
||||
});
|
||||
Reference in new issue
Block a user