fix: resolve critical bugs and improve admin panel reliability
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s

- Fix missing await in pets API route causing empty responses
- Fix updateSetting to use upsert pattern instead of update-only
- Create missing /api/admin/sounds/upload route (upload was broken)
- Wire bulk delete actions in catalog table
- Replace native confirm() with useConfirmDialog() across rooms and clone pages
- Add error logging to silent catch blocks in radio actions and audit route
- Add graceful degradation to devops health endpoint
- Add cache eviction to clone icon route to prevent memory leak
- Internationalize hardcoded Italian strings to English
- Remove placeholder created_at fields from prefix API responses
- Remove dead code and fix type errors in translations and import pages
- Standardize PERMS import path in analytics export route
This commit is contained in:
openhands committed 2026-08-06 18:32:55 +02:00
1 parent 6f53ca514d
commit 1b817fe434
19 files changed
+255 -89

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ import { sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { PERMS } from "@/lib/permissions";
const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]);
+26 -9
View File
@@ -1,17 +1,34 @@
import { NextResponse } from "next/server";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { withAdmin } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
const health = await fetchOpsHealth();
try {
const health = await fetchOpsHealth();
return NextResponse.json({
database: health.dbOk,
dbLatency: health.dbLatencyMs,
redis: health.redisOk,
emulator: health.emulatorOk,
onlineUsers: health.onlineUsers,
timestamp: new Date().toISOString(),
});
return NextResponse.json({
database: health.dbOk,
dbLatency: health.dbLatencyMs,
redis: health.redisOk,
emulator: health.emulatorOk,
onlineUsers: health.onlineUsers,
timestamp: new Date().toISOString(),
});
} catch (err) {
logger.error("Health check failed", { err });
return NextResponse.json(
{
database: false,
dbLatency: null,
redis: false,
emulator: false,
onlineUsers: 0,
timestamp: new Date().toISOString(),
error: "Health check partially failed",
},
{ status: 503 },
);
}
});
+5 -4
View File
@@ -1,4 +1,5 @@
import { withAdmin } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { runCatalogAudit } from "@/lib/services/catalog-audit";
@@ -32,8 +33,8 @@ export const POST = withAdmin(
applySql = body.applySql === true;
repairFurniData = body.repairFurniData === true;
repairStructure = body.repairStructure === true;
} catch {
/* no body */
} catch (err) {
logger.warn("Failed to parse audit request body", { err });
}
const stream = new ReadableStream({
@@ -64,8 +65,8 @@ export const POST = withAdmin(
try {
controller.close();
} catch {
/* ignore */
} catch (err) {
logger.warn("Failed to close audit SSE stream", { err });
}
},
});
@@ -7,6 +7,15 @@ import { getSource } from "@/lib/services/clone-sources";
// In-process cache of extracted icons, keyed by source+classname.
// null = known-missing (don't refetch the bundle every render).
const iconCache = new Map<string, Buffer | null>();
const MAX_ICON_CACHE_SIZE = 500;
function pruneIconCache() {
while (iconCache.size > MAX_ICON_CACHE_SIZE) {
const oldest = iconCache.keys().next().value;
if (oldest === undefined) break;
iconCache.delete(oldest);
}
}
// GET ?source=<id>&classname=<cn>
// Fetches the source's .nitro bundle and returns the embedded furni icon as a
@@ -39,6 +48,7 @@ export const GET = withAdmin(
}
icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
iconCache.set(cacheKey, icon);
pruneIconCache();
} catch {
iconCache.set(cacheKey, null);
return apiError("Failed to fetch bundle", 502);
+1 -1
View File
@@ -12,7 +12,7 @@ export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
if (request.nextUrl.searchParams.get("action") === "stats")
return apiOk(getPetStats());
return apiOk(await getPetStats());
const search = request.nextUrl.searchParams.get("search") || "";
return apiOk({ pets: getPetList(search) });
},
@@ -14,9 +14,7 @@ export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => {
sql`SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC`,
)) as unknown as [BlacklistWord[], unknown];
return apiOk({
words: words.map((w) => ({ ...w, created_at: "" })),
});
return apiOk({ words });
});
export const POST = withAdmin(
-1
View File
@@ -52,7 +52,6 @@ export const GET = withAdmin(
icon: p.icon || "",
effect: p.effect || "",
active: Boolean(p.active),
created_at: "",
})),
total,
page,
+95
View File
@@ -0,0 +1,95 @@
import { promises as fs } from "node:fs";
import { desc, eq } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, Soundtracks } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import {
extractMp3Duration,
getSoundtrackPath,
isSafeSoundtrackCode,
MAX_SOUNDTRACK_SIZE,
validateMp3Bytes,
writeSoundtrackFile,
} from "@/lib/services/soundtracks";
export const POST = withAdmin(
{ permission: PERMS.CATALOG_EDIT, requireCsrf: true },
async (request) => {
const formData = await request.formData();
const file = formData.get("file");
const name = String(formData.get("name") ?? "").trim();
const author = String(formData.get("author") ?? "").trim();
if (!(file instanceof File)) {
return apiError("No file uploaded", 400);
}
if (!name) {
return apiError("Name is required", 400);
}
if (file.size > MAX_SOUNDTRACK_SIZE) {
return apiError("File exceeds 10 MB limit", 413);
}
if (file.size === 0) {
return apiError("File is empty", 400);
}
const buf = Buffer.from(await file.arrayBuffer());
if (!validateMp3Bytes(buf)) {
return apiError("File is not a valid MP3", 415);
}
const duration = await extractMp3Duration(buf);
const maxRow = await db
.select({ maxId: Soundtracks.id })
.from(Soundtracks)
.orderBy(desc(Soundtracks.id))
.limit(1)
.then((rows) => rows[0]?.maxId ?? 0)
.catch(() => 0);
const nextId = maxRow + 1;
const code = `song_${nextId}`;
if (!isSafeSoundtrackCode(code)) {
return apiError("Generated code is invalid", 500);
}
const existing = await db
.select({ id: Soundtracks.id })
.from(Soundtracks)
.where(eq(Soundtracks.code, code))
.limit(1)
.catch(() => null);
if (existing && existing.length > 0) {
return apiError("Song code already exists", 409);
}
await writeSoundtrackFile(code, buf);
let id: number;
try {
const result = await db.insert(Soundtracks).values({
code,
name: name.slice(0, 100),
author: author.slice(0, 50),
track: "",
length: duration,
});
id = Number(result[0].insertId);
} catch (err) {
logger.error("Failed to insert soundtrack record", { err, code });
try {
await fs.unlink(getSoundtrackPath(code));
} catch {
/* ignore cleanup failure */
}
return apiError("Failed to save soundtrack", 500);
}
return apiOk({ ok: true, id, code });
},
);