style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+77 -59
View File
@@ -6,92 +6,110 @@ import { prisma } from "@/lib/prisma";
const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]);
function csvEscape(value: unknown): string {
if (value == null) return "";
const str = String(value);
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
if (value == null) return "";
const str = String(value);
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
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>> {
const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>`
const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>`
SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt
FROM users
WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
GROUP BY day
ORDER BY day ASC
`;
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(csvRow(["day", "registrations"])));
for (const r of rows) {
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])));
}
controller.close();
},
});
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(csvRow(["day", "registrations"])));
for (const r of rows) {
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])));
}
controller.close();
},
});
}
async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>`
const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>`
SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt
FROM users
WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
GROUP BY hour
ORDER BY hour ASC
`;
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
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.close();
},
});
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
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.close();
},
});
}
async function streamEconomy(): Promise<ReadableStream<Uint8Array>> {
const [totals] = await prisma.$queryRaw<{ total_credits: bigint; total_pixels: bigint }[]>`
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();
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();
},
});
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();
},
});
}
export const GET = withAdmin({ permission: PERMS.ANALYTICS_EXPORT }, async (request) => {
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);
}
export const GET = withAdmin(
{ permission: PERMS.ANALYTICS_EXPORT },
async (request) => {
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,
);
}
const stream =
report === "registrations"
? await streamRegistrations()
: report === "online-by-hour"
? await streamOnlineByHour()
: await streamEconomy();
const stream =
report === "registrations"
? await streamRegistrations()
: report === "online-by-hour"
? await streamOnlineByHour()
: await streamEconomy();
return new Response(stream, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${report}.csv"`,
"Cache-Control": "no-store",
},
});
});
return new Response(stream, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${report}.csv"`,
"Cache-Control": "no-store",
},
});
},
);
+41 -41
View File
@@ -6,54 +6,54 @@ 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;
return new Promise((resolve) => {
const host = env.RCON_HOST;
const port = env.RCON_PORT;
const socket = new net.Socket();
const timeout = setTimeout(() => {
socket.destroy();
resolve(false);
}, 3000);
const socket = new net.Socket();
const timeout = setTimeout(() => {
socket.destroy();
resolve(false);
}, 3000);
socket.connect(port, host, () => {
clearTimeout(timeout);
socket.destroy();
resolve(true);
});
socket.connect(port, host, () => {
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;
try {
const start = Date.now();
await prisma.$queryRaw`SELECT 1`;
dbLatency = Date.now() - start;
dbOk = true;
} catch {
dbOk = false;
}
// DB health
let dbOk = false;
let dbLatency = 0;
try {
const start = Date.now();
await prisma.$queryRaw`SELECT 1`;
dbLatency = Date.now() - start;
dbOk = true;
} catch {
dbOk = false;
}
// Emulator health (RCON TCP check)
const emulator = await checkRcon();
// Emulator health (RCON TCP check)
const emulator = await checkRcon();
// Online users
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
// Online users
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
return NextResponse.json({
database: dbOk,
dbLatency,
emulator,
onlineUsers,
timestamp: new Date().toISOString(),
});
return NextResponse.json({
database: dbOk,
dbLatency,
emulator,
onlineUsers,
timestamp: new Date().toISOString(),
});
});
+22 -14
View File
@@ -4,22 +4,30 @@ 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);
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);
try {
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
const texts: Record<string, string> = JSON.parse(raw);
try {
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 });
} catch {
return apiError("Failed to read ExternalTexts.json", 500);
}
});
return apiOk({ name, desc });
} catch {
return apiError("Failed to read ExternalTexts.json", 500);
}
},
);
+76 -52
View File
@@ -6,71 +6,95 @@ 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";
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 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>();
try {
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);
}
}
} catch {
// File not found or invalid - treat all as not imported
}
// Check which badges already exist in ExternalTexts.json
const existingCodes = new Set<string>();
try {
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);
}
}
} catch {
// File not found or invalid - treat all as not imported
}
const enriched = badges.map((badge) => ({
...badge,
alreadyImported: existingCodes.has(badge.code),
}));
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;
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
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);
try {
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);
}
if (texts[nameKey] !== undefined) {
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");
} catch (err) {
console.warn("[import-badges] Failed to update ExternalTexts.json:", (err as Error).message);
return apiError("Failed to update ExternalTexts.json", 500);
}
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);
}
logAudit({
userId: ctx.session.user.id,
action: "badge_import",
target: "ExternalTexts",
after: { code, name, description },
});
logAudit({
userId: ctx.session.user.id,
action: "badge_import",
target: "ExternalTexts",
after: { code, name, description },
});
return apiOk();
});
return apiOk();
},
);
+77 -51
View File
@@ -2,70 +2,96 @@ 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 {
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 || [];
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 || [];
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>>;
try {
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
} catch (err) {
return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502);
}
// Fetch furnidata once so we can resolve entries by classname.
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
try {
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
} catch (err) {
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[] = [];
for (const cn of rawClassnames) {
if (!seen.has(cn)) {
seen.add(cn);
items.push({ classname: cn });
}
}
// Deduplicate classnames preserving order.
const seen = new Set<string>();
const items: BatchItem[] = [];
for (const cn of rawClassnames) {
if (!seen.has(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);
if (!entry) {
return { ok: false, error: "classname not found in source furnidata" };
}
return runSseBatch<BatchItem>({
items,
concurrency: body.concurrency || 2,
labelOf: (it) => it.classname,
worker: async (it, _index, report) => {
const entry = entryMap.get(it.classname);
if (!entry) {
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",
targetId: 0,
after: { classname: it.classname, sourceId, sourceName: source.name },
});
}
if (result.ok) {
logAudit({
userId: ctx.session.user.id,
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,
};
},
});
},
);
+39 -34
View File
@@ -12,41 +12,46 @@ const iconCache = new Map<string, Buffer | null>();
// 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);
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 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);
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);
}
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
iconCache.set(cacheKey, icon);
} catch {
iconCache.set(cacheKey, null);
return apiError("Failed to fetch bundle", 502);
}
}
if (icon === undefined) {
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);
}
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
iconCache.set(cacheKey, icon);
} catch {
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",
},
});
});
return new Response(new Uint8Array(icon), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400",
},
});
},
);
+102 -67
View File
@@ -2,93 +2,128 @@ 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 {
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;
export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
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) });
}
// GET ?source=<id>&action=clonable → all not-yet-present classnames (for "clone all")
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),
]);
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>;
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 isHttps = (u: string) => {
try {
return new URL(u).protocol === "https:";
} catch {
return false;
}
};
const isHttps = (u: string) => {
try {
return new URL(u).protocol === "https:";
} catch {
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 (!isHttps(furnidataUrl) || !isHttps(nitroBaseUrl) || !isHttps(iconBaseUrl))
return apiError("Source URLs must be valid https:// URLs", 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);
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",
targetId: 0,
after: { id: entry.id, name: entry.name },
});
logAudit({
userId: ctx.session.user.id,
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);
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 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",
targetId: 0,
after: { id, name: source.name },
});
logAudit({
userId: ctx.session.user.id,
action: "clone_source_delete",
target: "CloneSource",
targetId: 0,
after: { id, name: source.name },
});
return apiOk({ id });
});
return apiOk({ id });
},
);
@@ -2,44 +2,50 @@ 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 {
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);
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 seen = new Set<string>();
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false;
seen.add(it.lib);
return true;
});
const seen = new Set<string>();
const items = rawItems.filter((it) => {
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 });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: "figure_import",
target: "FigureAsset",
targetId: 0,
after: { lib: it.lib, batch: true },
});
}
return { ok: r.ok, warnings: r.warnings, error: r.error };
},
});
});
return runSseBatch<FigureBatchItem>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSingleFigure({ lib: it.lib, onProgress: report });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: "figure_import",
target: "FigureAsset",
targetId: 0,
after: { lib: it.lib, batch: true },
});
}
return { ok: r.ok, warnings: r.warnings, error: r.error };
},
});
},
);
+40 -22
View File
@@ -2,27 +2,45 @@ 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 {
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 });
});
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 });
},
);
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);
logAudit({
userId: ctx.session.user.id,
action: "figure_delete",
target: "FigureAsset",
targetId: 0,
after: { lib },
});
return apiOk({ lib, deletedFile });
});
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);
logAudit({
userId: ctx.session.user.id,
action: "figure_delete",
target: "FigureAsset",
targetId: 0,
after: { lib },
});
return apiOk({ lib, deletedFile });
},
);
@@ -6,44 +6,48 @@ 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);
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 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 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;
});
return runSseBatch<ClothingSetBatchItem>({
items,
concurrency,
labelOf: (it) => `${it.setType}-${it.setId}`,
worker: async (it, _index, report) => {
const r = await importClothingSet({
setType: it.setType,
setId: it.setId,
onProgress: report,
});
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
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 runSseBatch<ClothingSetBatchItem>({
items,
concurrency,
labelOf: (it) => `${it.setType}-${it.setId}`,
worker: async (it, _index, report) => {
const r = await importClothingSet({
setType: it.setType,
setId: it.setId,
onProgress: report,
});
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
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 };
},
});
},
);
+27 -16
View File
@@ -1,20 +1,31 @@
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 {
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>);
}
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 });
});
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>,
);
}
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 });
},
);
+45 -39
View File
@@ -2,48 +2,54 @@ 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 {
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);
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);
// Dedupe by lib (the .nitro filename) to avoid double work in one request.
const seen = new Set<string>();
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false;
seen.add(it.lib);
return true;
});
// Dedupe by lib (the .nitro filename) to avoid double work in one request.
const seen = new Set<string>();
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false;
seen.add(it.lib);
return true;
});
await ensureEffectDirs();
await ensureEffectDirs();
return runSseBatch<EffectMapEntry>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSingleEffect({
id: it.id,
lib: it.lib,
type: it.type,
revision: it.revision,
onProgress: report,
});
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
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 runSseBatch<EffectMapEntry>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSingleEffect({
id: it.id,
lib: it.lib,
type: it.type,
revision: it.revision,
onProgress: report,
});
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
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 };
},
});
},
);
+37 -24
View File
@@ -2,29 +2,42 @@ 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 {
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 search = request.nextUrl.searchParams.get("search") || "";
const effects = await getEffectList(search);
return apiOk({ effects });
});
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 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);
logAudit({
userId: ctx.session.user.id,
action: "effect_delete",
target: "EffectMap",
targetId: 0,
after: { idOrLib, removed, deletedFile },
});
return apiOk({ idOrLib, removed, deletedFile });
});
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);
logAudit({
userId: ctx.session.user.id,
action: "effect_delete",
target: "EffectMap",
targetId: 0,
after: { idOrLib, removed, deletedFile },
});
return apiOk({ idOrLib, removed, deletedFile });
},
);
@@ -5,225 +5,272 @@ 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 {
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;
}
/**
* SSE batch regenerate .nitro files.
* 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);
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);
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`));
} catch {
/* stream closed */
}
};
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
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();
// Load furnidata once for all items
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 }> };
};
const fdClassnames = new Set([
...(existingFD.roomitemtypes?.furnitype || []).map((e) => e.classname),
...(existingFD.wallitemtypes?.furnitype || []).map((e) => e.classname),
]);
// Load existing FurnitureData.json classnames for dedup
const existingFD = (await readFurniData()) as {
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);
for (let i = 0; i < items.length; 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 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`,
);
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
skipped++;
send({ type: "regen_progress", classname: item.classname, status: "skipped" });
return;
}
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
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;
if (rev === 0) {
const fd =
furnidata.get(item.classname) ||
furnidata.get(baseClassname) ||
furnidata.get(`${baseClassname}*0`);
if (fd) rev = fd.revision;
}
// Resolve revision
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;
}
// Try spriteId as fallback revision
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);
}
// Try spriteId as fallback revision
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);
}
if (revsToTry.length === 0) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: "No revision found",
});
return;
}
if (revsToTry.length === 0) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
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;
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
{ validate: "swf" },
);
if (dl.ok) {
rev = tryRev;
downloaded = true;
break;
}
}
if (!downloaded) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: `SWF not found (revs: ${revsToTry.join(", ")})`,
});
return;
}
}
// Download SWF if needed
if (!existsSync(swfPath)) {
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" },
);
if (dl.ok) {
rev = tryRev;
downloaded = true;
break;
}
}
if (!downloaded) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: `SWF not found (revs: ${revsToTry.join(", ")})`,
});
return;
}
}
// Convert
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);
// Convert
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);
// 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 entry = buildFurniEntry({
id: dbItem?.spriteId || 0,
classname: baseClassname,
revision: rev,
category: fd?.category || "",
name: dbItem?.publicName || baseClassname,
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);
}
// 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 entry = buildFurniEntry({
id: dbItem?.spriteId || 0,
classname: baseClassname,
revision: rev,
category: fd?.category || "",
name: dbItem?.publicName || baseClassname,
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);
}
succeeded++;
send({ type: "regen_progress", classname: item.classname, status: "done" });
} catch (err) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: (err as Error).message,
});
}
});
succeeded++;
send({
type: "regen_progress",
classname: item.classname,
status: "done",
});
} catch (err) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
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);
send({
type: "regen_info",
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
});
} catch (err) {
send({
type: "regen_info",
message: `FurnitureData.json write failed: ${(err as Error).message}`,
});
}
}
// Batch write FurnitureData.json
if (furniDataEntries.length > 0) {
try {
await appendFurniEntriesBatch(furniDataEntries);
send({
type: "regen_info",
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
});
} catch (err) {
send({
type: "regen_info",
message: `FurnitureData.json write failed: ${(err as Error).message}`,
});
}
}
// RCON refresh
try {
await rcon.updateItems();
await rcon.updateCatalog();
} catch {
/* non-fatal */
}
// RCON refresh
try {
await rcon.updateItems();
await rcon.updateCatalog();
} catch {
/* non-fatal */
}
send({
type: "regen_complete",
succeeded,
failed,
skipped,
furniDataAdded: furniDataEntries.length,
duration: Date.now() - startTime,
});
send({
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",
},
});
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
},
);
+189 -163
View File
@@ -3,18 +3,21 @@ 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 {
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;
}
/**
@@ -22,176 +25,199 @@ interface BatchItem {
* Processes multiple furniture items with configurable concurrency,
* 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);
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);
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[] = [];
for (const it of rawItems) {
const key = `${it.id}:${it.classname}`;
if (seen.has(key)) continue;
seen.add(key);
items.push(it);
}
// 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[] = [];
for (const it of rawItems) {
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`));
} catch {
/* stream closed by client */
}
};
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
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);
// Process in chunks of `concurrency`
for (let i = 0; i < items.length; 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 promises = chunk.map(async (item, chunkIdx) => {
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",
revision: item.revision ?? 0,
category: item.category ?? "unknown",
skipFurniDataWrite: true,
onProgress: (status: string) => {
send({ type: "item_progress", classname: item.classname, status, index });
},
});
try {
const result: ImportSingleResult = await importSingleFurni({
id: item.id ?? 0,
classname: item.classname,
name: item.name,
description: item.description ?? "",
type: item.type ?? "flooritem",
revision: item.revision ?? 0,
category: item.category ?? "unknown",
skipFurniDataWrite: true,
onProgress: (status: string) => {
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);
if (result.ok) {
succeeded++;
if (result.warnings.length > 0) withWarnings++;
if (result.furniDataEntry)
furniDataEntries.push(result.furniDataEntry);
send({
type: "item_progress",
classname: item.classname,
status: "done",
index,
itemId: result.itemId,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
});
send({
type: "item_progress",
classname: item.classname,
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",
targetId: result.itemId!,
after: {
classname: item.classname,
name: item.name,
type: item.type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
batch: true,
},
});
} else {
failed++;
send({
type: "item_progress",
classname: item.classname,
status: "failed",
index,
message: result.error,
});
}
} catch (err) {
failed++;
send({
type: "item_progress",
classname: item.classname,
status: "failed",
index,
message: (err as Error).message,
});
}
});
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId!,
after: {
classname: item.classname,
name: item.name,
type: item.type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
batch: true,
},
});
} else {
failed++;
send({
type: "item_progress",
classname: item.classname,
status: "failed",
index,
message: result.error,
});
}
} catch (err) {
failed++;
send({
type: "item_progress",
classname: item.classname,
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;
if (furniDataEntries.length > 0) {
try {
await appendFurniEntriesBatch(furniDataEntries);
} catch (err) {
furniDataWriteOk = false;
send({
type: "item_progress",
classname: "_batch_",
status: "failed",
index: -1,
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
});
}
}
// 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;
if (furniDataEntries.length > 0) {
try {
await appendFurniEntriesBatch(furniDataEntries);
} catch (err) {
furniDataWriteOk = false;
send({
type: "item_progress",
classname: "_batch_",
status: "failed",
index: -1,
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
});
}
}
// Refresh game server caches once — skip if FurnitureData write failed
// to avoid an inconsistent state (emulator sees items, client doesn't).
if (furniDataWriteOk) {
try {
const okCat = await rcon.updateCatalog();
const okItems = await rcon.updateItems();
if (!okCat || !okItems) {
send({
type: "item_progress",
classname: "_rcon_",
status: "failed",
index: -1,
message: "RCON refresh failed — emulator cache may be stale",
});
}
} catch (err) {
console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
}
}
// Refresh game server caches once — skip if FurnitureData write failed
// to avoid an inconsistent state (emulator sees items, client doesn't).
if (furniDataWriteOk) {
try {
const okCat = await rcon.updateCatalog();
const okItems = await rcon.updateItems();
if (!okCat || !okItems) {
send({
type: "item_progress",
classname: "_rcon_",
status: "failed",
index: -1,
message: "RCON refresh failed — emulator cache may be stale",
});
}
} catch (err) {
console.warn(
"[import-furni] RCON update failed after batch:",
(err as Error).message,
);
}
}
send({
type: "batch_complete",
succeeded,
failed,
warnings: withWarnings,
duration: Date.now() - startTime,
});
send({
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",
},
});
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
},
);
@@ -7,121 +7,153 @@ 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 {
readFurniData,
withFurniDataLock,
writeFurniData,
} from "@/lib/services/furni-data";
import { rcon } from "@/lib/services/rcon";
import {
computeDefaultDir,
createNitroBundle,
extractPartColors,
parseNitroBundle,
computeDefaultDir,
createNitroBundle,
extractPartColors,
parseNitroBundle,
} 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);
export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request: NextRequest) => {
const rawClassname = request.nextUrl.searchParams.get("classname");
if (!rawClassname) return apiError("Missing classname", 400);
const base = baseClassname(rawClassname);
const { nitroDir } = await getFurniAssetDirs();
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
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);
try {
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
const { json } = parseNitroBundle(buffer);
// Fetch interaction flags from DB
const items = await prisma.$queryRaw<
Array<{
allow_sit: string;
allow_lay: string;
allow_walk: string;
}>
>`
// Fetch interaction flags from DB
const items = await prisma.$queryRaw<
Array<{
allow_sit: string;
allow_lay: string;
allow_walk: string;
}>
>`
SELECT allow_sit, allow_lay, allow_walk
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
`;
const dbRow = items[0];
const flags = {
canstandon: dbRow?.allow_walk === "1",
cansiton: dbRow?.allow_sit === "1",
canlayon: dbRow?.allow_lay === "1",
};
const dbRow = items[0];
const flags = {
canstandon: dbRow?.allow_walk === "1",
cansiton: dbRow?.allow_sit === "1",
canlayon: dbRow?.allow_lay === "1",
};
return apiOk({ metadata: json, flags });
} catch (err) {
console.error("[nitro-editor] Failed to parse .nitro:", (err as Error).message);
return apiError("Failed to parse .nitro file", 500);
}
});
return apiOk({ metadata: json, flags });
} catch (err) {
console.error(
"[nitro-editor] Failed to parse .nitro:",
(err as Error).message,
);
return apiError("Failed to parse .nitro file", 500);
}
},
);
// ── PUT: Save .nitro metadata ──────────────────────────────────────
export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest, ctx) => {
const body = await request.json();
const { classname: rawClassname, metadata, canstandon, cansiton, canlayon } = body;
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"},
@@ -129,33 +161,46 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
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 });
},
);
+182 -152
View File
@@ -4,10 +4,10 @@ import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
appendFurniEntriesBatch,
buildFurniEntry,
getFurnitureDataPath,
readFurniData,
appendFurniEntriesBatch,
buildFurniEntry,
getFurnitureDataPath,
readFurniData,
} from "@/lib/services/furni-data";
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
@@ -26,171 +26,201 @@ import { rcon } from "@/lib/services/rcon";
* Entries are upserted by classname so running this repeatedly is safe.
* Triggers RCON updateCatalog + updateItems at the end.
*/
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
const qp = request.nextUrl.searchParams;
const mode: "days" | "missing" | "broken" | "all" =
qp.get("all") === "1"
? "all"
: qp.get("broken") === "1"
? "broken"
: qp.get("missing") === "1"
? "missing"
: "days";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const qp = request.nextUrl.searchParams;
const mode: "days" | "missing" | "broken" | "all" =
qp.get("all") === "1"
? "all"
: qp.get("broken") === "1"
? "broken"
: qp.get("missing") === "1"
? "missing"
: "days";
const days = Math.max(1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365));
const 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,
examined: 0,
resynced: 0,
failed: 0,
errors: [],
});
}
if (targetIds.length === 0) {
return apiOk({
mode,
days: mode === "days" ? days : undefined,
examined: 0,
resynced: 0,
failed: 0,
errors: [],
});
}
// Process in chunks to keep memory flat and make progress visible.
const CHUNK = 500;
let resynced = 0;
const errors: Array<{ classname: string; message: string }> = [];
// Process in chunks to keep memory flat and make progress visible.
const CHUNK = 500;
let resynced = 0;
const errors: Array<{ classname: string; message: string }> = [];
for (let i = 0; i < targetIds.length; i += CHUNK) {
const slice = targetIds.slice(i, i + CHUNK);
const rows = await prisma.itemsBase.findMany({
where: { id: { in: slice } },
select: {
id: true,
spriteId: true,
itemName: true,
publicName: true,
type: true,
width: true,
length: true,
stackHeight: true,
},
});
for (let i = 0; i < targetIds.length; i += CHUNK) {
const slice = targetIds.slice(i, i + CHUNK);
const rows = await prisma.itemsBase.findMany({
where: { id: { in: slice } },
select: {
id: true,
spriteId: true,
itemName: true,
publicName: true,
type: true,
width: true,
length: true,
stackHeight: true,
},
});
const entries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
for (const row of rows) {
try {
const classname = row.itemName;
let habboIt = null;
try {
habboIt = await lookupHabboIt(classname);
} catch {
/* optional */
}
const entries: Array<{
entry: Record<string, unknown>;
itemType: string;
}> = [];
for (const row of rows) {
try {
const classname = row.itemName;
let habboIt = null;
try {
habboIt = await lookupHabboIt(classname);
} catch {
/* optional */
}
const entry = buildFurniEntry({
id: row.id,
classname,
revision: habboIt?.revision ?? 0,
category: habboIt?.category || "unknown",
name: row.publicName || habboIt?.name || classname,
description: habboIt?.description || "",
spriteId: row.spriteId,
dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)) },
metadata: null,
habboIt,
});
const entry = buildFurniEntry({
id: row.id,
classname,
revision: habboIt?.revision ?? 0,
category: habboIt?.category || "unknown",
name: row.publicName || habboIt?.name || classname,
description: habboIt?.description || "",
spriteId: row.spriteId,
dims: {
x: row.width,
y: row.length,
z: Math.round(Number(row.stackHeight)),
},
metadata: null,
habboIt,
});
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
} catch (err) {
errors.push({ classname: row.itemName, message: (err as Error).message });
}
}
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
} catch (err) {
errors.push({
classname: row.itemName,
message: (err as Error).message,
});
}
}
if (entries.length > 0) {
try {
await appendFurniEntriesBatch(entries);
resynced += entries.length;
} catch (err) {
errors.push({
classname: `_chunk_${i}_${i + slice.length}_`,
message: `FurnitureData.json write failed: ${(err as Error).message}`,
});
break;
}
}
}
if (entries.length > 0) {
try {
await appendFurniEntriesBatch(entries);
resynced += entries.length;
} catch (err) {
errors.push({
classname: `_chunk_${i}_${i + slice.length}_`,
message: `FurnitureData.json write failed: ${(err as Error).message}`,
});
break;
}
}
}
let rconOk = false;
try {
const a = await rcon.updateCatalog();
const b = await rcon.updateItems();
rconOk = a && b;
} catch {
/* logged in rcon service */
}
let rconOk = false;
try {
const a = await rcon.updateCatalog();
const b = await rcon.updateItems();
rconOk = a && b;
} catch {
/* logged in rcon service */
}
return apiOk({
mode,
days: mode === "days" ? days : undefined,
examined: targetIds.length,
resynced,
failed: errors.length,
rconOk,
errors: errors.slice(0, 50),
});
});
return apiOk({
mode,
days: mode === "days" ? days : undefined,
examined: targetIds.length,
resynced,
failed: errors.length,
rconOk,
errors: errors.slice(0, 50),
});
},
);
async function resolveTargetIds(
mode: "days" | "missing" | "broken" | "all",
days: number,
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 (!existsSync(furniDataPath)) {
const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
return rows.map((r) => r.id);
}
const furniData = (await readFurniData()) as {
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
};
const byClassname = new Map<string, Record<string, unknown>>();
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
for (const e of section?.furnitype ?? []) {
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
}
}
const rows = await prisma.itemsBase.findMany({
select: { id: true, itemName: true, spriteId: true, width: true, length: true },
orderBy: { id: "asc" },
});
if (mode === "missing" || mode === "broken") {
const furniDataPath = await getFurnitureDataPath();
if (!existsSync(furniDataPath)) {
const rows = await prisma.itemsBase.findMany({
select: { id: true },
orderBy: { id: "asc" },
});
return rows.map((r) => r.id);
}
const furniData = (await readFurniData()) as {
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
};
const byClassname = new Map<string, Record<string, unknown>>();
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
for (const e of section?.furnitype ?? []) {
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
}
}
const rows = await prisma.itemsBase.findMany({
select: {
id: true,
itemName: true,
spriteId: true,
width: true,
length: true,
},
orderBy: { id: "asc" },
});
if (mode === "missing") {
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
}
if (mode === "missing") {
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
}
// broken: entry present but inconsistent with items_base
return rows
.filter((r) => {
const e = byClassname.get(r.itemName);
if (!e) return true;
const entryId = Number(e.id ?? -1);
if (entryId !== r.id && entryId !== r.spriteId) return true;
const xdim = Number(e.xdim ?? 0);
const ydim = Number(e.ydim ?? 0);
if (xdim <= 0 || ydim <= 0) return true;
if (xdim !== r.width || ydim !== r.length) return true;
return false;
})
.map((r) => r.id);
}
// broken: entry present but inconsistent with items_base
return rows
.filter((r) => {
const e = byClassname.get(r.itemName);
if (!e) return true;
const entryId = Number(e.id ?? -1);
if (entryId !== r.id && entryId !== r.spriteId) return true;
const xdim = Number(e.xdim ?? 0);
const ydim = Number(e.ydim ?? 0);
if (xdim <= 0 || ydim <= 0) return true;
if (xdim !== r.width || ydim !== r.length) return true;
return false;
})
.map((r) => r.id);
}
// mode === 'days'
const sinceIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
// mode === 'days'
const sinceIso = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString();
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
SELECT DISTINCT target_id
FROM admin_audit_log
WHERE action = 'furni_import'
@@ -199,5 +229,5 @@ async function resolveTargetIds(
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);
}
File diff suppressed because it is too large. Load diff
+35 -32
View File
@@ -6,40 +6,43 @@ 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);
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 seen = new Set<string>();
const items = rawItems.filter((it) => {
if (seen.has(it.lib)) return false;
seen.add(it.lib);
return true;
});
const seen = new Set<string>();
const items = rawItems.filter((it) => {
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 });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: "pet_import",
target: "PetAsset",
targetId: 0,
after: { lib: it.lib, batch: true },
});
}
return { ok: r.ok, warnings: r.warnings, error: r.error };
},
});
});
return runSseBatch<PetBatchItem>({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
const r = await importSinglePet({ lib: it.lib, onProgress: report });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
action: "pet_import",
target: "PetAsset",
targetId: 0,
after: { lib: it.lib, batch: true },
});
}
return { ok: r.ok, warnings: r.warnings, error: r.error };
},
});
},
);
+16 -13
View File
@@ -3,16 +3,19 @@ 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 });
return new Response(new Uint8Array(png), {
headers: {
"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",
},
});
});
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 });
return new Response(new Uint8Array(png), {
headers: {
"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",
},
});
},
);
+31 -20
View File
@@ -2,25 +2,36 @@ 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 {
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) });
});
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) });
},
);
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);
logAudit({
userId: ctx.session.user.id,
action: "pet_delete",
target: "PetAsset",
targetId: 0,
after: { lib },
});
return apiOk({ lib, deletedFile });
});
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);
logAudit({
userId: ctx.session.user.id,
action: "pet_delete",
target: "PetAsset",
targetId: 0,
after: { lib },
});
return apiOk({ lib, deletedFile });
},
);
@@ -6,6 +6,6 @@ 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>);
});
+112 -104
View File
@@ -9,119 +9,127 @@ import { rcon } from "@/lib/services/rcon";
* Body: { classnames: string[], concurrency?: number }
* 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 : [];
// 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);
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
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);
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`));
} catch {
/* stream closed */
}
};
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
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);
for (let i = 0; i < classnames.length; i += concurrency) {
const chunk = classnames.slice(i, i + concurrency);
const promises = chunk.map(async (classname) => {
send({ type: "repair_progress", classname, status: "working" });
try {
const result = await repairItem(classname);
if (result.unrepairable) unrepairable++;
if (result.ok) {
succeeded++;
send({
type: "repair_progress",
classname,
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++;
send({
type: "repair_progress",
classname,
status: "partial",
nitroRepaired: result.nitroRepaired,
iconRepaired: result.iconRepaired,
warnings: result.warnings,
error: result.error,
unrepairable: result.unrepairable,
});
} else {
failed++;
send({
type: "repair_progress",
classname,
status: "failed",
warnings: result.warnings,
error: result.error || result.warnings[0] || "Unknown error",
unrepairable: result.unrepairable,
});
}
}
} catch (err) {
failed++;
send({
type: "repair_progress",
classname,
status: "failed",
error: (err as Error).message,
});
}
});
const promises = chunk.map(async (classname) => {
send({ type: "repair_progress", classname, status: "working" });
try {
const result = await repairItem(classname);
if (result.unrepairable) unrepairable++;
if (result.ok) {
succeeded++;
send({
type: "repair_progress",
classname,
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++;
send({
type: "repair_progress",
classname,
status: "partial",
nitroRepaired: result.nitroRepaired,
iconRepaired: result.iconRepaired,
warnings: result.warnings,
error: result.error,
unrepairable: result.unrepairable,
});
} else {
failed++;
send({
type: "repair_progress",
classname,
status: "failed",
warnings: result.warnings,
error:
result.error || result.warnings[0] || "Unknown error",
unrepairable: result.unrepairable,
});
}
}
} catch (err) {
failed++;
send({
type: "repair_progress",
classname,
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();
} catch {
/* non-fatal */
}
// RCON refresh so the emulator picks up any items_base changes
try {
await rcon.updateItems();
await rcon.updateCatalog();
} catch {
/* non-fatal */
}
send({
type: "repair_complete",
succeeded,
partial,
failed,
unrepairable,
duration: Date.now() - startTime,
});
send({
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",
},
});
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
},
);
@@ -5,21 +5,34 @@ 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);
if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(roleId) || roleId <= 0) {
return apiError("Valid userId and roleId required", 400);
}
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 } });
}
revalidateTag("permissions", { expire: 0 });
return NextResponse.json({ success: true });
});
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);
if (
!Number.isInteger(userId) ||
userId <= 0 ||
!Number.isInteger(roleId) ||
roleId <= 0
) {
return apiError("Valid userId and roleId required", 400);
}
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 },
});
}
revalidateTag("permissions", { expire: 0 });
return NextResponse.json({ success: true });
},
);
+39 -26
View File
@@ -5,30 +5,43 @@ 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 });
});
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 });
},
);
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);
await prisma.$transaction([
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 });
});
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);
await prisma.$transaction([
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 });
},
);
+182 -113
View File
@@ -2,138 +2,207 @@
import { NextResponse } from "next/server";
import { requireStaff } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { logger } from "@/lib/logger";
const giveCurrency = async ({
rconClient: _rconClient,
db: _db,
userId,
type,
amount,
rconClient: _rconClient,
db: _db,
userId,
type,
amount,
}: {
rconClient: typeof rcon;
db: typeof prisma;
userId: number;
type: string;
amount: number;
rconClient: typeof rcon;
db: typeof prisma;
userId: number;
type: string;
amount: number;
}) => {
await logStaffActivity({
staffId: 1,
action: "give_currency",
description: `Gave ${amount} ${type} to user #${userId}`,
targetType: "user",
targetId: userId,
});
return { success: true, message: `Gave ${amount} ${type} to user #${userId}` };
await logStaffActivity({
staffId: 1,
action: "give_currency",
description: `Gave ${amount} ${type} to user #${userId}`,
targetType: "user",
targetId: userId,
});
return {
success: true,
message: `Gave ${amount} ${type} to user #${userId}`,
};
};
export async function POST(request: Request) {
try {
const staff = await requireStaff();
const formData = await request.formData();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") || "");
const action = String(formData.get("action") || "");
try {
const staff = await requireStaff();
const formData = await request.formData();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") || "");
const action = String(formData.get("action") || "");
if (!userId || !username) {
return NextResponse.json({ success: false, message: "Invalid user data" }, { status: 400 });
}
if (!userId || !username) {
return NextResponse.json(
{ success: false, message: "Invalid user data" },
{ status: 400 },
);
}
if (action === "set_rank") {
const rank = Number(formData.get("rank") || "0");
if (!rank || rank < 0 || rank > 10) {
return NextResponse.json({ success: false, message: "Invalid rank value" }, { status: 400 });
}
if (action === "set_rank") {
const rank = Number(formData.get("rank") || "0");
if (!rank || rank < 0 || rank > 10) {
return NextResponse.json(
{ success: false, message: "Invalid rank value" },
{ status: 400 },
);
}
await prisma.user.update({ where: { id: userId }, data: { rank } });
await rcon.setRank(userId, rank);
await logStaffActivity({
staffId: staff.id,
action: "rank_change",
description: `Set rank of user #${userId} to ${rank}`,
targetType: "user",
targetId: userId,
});
await prisma.user.update({ where: { id: userId }, data: { rank } });
await rcon.setRank(userId, rank);
await logStaffActivity({
staffId: staff.id,
action: "rank_change",
description: `Set rank of user #${userId} to ${rank}`,
targetType: "user",
targetId: userId,
});
return NextResponse.json(
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
{ status: 200 },
);
}
return NextResponse.json(
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
{ status: 200 },
);
}
if (action === "disconnect") {
await rcon.disconnectUser(userId, username);
return NextResponse.json(
{ success: true, message: `Disconnected user #${userId} (${username})` },
{ status: 200 },
);
}
if (action === "disconnect") {
await rcon.disconnectUser(userId, username);
return NextResponse.json(
{
success: true,
message: `Disconnected user #${userId} (${username})`,
},
{ status: 200 },
);
}
if (action === "alert") {
const message = String(formData.get("message") || "")
.trim()
.slice(0, 512);
if (!message) {
return NextResponse.json({ success: false, message: "Alert message is required" }, { status: 400 });
}
await rcon.alertUser(userId, message);
return NextResponse.json({ success: true, message: `Sent alert to user #${userId}` }, { status: 200 });
}
if (action === "alert") {
const message = String(formData.get("message") || "")
.trim()
.slice(0, 512);
if (!message) {
return NextResponse.json(
{ success: false, message: "Alert message is required" },
{ status: 400 },
);
}
await rcon.alertUser(userId, message);
return NextResponse.json(
{ success: true, message: `Sent alert to user #${userId}` },
{ status: 200 },
);
}
if (action === "give_credits") {
const credits = Number(formData.get("credits") || "0");
if (!credits || credits <= 0) {
return NextResponse.json({ success: false, message: "Invalid credit amount" }, { status: 400 });
}
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "credits", amount: credits });
return NextResponse.json(
{ success: true, message: `Gave ${credits} credits to user #${userId}` },
{ status: 200 },
);
}
if (action === "give_credits") {
const credits = Number(formData.get("credits") || "0");
if (!credits || credits <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid credit amount" },
{ status: 400 },
);
}
await giveCurrency({
rconClient: rcon,
db: prisma,
userId,
type: "credits",
amount: credits,
});
return NextResponse.json(
{
success: true,
message: `Gave ${credits} credits to user #${userId}`,
},
{ status: 200 },
);
}
if (action === "give_duckets") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json({ success: false, message: "Invalid duckets amount" }, { status: 400 });
}
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "duckets", amount });
return NextResponse.json(
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
{ status: 200 },
);
}
if (action === "give_duckets") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid duckets amount" },
{ status: 400 },
);
}
await giveCurrency({
rconClient: rcon,
db: prisma,
userId,
type: "duckets",
amount,
});
return NextResponse.json(
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
{ status: 200 },
);
}
if (action === "give_diamonds") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json({ success: false, message: "Invalid diamonds amount" }, { status: 400 });
}
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "diamonds", amount });
return NextResponse.json(
{ success: true, message: `Gave ${amount} diamonds to user #${userId}` },
{ status: 200 },
);
}
if (action === "give_diamonds") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid diamonds amount" },
{ status: 400 },
);
}
await giveCurrency({
rconClient: rcon,
db: prisma,
userId,
type: "diamonds",
amount,
});
return NextResponse.json(
{
success: true,
message: `Gave ${amount} diamonds to user #${userId}`,
},
{ status: 200 },
);
}
if (action === "give_points") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json({ success: false, message: "Invalid points amount" }, { status: 400 });
}
await giveCurrency({ rconClient: rcon, db: prisma, userId, type: "points", amount });
return NextResponse.json(
{ success: true, message: `Gave ${amount} points to user #${userId}` },
{ status: 200 },
);
}
if (action === "give_points") {
const amount = Number(formData.get("amount") || "0");
if (!amount || amount <= 0) {
return NextResponse.json(
{ success: false, message: "Invalid points amount" },
{ status: 400 },
);
}
await giveCurrency({
rconClient: rcon,
db: prisma,
userId,
type: "points",
amount,
});
return NextResponse.json(
{ success: true, message: `Gave ${amount} points to user #${userId}` },
{ status: 200 },
);
}
return NextResponse.json({ success: false, message: `Unknown action: ${action}` }, { status: 400 });
} catch (error) {
logger.error("Admin users actions error", { module: "admin/users/actions", error: String(error) });
return NextResponse.json({ success: false, message: "Internal server error" }, { status: 500 });
}
return NextResponse.json(
{ success: false, message: `Unknown action: ${action}` },
{ status: 400 },
);
} catch (error) {
logger.error("Admin users actions error", {
module: "admin/users/actions",
error: String(error),
});
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 },
);
}
}