From 2ff5b47104300a151dc765cae18d1c158074a920 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 21:14:35 +0200 Subject: [PATCH] Harden catalog writes: bulk import batch + field allowlists. Bulk import resolves names from items_base and refreshes RCON once. Page/item updates only accept an allowlisted field set. Co-authored-by: Cursor --- src/actions/catalog-bc.ts | 51 ++++- src/actions/catalog-items.ts | 175 +++++++++++++++++- src/actions/catalog.ts | 42 ++++- .../admin/catalog/bulk-import-items.tsx | 70 ++++--- 4 files changed, 288 insertions(+), 50 deletions(-) diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index dfff3f5e..6008ccb6 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -7,12 +7,55 @@ import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; +const BC_PAGE_FIELDS = [ + "caption", + "parentId", + "pageLayout", + "enabled", + "visible", + "orderNum", + "iconImage", + "iconColor", + "pageHeadline", + "pageTeaser", + "pageSpecial", + "pageText1", + "pageText2", + "pageTextDetails", + "pageTextTeaser", +] as const; + +const BC_ITEM_FIELDS = [ + "itemIds", + "catalogName", + "orderNumber", + "extradata", + "pageId", +] as const; + +function pickAllowed( + fields: Record, + allowed: readonly string[], +) { + const out: Record = {}; + for (const key of allowed) { + if (Object.hasOwn(fields, key) && fields[key] !== undefined) { + out[key] = fields[key]; + } + } + return out; +} + export async function updateBcPage({ id, ...fields }: { id: number } & Record) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await prisma.catalogPagesBc.update({ where: { id }, data: fields as any }); + const data = pickAllowed(fields, BC_PAGE_FIELDS); + if (Object.keys(data).length === 0) { + return { ok: false as const, error: "No valid fields to update" }; + } + await prisma.catalogPagesBc.update({ where: { id }, data: data as any }); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, @@ -51,7 +94,11 @@ export async function updateBcItem({ extradata?: string; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await prisma.catalogItemsBc.update({ where: { id }, data: data as any }); + const safe = pickAllowed(data as Record, BC_ITEM_FIELDS); + if (Object.keys(safe).length === 0) { + return { ok: false as const, error: "No valid fields to update" }; + } + await prisma.catalogItemsBc.update({ where: { id }, data: safe as any }); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 75410a1f..0b041f67 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -7,6 +7,62 @@ import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; +const CATALOG_ITEM_FIELDS = [ + "pageId", + "itemIds", + "catalogName", + "costCredits", + "costPoints", + "pointsType", + "amount", + "orderNumber", + "offerId", + "songId", + "limitedSells", + "limitedStack", + "extradata", + "haveOffer", + "clubOnly", +] as const; + +const ITEMS_BASE_FIELDS = [ + "publicName", + "itemName", + "type", + "width", + "length", + "stackHeight", + "allowStack", + "allowSit", + "allowLay", + "allowWalk", + "allowGift", + "allowTrade", + "allowRecycle", + "allowMarketplaceSell", + "allowInventoryStack", + "interactionType", + "interactionModesCount", + "vendingIds", + "customparams", + "effectIdMale", + "effectIdFemale", + "clothingOnWalk", +] as const; + +function pickAllowed( + fields: Record, + allowed: readonly string[], +): Record { + const out: Record = {}; + for (const key of allowed) { + if (Object.hasOwn(fields, key) && fields[key] !== undefined) { + out[key] = fields[key]; + } + } + return out; +} + export async function createCatalogItem(data: { pageId: number; itemIds: string; @@ -25,7 +81,20 @@ export async function createCatalogItem(data: { clubOnly: "0" | "1"; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const created = await prisma.catalogItems.create({ data }); + let catalogName = data.catalogName.trim(); + if (!catalogName) { + const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10); + if (firstId > 0) { + const base = await prisma.itemsBase.findUnique({ + where: { id: firstId }, + select: { publicName: true, itemName: true }, + }); + catalogName = base?.publicName || base?.itemName || String(firstId); + } + } + const created = await prisma.catalogItems.create({ + data: { ...data, catalogName }, + }); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, @@ -38,6 +107,84 @@ export async function createCatalogItem(data: { return { ok: true as const, data: { id: created.id } }; } +/** Bulk create with one RCON refresh at the end. */ +export async function bulkCreateCatalogItems({ + pageId, + rows, +}: { + pageId: number; + rows: Array<{ + baseId: number; + credits?: number; + points?: number; + pointsType?: number; + }>; +}) { + const staff = await requirePermission(PERMS.CATALOG_EDIT); + if (rows.length === 0) { + return { ok: true as const, data: { created: 0, failed: 0 } }; + } + if (rows.length > 500) { + return { ok: false as const, error: "Max 500 items per bulk import" }; + } + + const baseIds = [...new Set(rows.map((r) => r.baseId))]; + const bases = await prisma.itemsBase.findMany({ + where: { id: { in: baseIds } }, + select: { id: true, publicName: true, itemName: true }, + }); + const baseMap = new Map(bases.map((b) => [b.id, b])); + + let created = 0; + let failed = 0; + + for (const row of rows) { + const base = baseMap.get(row.baseId); + if (!base) { + failed++; + continue; + } + try { + await prisma.catalogItems.create({ + data: { + pageId, + itemIds: String(row.baseId), + catalogName: base.publicName || base.itemName || String(row.baseId), + costCredits: row.credits ?? 0, + costPoints: row.points ?? 0, + pointsType: row.pointsType ?? 0, + amount: 1, + limitedSells: 0, + limitedStack: 0, + orderNumber: 1, + offerId: -1, + songId: 0, + haveOffer: "1", + clubOnly: "0", + extradata: "", + }, + }); + created++; + } catch { + failed++; + } + } + + if (created > 0) { + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_bulk_create", + description: `Bulk imported ${created} catalog item(s) on page #${pageId}`, + targetType: "catalog_page", + targetId: pageId, + }); + revalidatePath("/admin/catalog"); + } + + return { ok: true as const, data: { created, failed } }; +} + export async function deleteCatalogItems({ ids }: { ids: number[] }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } }); @@ -93,16 +240,25 @@ export async function updateCatalogItem({ baseItem?: { id: number; fields: Record }; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await prisma.catalogItems.update({ - where: { id }, - data: catalogFields as any, - }); - if (baseItem) { - await prisma.itemsBase.update({ - where: { id: baseItem.id }, - data: baseItem.fields as any, + const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS); + if (Object.keys(safeCatalog).length === 0 && !baseItem) { + return { ok: false as const, error: "No valid fields to update" }; + } + if (Object.keys(safeCatalog).length > 0) { + await prisma.catalogItems.update({ + where: { id }, + data: safeCatalog as any, }); } + if (baseItem) { + const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); + if (Object.keys(safeBase).length > 0) { + await prisma.itemsBase.update({ + where: { id: baseItem.id }, + data: safeBase as any, + }); + } + } await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, @@ -160,7 +316,6 @@ export async function translateCatalogItems({ where: { id: base.id }, data: { publicName: nextName }, }); - // Sync catalogName for catalog rows that reference this base item const idStr = String(base.id); const related = await prisma.catalogItems.findMany({ where: { diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 25259814..9a3cb0b7 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -9,12 +9,52 @@ import { deletePage, movePage } from "@/lib/services/catalog-tree"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; +const CATALOG_PAGE_FIELDS = [ + "caption", + "parentId", + "pageLayout", + "enabled", + "visible", + "minRank", + "clubOnly", + "vipOnly", + "orderNum", + "iconImage", + "iconColor", + "pageHeadline", + "pageTeaser", + "pageSpecial", + "pageText1", + "pageText2", + "pageTextDetails", + "pageTextTeaser", + "includes", + "captionSave", +] as const; + +function pickPageFields(fields: Record) { + const out: Record = {}; + for (const key of CATALOG_PAGE_FIELDS) { + if (Object.hasOwn(fields, key) && fields[key] !== undefined) { + out[key] = fields[key]; + } + } + return out; +} + export async function updateCatalogPage({ id, ...fields }: { id: number } & Record): Promise { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await prisma.catalogPages.update({ where: { id }, data: fields as any }); + const data = pickPageFields(fields); + if (Object.keys(data).length === 0) { + return { ok: false as const, error: "No valid fields to update" }; + } + if (typeof data.caption === "string" && !data.captionSave) { + data.captionSave = data.caption.slice(0, 25); + } + await prisma.catalogPages.update({ where: { id }, data: data as any }); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, diff --git a/src/components/admin/catalog/bulk-import-items.tsx b/src/components/admin/catalog/bulk-import-items.tsx index 6868b780..cd2fb342 100644 --- a/src/components/admin/catalog/bulk-import-items.tsx +++ b/src/components/admin/catalog/bulk-import-items.tsx @@ -3,7 +3,7 @@ import { AlertTriangle, CheckCircle2, Loader2, Upload } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; -import { createCatalogItem } from "@/actions/catalog-items"; +import { bulkCreateCatalogItems } from "@/actions/catalog-items"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -91,43 +91,39 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) { return; } setImporting(true); - let success = 0; - let failed = 0; - for (const row of valid) { - try { - const res = await createCatalogItem({ - pageId, - itemIds: String(row.baseId!), - catalogName: "", - costCredits: row.credits ?? 0, - costPoints: row.points ?? 0, - pointsType: row.pointsType ?? 0, - amount: 1, - limitedSells: 0, - limitedStack: 0, - orderNumber: 1, - offerId: -1, - songId: 0, - haveOffer: "1", - clubOnly: "0", - extradata: "", - }); - if (res.ok) success++; - else failed++; - } catch { - failed++; + try { + const res = await bulkCreateCatalogItems({ + pageId, + rows: valid.map((row) => ({ + baseId: row.baseId!, + credits: row.credits, + points: row.points, + pointsType: row.pointsType, + })), + }); + if (!res.ok) { + toast.error(res.error || "Bulk import failed."); + return; } - } - setImporting(false); - if (success > 0) { - toast.success( - `Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`, - ); - setInput(""); - setOpen(false); - onImported?.(); - } else { - toast.error(`All ${failed} imports failed.`); + const { created, failed } = res.data; + if (created > 0) { + toast.success( + `Imported ${created} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`, + ); + setInput(""); + setOpen(false); + onImported?.(); + } else { + toast.error( + failed > 0 + ? `All ${failed} imports failed (unknown base IDs?).` + : "Nothing imported.", + ); + } + } catch { + toast.error("Bulk import failed."); + } finally { + setImporting(false); } }