From 1b817fe434a5cc70c26f9b6f7f8a6c4fe0e024a7 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 6 Aug 2026 18:32:55 +0200 Subject: [PATCH] fix: resolve critical bugs and improve admin panel reliability - 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 --- src/actions/admin-radio-extra.ts | 33 +++---- src/actions/admin-settings.ts | 6 +- src/app/admin/catalog/catalog-table.tsx | 19 +++- .../import/clone/import-clone-client.tsx | 16 ++-- .../import/furni/import-furni-client.tsx | 8 -- .../import/furni/nitro-editor-dialog.tsx | 14 +-- .../admin/rooms/[id]/furni/furni-client.tsx | 54 ++++++++--- src/app/admin/rooms/[id]/room-edit-form.tsx | 18 ++-- src/app/admin/sounds/sounds-client.tsx | 2 +- src/app/admin/sounds/trax-player.tsx | 4 +- src/app/admin/translations/emulator/page.tsx | 12 ++- src/app/api/admin/analytics/export/route.ts | 2 +- src/app/api/admin/devops/health/route.ts | 35 +++++-- src/app/api/admin/import/audit/route.ts | 9 +- src/app/api/admin/import/clone/icon/route.ts | 10 ++ src/app/api/admin/import/pets/route.ts | 2 +- src/app/api/admin/prefixes/blacklist/route.ts | 4 +- src/app/api/admin/prefixes/route.ts | 1 - src/app/api/admin/sounds/upload/route.ts | 95 +++++++++++++++++++ 19 files changed, 255 insertions(+), 89 deletions(-) create mode 100644 src/app/api/admin/sounds/upload/route.ts diff --git a/src/actions/admin-radio-extra.ts b/src/actions/admin-radio-extra.ts index 7f3debab83..ca8ec20114 100644 --- a/src/actions/admin-radio-extra.ts +++ b/src/actions/admin-radio-extra.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db"; +import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; import { siteSettings } from "@/lib/services/site-settings"; @@ -50,8 +51,8 @@ export async function saveRadioSetting(formData: FormData): Promise { .values({ key, value, comment: comment || null }) .onDuplicateKeyUpdate({ set: { value } }); siteSettings.reload(); - } catch { - // DB unavailable — fail soft so the action does not throw. + } catch (err) { + logger.error("Failed to save radio setting", { err, key }); } revalidatePath("/admin/radio/settings"); } @@ -81,8 +82,8 @@ export async function saveRadioSettings(formData: FormData): Promise { }), ); siteSettings.reload(); - } catch { - // Fail soft. + } catch (err) { + logger.error("Failed to bulk-save radio settings", { err, keys }); } revalidatePath("/admin/radio/settings"); } @@ -114,8 +115,8 @@ export async function createRadioBanner(formData: FormData): Promise { createdAt: now, updatedAt: now, }); - } catch { - // Fail soft. + } catch (err) { + logger.error("Failed to create radio banner", { err, imagePath }); } revalidatePath("/admin/radio/banners"); } @@ -147,8 +148,8 @@ export async function updateRadioBanner(formData: FormData): Promise { updatedAt: new Date(), }) .where(eq(RadioBanners.id, id)); - } catch { - // Row may be gone; ignore. + } catch (err) { + logger.error("Failed to update radio banner", { err, id: String(id) }); } revalidatePath("/admin/radio/banners"); } @@ -159,8 +160,8 @@ export async function deleteRadioBanner(formData: FormData): Promise { if (id === null) return; try { await db.delete(RadioBanners).where(eq(RadioBanners.id, id)); - } catch { - // Already deleted; ignore. + } catch (err) { + logger.error("Failed to delete radio banner", { err, id: String(id) }); } revalidatePath("/admin/radio/banners"); } @@ -186,8 +187,8 @@ export async function createRadioRank(formData: FormData): Promise { createdAt: now, updatedAt: now, }); - } catch { - // Fail soft. + } catch (err) { + logger.error("Failed to create radio rank", { err, name }); } revalidatePath("/admin/radio/ranks"); } @@ -214,8 +215,8 @@ export async function updateRadioRank(formData: FormData): Promise { updatedAt: new Date(), }) .where(eq(RadioRanks.id, id)); - } catch { - // Row may be gone; ignore. + } catch (err) { + logger.error("Failed to update radio rank", { err, id: String(id) }); } revalidatePath("/admin/radio/ranks"); } @@ -226,8 +227,8 @@ export async function deleteRadioRank(formData: FormData): Promise { if (id === null) return; try { await db.delete(RadioRanks).where(eq(RadioRanks.id, id)); - } catch { - // Already deleted; ignore. + } catch (err) { + logger.error("Failed to delete radio rank", { err, id: String(id) }); } revalidatePath("/admin/radio/ranks"); } diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index 391c370ca2..27a5396c65 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -77,9 +77,9 @@ export async function updateSetting(formData: FormData): Promise { ); if (!key) return; await db - .update(WebsiteSetting) - .set({ value }) - .where(eq(WebsiteSetting.key, key)); + .insert(WebsiteSetting) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); await siteSettings.reload(); bustGamedataCachesIfNeeded(key); revalidatePath("/admin/settings"); diff --git a/src/app/admin/catalog/catalog-table.tsx b/src/app/admin/catalog/catalog-table.tsx index 50d1cf13df..6cfa94591f 100644 --- a/src/app/admin/catalog/catalog-table.tsx +++ b/src/app/admin/catalog/catalog-table.tsx @@ -2,8 +2,8 @@ import { Pencil } from "lucide-react"; import Link from "next/link"; -import { toggleCatalogPage } from "@/actions/catalog"; -import { toggleBcPage } from "@/actions/catalog-bc"; +import { deleteCatalogPage, toggleCatalogPage } from "@/actions/catalog"; +import { deleteBcTreePage, toggleBcPage } from "@/actions/catalog-bc"; import { DataTable } from "@/components/admin/data-table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -175,6 +175,20 @@ export function CatalogTable({ }, ]; + async function handleBulkAction(action: string, ids: (number | string)[]) { + if (action !== "delete" || ids.length === 0) return; + for (const id of ids) { + const numId = typeof id === "string" ? Number(id) : id; + if (Number.isInteger(numId) && numId > 0) { + run(() => + isBc + ? deleteBcTreePage({ pageId: numId, mode: "reparent" }) + : deleteCatalogPage({ id: numId }), + ); + } + } + } + return ( ); } diff --git a/src/app/admin/import/clone/import-clone-client.tsx b/src/app/admin/import/clone/import-clone-client.tsx index da1dd32b45..df7f12f656 100644 --- a/src/app/admin/import/clone/import-clone-client.tsx +++ b/src/app/admin/import/clone/import-clone-client.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -370,6 +371,7 @@ interface FurniGridProps { const PER_PAGE = 50; function FurniGrid({ source }: FurniGridProps) { + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [searchTerm, setSearchTerm] = useState(""); const [activeSearch, setActiveSearch] = useState(""); const [items, setItems] = useState([]); @@ -554,13 +556,12 @@ function FurniGrid({ source }: FurniGridProps) { toast.info("Nothing to clone — everything is already present"); return; } - if ( - !window.confirm( - `Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`, - ) - ) { - return; - } + const confirmed = await confirm({ + title: "Clone All Furni", + description: `Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`, + confirmLabel: "Clone All", + }); + if (!confirmed) return; batchAbortRef.current?.abort(); const abort = new AbortController(); batchAbortRef.current = abort; @@ -614,6 +615,7 @@ function FurniGrid({ source }: FurniGridProps) { return (
+ {confirmDialog} {/* Stats bar */} {stats && (
diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index 47c46b05e0..9ad1e6268e 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -396,14 +396,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { } }, [statusFilter, activeSearch, fetchItems]); - function _doSearch(e?: React.FormEvent) { - e?.preventDefault(); - setActiveSearch(searchTerm); - setPage(1); - fetchItems(searchTerm, 1, statusFilter); - } - void _doSearch; - function goPage(p: number) { setPage(p); fetchItems(activeSearch, p, statusFilter); diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index 0d6fac46b2..e89e4120c4 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -215,7 +215,7 @@ export function NitroEditorDialog({ return; } - toast.success("Nitro metadata salvato"); + toast.success("Nitro metadata saved"); setMetadata(metadataToSave); setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave))); setOriginalFlags({ ...flags }); @@ -392,7 +392,7 @@ export function NitroEditorDialog({
-

Dimensioni

+

Dimensions

@@ -400,7 +400,7 @@ export function NitroEditorDialog({ htmlFor="dim-x" className="text-xs text-muted-foreground" > - X (larghezza) + X (width) - Z (altezza) + Z (height)
-

Interazione

+

Interaction

@@ -489,7 +489,7 @@ export function NitroEditorDialog({
-

Direzioni

+

Directions

{directions.length} attive @@ -784,7 +784,7 @@ export function NitroEditorDialog({ variant="secondary" className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]" > - Modifiche non salvate + Unsaved changes )}
diff --git a/src/app/admin/rooms/[id]/furni/furni-client.tsx b/src/app/admin/rooms/[id]/furni/furni-client.tsx index 4138cfbff2..07ffc69a5c 100644 --- a/src/app/admin/rooms/[id]/furni/furni-client.tsx +++ b/src/app/admin/rooms/[id]/furni/furni-client.tsx @@ -24,6 +24,7 @@ import { deleteRoomItem, roomRconAction, } from "@/actions/rooms"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -91,6 +92,7 @@ function isWallType(t: string) { export function FurniClient({ room, items = [], canEdit }: Props) { const { run, isPending } = useServerAction(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [view, setView] = useState("flat"); @@ -231,29 +233,44 @@ export function FurniClient({ room, items = [], canEdit }: Props) { setSelected(next); } - function handleRcon(action: "reload" | "kick") { - if (action === "kick" && !confirm("Kick all users from this room?")) return; + async function handleRcon(action: "reload" | "kick") { + if (action === "kick") { + const ok = await confirm({ + title: "Kick Users", + description: "Kick all users from this room?", + confirmLabel: "Kick", + variant: "danger", + }); + if (!ok) return; + } run(() => roomRconAction({ roomId: room.id, action }), { successMessage: action === "reload" ? "Room reloaded" : "All users kicked", }); } - function handleDelete(itemId: number, name: string) { - if (!confirm(`Delete "${name}" (#${itemId}) from this room?`)) return; + async function handleDelete(itemId: number, name: string) { + const ok = await confirm({ + title: "Delete Item", + description: `Delete "${name}" (#${itemId}) from this room?`, + confirmLabel: "Delete", + variant: "danger", + }); + if (!ok) return; run(() => deleteRoomItem({ roomId: room.id, itemId }), { successMessage: "Item deleted", }); } - function handleBulkDelete() { + async function handleBulkDelete() { if (selected.size === 0) return; - if ( - !confirm( - `Delete ${selected.size} selected item${selected.size > 1 ? "s" : ""}?`, - ) - ) - return; + const ok = await confirm({ + title: "Delete Items", + description: `Delete ${selected.size} selected item${selected.size > 1 ? "s" : ""}?`, + confirmLabel: "Delete", + variant: "danger", + }); + if (!ok) return; run( () => bulkDeleteRoomItems({ roomId: room.id, itemIds: Array.from(selected) }), @@ -264,8 +281,18 @@ export function FurniClient({ room, items = [], canEdit }: Props) { ); } - function handleDeleteGroup(_baseId: number, ids: number[], name: string) { - if (!confirm(`Delete all ${ids.length} "${name}" from this room?`)) return; + async function handleDeleteGroup( + _baseId: number, + ids: number[], + name: string, + ) { + const ok = await confirm({ + title: "Delete Items", + description: `Delete all ${ids.length} "${name}" from this room?`, + confirmLabel: "Delete", + variant: "danger", + }); + if (!ok) return; run(() => bulkDeleteRoomItems({ roomId: room.id, itemIds: ids }), { successMessage: `${ids.length} item(s) deleted`, }); @@ -276,6 +303,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) { return (
+ {confirmDialog} {/* ── Header ─────────────────────────────────────────── */}
diff --git a/src/app/admin/rooms/[id]/room-edit-form.tsx b/src/app/admin/rooms/[id]/room-edit-form.tsx index 85cb5118eb..70958fcd31 100644 --- a/src/app/admin/rooms/[id]/room-edit-form.tsx +++ b/src/app/admin/rooms/[id]/room-edit-form.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { useForm } from "react-hook-form"; import { deleteRoom, roomRconAction, updateRoom } from "@/actions/rooms"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -63,13 +64,17 @@ export function RoomEditForm({ room }: RoomEditFormProps) { }); } - function handleDelete() { - if ( - !confirm( + const { confirm, dialog: confirmDialog } = useConfirmDialog(); + + async function handleDelete() { + const ok = await confirm({ + title: "Delete Room", + description: "Are you sure you want to delete this room? This cannot be undone.", - ) - ) - return; + confirmLabel: "Delete", + variant: "danger", + }); + if (!ok) return; run(() => deleteRoom({ id: room.id }), { redirectTo: "/admin/rooms" }); } @@ -82,6 +87,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) { return (
+ {confirmDialog}
{/* Edit Form */} diff --git a/src/app/admin/sounds/sounds-client.tsx b/src/app/admin/sounds/sounds-client.tsx index 0ec0d0bb1d..c4b4b76385 100644 --- a/src/app/admin/sounds/sounds-client.tsx +++ b/src/app/admin/sounds/sounds-client.tsx @@ -271,7 +271,7 @@ export function SoundsClient({ onEnded={() => setPlayingId(null)} onError={() => toast.error( - `Impossibile riprodurre "${s.code}.mp3" da ${baseUrl}.`, + `Failed to play "${s.code}.mp3" from ${baseUrl}.`, ) } /> diff --git a/src/app/admin/sounds/trax-player.tsx b/src/app/admin/sounds/trax-player.tsx index 28e173f37e..c54c3e5d78 100644 --- a/src/app/admin/sounds/trax-player.tsx +++ b/src/app/admin/sounds/trax-player.tsx @@ -100,7 +100,7 @@ export function TraxPlayer({ const AudioCtor = window.AudioContext; if (!AudioCtor) { - toast.error("Web Audio API non supportata da questo browser."); + toast.error("Web Audio API is not supported by this browser."); setStatus("error"); return; } @@ -137,7 +137,7 @@ export function TraxPlayer({ if (failed > 0) { toast.warning( - `${failed}/${uniqueIds.length} sample non caricati — la riproduzione potrebbe avere buchi.`, + `${failed}/${uniqueIds.length} samples failed to load — playback may have gaps.`, ); } if (failed === uniqueIds.length) { diff --git a/src/app/admin/translations/emulator/page.tsx b/src/app/admin/translations/emulator/page.tsx index 7224a023e8..d82fa22c84 100644 --- a/src/app/admin/translations/emulator/page.tsx +++ b/src/app/admin/translations/emulator/page.tsx @@ -1,4 +1,4 @@ -import { and, asc, count, like, or, type SQL } from "drizzle-orm"; +import { and, asc, count, like, or, type SQL, sql } from "drizzle-orm"; import { ExternalLink } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; @@ -34,10 +34,12 @@ export default async function EmulatorTranslationsPage({ const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200); const page = Math.max(parseInt(sp.page || "1", 10), 1); - const patternFilters = or( - ...TRANSLATION_KEY_PATTERNS.map((p) => like(EmulatorSettings.key, `${p}%`)), - ); - if (!patternFilters) throw new Error("No emulator translation key patterns"); + const patternFilters = + or( + ...TRANSLATION_KEY_PATTERNS.map((p) => + like(EmulatorSettings.key, `${p}%`), + ), + ) ?? sql`1=0`; const conditions: SQL[] = [patternFilters]; if (group) conditions.push(like(EmulatorSettings.key, `${group}.%`)); diff --git a/src/app/api/admin/analytics/export/route.ts b/src/app/api/admin/analytics/export/route.ts index 7101009c84..7df399da72 100644 --- a/src/app/api/admin/analytics/export/route.ts +++ b/src/app/api/admin/analytics/export/route.ts @@ -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"]); diff --git a/src/app/api/admin/devops/health/route.ts b/src/app/api/admin/devops/health/route.ts index f9ac148672..74ac1429eb 100644 --- a/src/app/api/admin/devops/health/route.ts +++ b/src/app/api/admin/devops/health/route.ts @@ -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 }, + ); + } }); diff --git a/src/app/api/admin/import/audit/route.ts b/src/app/api/admin/import/audit/route.ts index 3f137e9dd7..d37d8f1d41 100644 --- a/src/app/api/admin/import/audit/route.ts +++ b/src/app/api/admin/import/audit/route.ts @@ -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 }); } }, }); diff --git a/src/app/api/admin/import/clone/icon/route.ts b/src/app/api/admin/import/clone/icon/route.ts index 44a49a13bf..03db3d3ea0 100644 --- a/src/app/api/admin/import/clone/icon/route.ts +++ b/src/app/api/admin/import/clone/icon/route.ts @@ -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(); +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=&classname= // 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); diff --git a/src/app/api/admin/import/pets/route.ts b/src/app/api/admin/import/pets/route.ts index 433866488a..b5fdc7123f 100644 --- a/src/app/api/admin/import/pets/route.ts +++ b/src/app/api/admin/import/pets/route.ts @@ -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) }); }, diff --git a/src/app/api/admin/prefixes/blacklist/route.ts b/src/app/api/admin/prefixes/blacklist/route.ts index 9c2b01dda5..6d411fdd1b 100644 --- a/src/app/api/admin/prefixes/blacklist/route.ts +++ b/src/app/api/admin/prefixes/blacklist/route.ts @@ -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( diff --git a/src/app/api/admin/prefixes/route.ts b/src/app/api/admin/prefixes/route.ts index 5a1cd2526d..1901f6936b 100644 --- a/src/app/api/admin/prefixes/route.ts +++ b/src/app/api/admin/prefixes/route.ts @@ -52,7 +52,6 @@ export const GET = withAdmin( icon: p.icon || "", effect: p.effect || "", active: Boolean(p.active), - created_at: "", })), total, page, diff --git a/src/app/api/admin/sounds/upload/route.ts b/src/app/api/admin/sounds/upload/route.ts new file mode 100644 index 0000000000..618327509a --- /dev/null +++ b/src/app/api/admin/sounds/upload/route.ts @@ -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 }); + }, +);