style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

+40 -44
View File
@@ -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",
},
})
})
});
});
+33 -33
View File
@@ -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(),
})
})
});
});
+15 -18
View File
@@ -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);
}
})
});
+37 -40
View File
@@ -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();
});
+33 -33
View File
@@ -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 };
},
})
})
});
});
+27 -27
View File
@@ -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",
},
})
})
});
});
+49 -49
View File
@@ -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 };
},
})
})
});
});
+20 -20
View File
@@ -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 };
},
})
})
});
});
+12 -12
View File
@@ -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 });
});
+24 -24
View File
@@ -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 };
},
})
})
});
});
+21 -21
View File
@@ -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",
},
})
})
});
});
+87 -87
View File
@@ -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 });
});
+77 -77
View File
@@ -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);
}
+264 -274
View File
@@ -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,
})
})
});
});
+24 -24
View File
@@ -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 };
},
})
})
});
});
+10 -10
View File
@@ -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",
},
})
})
});
});
+18 -18
View File
@@ -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>);
});
+52 -52
View File
@@ -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 });
});
+27 -24
View File
@@ -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 });
});
@@ -1,25 +1,31 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { type NextRequest, NextResponse } from 'next/server'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
import { promises as fs } from "node:fs";
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS' }
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" };
export async function GET(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const segments = (await params).path
const filename = segments.at(-1)
if (!filename || filename !== path.basename(filename) || !filename.endsWith('.nitro')) {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS })
const segments = (await params).path;
const filename = segments.at(-1);
if (!filename || filename !== path.basename(filename) || !filename.endsWith(".nitro")) {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
}
try {
const { nitroDir } = await getFurniAssetDirs()
const file = await fs.readFile(path.join(nitroDir, filename))
return new NextResponse(file, { headers: { ...CORS_HEADERS, 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=86400, immutable' } })
const { nitroDir } = await getFurniAssetDirs();
const file = await fs.readFile(path.join(nitroDir, filename));
return new NextResponse(file, {
headers: {
...CORS_HEADERS,
"Content-Type": "application/octet-stream",
"Cache-Control": "public, max-age=86400, immutable",
},
});
} catch {
return new NextResponse(null, { status: 404, headers: CORS_HEADERS })
return new NextResponse(null, { status: 404, headers: CORS_HEADERS });
}
}
export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS })
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
+19 -17
View File
@@ -7,11 +7,7 @@ import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import {
authorizeTopupCapture,
claimTopupDelivery,
TopupCaptureError,
} from "@/lib/services/paypal-topup";
import { authorizeTopupCapture, claimTopupDelivery, TopupCaptureError } from "@/lib/services/paypal-topup";
import { logServerError } from "@/lib/server-log";
export const dynamic = "force-dynamic";
@@ -64,10 +60,12 @@ export async function POST(req: Request): Promise<Response> {
// Do not let a signed-in user submit somebody else's approved order id.
let authorized;
try {
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) => prisma.websitePaypalTransactions.findFirst({
where: { transactionId },
select: { userId: true, status: true, amount: true },
}));
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) =>
prisma.websitePaypalTransactions.findFirst({
where: { transactionId },
select: { userId: true, status: true, amount: true },
}),
);
} catch (error) {
if (error instanceof TopupCaptureError) {
const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409;
@@ -80,10 +78,12 @@ export async function POST(req: Request): Promise<Response> {
const amount = authorized.amount ?? 0;
const credits = Math.floor(amount * creditsPerUnit());
try {
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}));
await claimTopupDelivery(() =>
prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}),
);
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
@@ -168,10 +168,12 @@ export async function POST(req: Request): Promise<Response> {
// external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible
// for staff reconciliation instead of automatically risking a second grant.
try {
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}));
await claimTopupDelivery(() =>
prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}),
);
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
+23 -18
View File
@@ -28,24 +28,29 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
try {
// Ownership check — only the ticket owner may reply.
const reply = await prisma.$transaction((tx) => createOwnedTicketReply(
{
findTicket: (ticketId) => tx.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true },
}),
createReply: (data) => tx.websiteHelpCenterTicketReplies.create({
data,
select: { id: true, userId: true, content: true, createdAt: true },
}),
touchTicket: (ticketId, updatedAt) => tx.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt },
select: { id: true },
}),
},
{ ticketId, userId: uid, content },
));
const reply = await prisma.$transaction((tx) =>
createOwnedTicketReply(
{
findTicket: (ticketId) =>
tx.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true },
}),
createReply: (data) =>
tx.websiteHelpCenterTicketReplies.create({
data,
select: { id: true, userId: true, content: true, createdAt: true },
}),
touchTicket: (ticketId, updatedAt) =>
tx.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt },
select: { id: true },
}),
},
{ ticketId, userId: uid, content },
),
);
if (!reply) return apiError("Ticket not found", 404);
return apiJson(