diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 2cf94ac2..574cc9a1 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -5,9 +5,11 @@ import { Prisma } from "@/generated/prisma/client"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; +import { logAudit } from "@/lib/services/audit"; import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { translateItemsSchema } from "@/lib/validators/catalog"; const CATALOG_ITEM_FIELDS = [ "pageId", @@ -323,13 +325,19 @@ export async function updateCatalogItem({ return { ok: true as const, data: {} }; } -export async function translateCatalogItems({ - items, -}: { +export async function translateCatalogItems(input: { /** `id` is items_base.id (not catalog_items.id) */ - items: Array<{ id: number; publicName: string; description: string }>; + items: Array<{ id: number; publicName: string; description?: string }>; }) { - await requirePermission(PERMS.CATALOG_EDIT); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + const parsed = translateItemsSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false as const, + error: parsed.error.issues[0]?.message ?? "Invalid translate payload", + }; + } + const { items } = parsed.data; const { invalidateFurniDataCache } = await import( "@/lib/services/catalog-items-loader" ); @@ -413,6 +421,17 @@ export async function translateCatalogItems({ } await rcon.updateCatalog(); + await logAudit({ + userId: staff.id, + action: "items_base_translate", + target: "ItemsBase", + after: { + namesUpdated, + descriptionsUpdated, + furniDataUpdated: furniResult.updated > 0, + furniDataInserted: furniResult.inserted, + }, + }); revalidatePath("/admin/catalog"); return { ok: true as const, diff --git a/src/app/admin/import/clone/import-clone-client.tsx b/src/app/admin/import/clone/import-clone-client.tsx index 3fbf3f38..47acaeda 100644 --- a/src/app/admin/import/clone/import-clone-client.tsx +++ b/src/app/admin/import/clone/import-clone-client.tsx @@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { adminFetch } from "@/lib/admin-fetch"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -62,7 +63,7 @@ async function runSseImport( onDone: (classname: string) => void, onComplete: (succeeded: number, failed: number) => void, ): Promise { - const res = await fetch(url, { + const res = await adminFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -202,7 +203,7 @@ function SourcesManager({ async function handleSave(src: Partial) { setSaving(true); try { - const res = await fetch("/api/admin/import/clone", { + const res = await adminFetch("/api/admin/import/clone", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(src), @@ -224,7 +225,7 @@ function SourcesManager({ async function handleDelete(src: CloneSource) { setDeletingId(src.id); try { - const res = await fetch( + const res = await adminFetch( `/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, { method: "DELETE", @@ -417,7 +418,7 @@ function FurniGrid({ source }: FurniGridProps) { }); if (search) params.set("search", search); if (filterVal !== "all") params.set("filter", filterVal); - const res = await fetch(`/api/admin/import/clone?${params}`); + const res = await adminFetch(`/api/admin/import/clone?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); @@ -535,7 +536,7 @@ function FurniGrid({ source }: FurniGridProps) { async function cloneAll() { let names: string[] = []; try { - const res = await fetch( + const res = await adminFetch( `/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`, ); const data = await res.json(); @@ -913,7 +914,7 @@ export function ImportCloneClient() { const fetchSources = useCallback(async () => { try { - const res = await fetch("/api/admin/import/clone?action=sources"); + const res = await adminFetch("/api/admin/import/clone?action=sources"); const data = await res.json(); if (res.ok) { setSources(data.sources || []); diff --git a/src/app/admin/import/clothing/import-clothing-client.tsx b/src/app/admin/import/clothing/import-clothing-client.tsx index 3b423b8d..48c2059f 100644 --- a/src/app/admin/import/clothing/import-clothing-client.tsx +++ b/src/app/admin/import/clothing/import-clothing-client.tsx @@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { getAvatarUrl } from "@/lib/imager"; +import { adminFetch } from "@/lib/admin-fetch"; // ── Shared types ───────────────────────────────────────────────────────────── @@ -50,7 +51,7 @@ async function runSseImport( onDone: (label: string) => void, onComplete: (succeeded: number, failed: number) => void, ): Promise { - const res = await fetch(url, { + const res = await adminFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -155,7 +156,7 @@ function LibsView() { try { const params = new URLSearchParams({ page: String(pageNum) }); if (search) params.set("search", search); - const res = await fetch(`/api/admin/import/clothing?${params}`); + const res = await adminFetch(`/api/admin/import/clothing?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); @@ -251,7 +252,7 @@ function LibsView() { async function remove(p: FigureItem) { setBusyLib(p.lib); try { - const res = await fetch( + const res = await adminFetch( `/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, { method: "DELETE", @@ -532,7 +533,7 @@ function SetsView() { try { const params = new URLSearchParams({ page: String(pageNum) }); if (search) params.set("search", search); - const res = await fetch(`/api/admin/import/clothing/sets?${params}`); + const res = await adminFetch(`/api/admin/import/clothing/sets?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); diff --git a/src/app/admin/import/effects/import-effects-client.tsx b/src/app/admin/import/effects/import-effects-client.tsx index 791a1430..a03a10d6 100644 --- a/src/app/admin/import/effects/import-effects-client.tsx +++ b/src/app/admin/import/effects/import-effects-client.tsx @@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { adminFetch } from "@/lib/admin-fetch"; interface EffectItem { id: string; @@ -50,7 +51,7 @@ export function ImportEffectsClient() { try { const params = new URLSearchParams(); if (search) params.set("search", search); - const res = await fetch(`/api/admin/import/effects?${params}`); + const res = await adminFetch(`/api/admin/import/effects?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); @@ -101,7 +102,7 @@ export function ImportEffectsClient() { async function importViaSse(items: EffectItem[]) { setBatchProgress({ done: 0, total: items.length }); - const res = await fetch("/api/admin/import/effects/batch", { + const res = await adminFetch("/api/admin/import/effects/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items, concurrency: 3 }), @@ -167,7 +168,7 @@ export function ImportEffectsClient() { async function remove(e: EffectItem) { setBusyLib(e.lib); try { - const res = await fetch( + const res = await adminFetch( `/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, { method: "DELETE", diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index e5868c41..81f7f90e 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -52,6 +52,7 @@ import { SelectValue, } from "@/components/ui/select"; import { NitroEditorDialog } from "./nitro-editor-dialog"; +import { adminFetch } from "@/lib/admin-fetch"; interface FurniItem { id: number; @@ -222,7 +223,7 @@ export function ImportFurniClient() { const fetchStats = useCallback(async () => { try { - const res = await fetch("/api/admin/import/furni?action=stats"); + const res = await adminFetch("/api/admin/import/furni?action=stats"); const data = await res.json(); if (res.ok) { setStats({ @@ -327,7 +328,7 @@ export function ImportFurniClient() { if (status === "missing-nitro") { params.set("action", "missing-nitro"); } - const res = await fetch(`/api/admin/import/furni?${params}`); + const res = await adminFetch(`/api/admin/import/furni?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); @@ -417,7 +418,7 @@ export function ImportFurniClient() { async function doImport(item: FurniItem) { setImportingId(item.classname); try { - const res = await fetch("/api/admin/import/furni", { + const res = await adminFetch("/api/admin/import/furni", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -483,7 +484,7 @@ export function ImportFurniClient() { setBatchProgress(new Map(initialProgress)); try { - const res = await fetch("/api/admin/import/furni/batch", { + const res = await adminFetch("/api/admin/import/furni/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -590,7 +591,7 @@ export function ImportFurniClient() { async function regenNitro(item: FurniItem) { setRegeneratingNitro((prev) => new Set(prev).add(item.classname)); try { - const res = await fetch("/api/admin/import/furni", { + const res = await adminFetch("/api/admin/import/furni", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -640,7 +641,7 @@ export function ImportFurniClient() { setRegenProgress(new Map(initialProgress)); try { - const res = await fetch("/api/admin/import/furni/batch-regen", { + const res = await adminFetch("/api/admin/import/furni/batch-regen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -753,7 +754,7 @@ export function ImportFurniClient() { async function startReorganize() { setReorganizing(true); try { - const previewRes = await fetch("/api/admin/import/furni?dryrun=1", { + const previewRes = await adminFetch("/api/admin/import/furni?dryrun=1", { method: "PUT", }); const previewData = await previewRes.json(); @@ -779,7 +780,7 @@ export function ImportFurniClient() { setReorgPreview(null); setReorganizing(true); try { - const res = await fetch("/api/admin/import/furni", { method: "PUT" }); + const res = await adminFetch("/api/admin/import/furni", { method: "PUT" }); const d = await res.json(); if (res.ok) { const parts: string[] = []; diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index a54f6c14..74860c70 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -31,6 +31,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { adminFetch } from "@/lib/admin-fetch"; interface NitroEditorDialogProps { classname: string; @@ -121,7 +122,7 @@ export function NitroEditorDialog({ setLoading(true); setJsonError(null); try { - const res = await fetch( + const res = await adminFetch( `/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`, ); if (!res.ok) { @@ -189,7 +190,7 @@ export function NitroEditorDialog({ setSaving(true); try { - const res = await fetch("/api/admin/import/furni/nitro-editor", { + const res = await adminFetch("/api/admin/import/furni/nitro-editor", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/app/admin/import/pets/import-pets-client.tsx b/src/app/admin/import/pets/import-pets-client.tsx index bfc90572..0372a7d2 100644 --- a/src/app/admin/import/pets/import-pets-client.tsx +++ b/src/app/admin/import/pets/import-pets-client.tsx @@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { adminFetch } from "@/lib/admin-fetch"; interface PetItem { lib: string; @@ -57,7 +58,7 @@ export function ImportPetsClient() { try { const params = new URLSearchParams(); if (search) params.set("search", search); - const res = await fetch(`/api/admin/import/pets?${params}`); + const res = await adminFetch(`/api/admin/import/pets?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); @@ -99,7 +100,7 @@ export function ImportPetsClient() { async function importViaSse(items: PetItem[]) { setBatchProgress({ done: 0, total: items.length }); - const res = await fetch("/api/admin/import/pets/batch", { + const res = await adminFetch("/api/admin/import/pets/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items, concurrency: 3 }), @@ -161,7 +162,7 @@ export function ImportPetsClient() { async function remove(p: PetItem) { setBusyLib(p.lib); try { - const res = await fetch( + const res = await adminFetch( `/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, { method: "DELETE", diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 1062e66b..59b8055d 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -11,6 +11,7 @@ import { LanguageSwitcher } from "@/components/language-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { requireStaff } from "@/lib/admin/guard"; import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav"; +import { setCsrfCookie } from "@/lib/foundation/security"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; @@ -22,6 +23,7 @@ export default async function AdminLayout({ children: ReactNode; }) { const staff = await requireStaff(); + const csrfToken = await setCsrfCookie(); if (await siteSettings.getBool("force_staff_2fa", false)) { const u = await prisma.user .findUnique({ @@ -33,18 +35,21 @@ export default async function AdminLayout({ } return ( - }> -
- -
- {children} -
-
-
+ <> + + }> +
+ +
+ {children} +
+
+
+ ); } diff --git a/src/app/admin/sounds/sounds-client.tsx b/src/app/admin/sounds/sounds-client.tsx index c259d408..b79e0e3d 100644 --- a/src/app/admin/sounds/sounds-client.tsx +++ b/src/app/admin/sounds/sounds-client.tsx @@ -46,6 +46,7 @@ import { useServerAction } from "@/hooks/use-server-action"; import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache"; import { parseTraxChannels } from "@/lib/trax-format"; import { TraxPlayer } from "./trax-player"; +import { adminFetch } from "@/lib/admin-fetch"; interface SoundtrackRow { id: number; @@ -434,7 +435,7 @@ function UploadDialog({ fd.append("author", author.trim()); try { - const res = await fetch("/api/admin/sounds/upload", { + const res = await adminFetch("/api/admin/sounds/upload", { method: "POST", body: fd, }); diff --git a/src/app/api/paypal/capture/route.ts b/src/app/api/paypal/capture/route.ts index c9aad4df..d541b891 100644 --- a/src/app/api/paypal/capture/route.ts +++ b/src/app/api/paypal/capture/route.ts @@ -19,6 +19,7 @@ import { } from "@/lib/services/paypal-topup"; import { rcon } from "@/lib/services/rcon"; import { sendCurrency } from "@/lib/services/send-currency"; +import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; @@ -157,6 +158,9 @@ export async function POST(req: Request): Promise { ); } + const hotelName = + (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME; + if (result.status !== "COMPLETED") { // Record the non-completed attempt so support can trace it. try { @@ -164,7 +168,7 @@ export async function POST(req: Request): Promise { where: { userId, transactionId: orderId, status: "CREATED" }, data: { status: result.status, - description: `${env.HOTEL_NAME} top-up (not completed)`, + description: `${hotelName} top-up (not completed)`, amount: result.amount, currency: result.currency, createdAt: new Date(), @@ -189,7 +193,7 @@ export async function POST(req: Request): Promise { where: { userId, transactionId: orderId, status: "CREATED" }, data: { status: "CAPTURED_PENDING_CREDIT", - description: `${env.HOTEL_NAME} top-up: ${credits} credits`, + description: `${hotelName} top-up: ${credits} credits`, amount: result.amount, currency: result.currency, createdAt: new Date(), diff --git a/src/app/api/paypal/create/route.ts b/src/app/api/paypal/create/route.ts index e0a5f7f3..6ac40e26 100644 --- a/src/app/api/paypal/create/route.ts +++ b/src/app/api/paypal/create/route.ts @@ -11,6 +11,7 @@ import { PAYPAL_CURRENCY, } from "@/lib/services/paypal"; import { recordCreatedTopup } from "@/lib/services/paypal-topup"; +import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; @@ -68,10 +69,12 @@ export async function POST(req: Request): Promise { const credits = Math.floor(amount * creditsPerUnit()); const base = env.APP_URL.replace(/\/+$/, ""); + const hotelName = + (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME; try { const order = await createOrder(amount, { - description: `${env.HOTEL_NAME} top-up: ${credits} credits`, + description: `${hotelName} top-up: ${credits} credits`, returnUrl: `${base}/shop/topup?status=success`, cancelUrl: `${base}/shop/topup?status=cancel`, }); diff --git a/src/components/admin/catalog-tree.tsx b/src/components/admin/catalog-tree.tsx index e372a29e..56589eb9 100644 --- a/src/components/admin/catalog-tree.tsx +++ b/src/components/admin/catalog-tree.tsx @@ -52,6 +52,7 @@ import { } from "@/components/ui/tooltip"; import { translateCaption } from "@/lib/catalog-translations"; import { cn } from "@/lib/utils"; +import { adminFetch } from "@/lib/admin-fetch"; /* ─── Types ──────────────────────────────────────────────── */ @@ -211,7 +212,7 @@ export function CatalogTree({ setSearching(true); try { const catParam = catalogType === "bc" ? "&catalog=bc" : ""; - const res = await fetch( + const res = await adminFetch( `/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`, ); if (res.ok) { @@ -237,7 +238,7 @@ export function CatalogTree({ const handleToggleEnabled = useCallback( async (node: TreeNode) => { try { - const res = await fetch("/api/admin/catalog/tree", { + const res = await adminFetch("/api/admin/catalog/tree", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`, @@ -257,7 +258,7 @@ export function CatalogTree({ const handleToggleVisible = useCallback( async (node: TreeNode) => { try { - const res = await fetch("/api/admin/catalog/tree", { + const res = await adminFetch("/api/admin/catalog/tree", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`, @@ -290,7 +291,7 @@ export function CatalogTree({ if (!ok) return; try { - const res = await fetch( + const res = await adminFetch( `/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`, { method: "DELETE", @@ -571,7 +572,7 @@ function TreeItem({ setLoading(true); try { const catParam = catalogType === "bc" ? "&catalog=bc" : ""; - const res = await fetch( + const res = await adminFetch( `/api/admin/catalog/tree?parentId=${node.id}${catParam}`, ); if (res.ok) { diff --git a/src/lib/admin-fetch.test.ts b/src/lib/admin-fetch.test.ts new file mode 100644 index 00000000..5e446be3 --- /dev/null +++ b/src/lib/admin-fetch.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("admin CSRF wiring", () => { + it("defaults CSRF on for mutating withAdmin handlers", () => { + const source = readFileSync( + resolve(process.cwd(), "src/lib/api-handler.ts"), + "utf8", + ); + expect(source).toContain("options.requireCsrf !== false"); + }); + + it("issues a csrf meta tag from the admin layout", () => { + const source = readFileSync( + resolve(process.cwd(), "src/app/admin/layout.tsx"), + "utf8", + ); + expect(source).toContain("setCsrfCookie"); + expect(source).toContain('meta name="csrf-token"'); + }); + + it("provides adminFetch helper that sets x-csrf-token", () => { + const source = readFileSync( + resolve(process.cwd(), "src/lib/admin-fetch.ts"), + "utf8", + ); + expect(source).toContain("x-csrf-token"); + expect(source).toContain("getCsrfToken"); + }); +}); diff --git a/src/lib/admin-fetch.ts b/src/lib/admin-fetch.ts new file mode 100644 index 00000000..9f6c8e6d --- /dev/null +++ b/src/lib/admin-fetch.ts @@ -0,0 +1,31 @@ +const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** Read the CSRF token injected by the admin layout ``. */ +export function getCsrfToken(): string | null { + if (typeof document === "undefined") return null; + return ( + document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute("content") ?? null + ); +} + +/** + * Same-origin fetch for admin APIs. Attaches `x-csrf-token` on mutating methods. + */ +export function adminFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const method = (init?.method ?? "GET").toUpperCase(); + const headers = new Headers(init?.headers); + if (MUTATING.has(method)) { + const token = getCsrfToken(); + if (token) headers.set("x-csrf-token", token); + } + return fetch(input, { + ...init, + headers, + credentials: init?.credentials ?? "same-origin", + }); +} diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index e33ab059..d32184a1 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -25,7 +25,10 @@ export function withAdmin( handler: AdminHandler, ) { return async (request: NextRequest, routeContext: RouteContext = {}) => { - if (options.requireCsrf === true && MUTATING_METHODS.has(request.method)) { + // CSRF required for mutating admin APIs unless explicitly opted out. + const csrfRequired = + options.requireCsrf !== false && MUTATING_METHODS.has(request.method); + if (csrfRequired) { const csrfToken = request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token") ?? diff --git a/src/lib/foundation/security.ts b/src/lib/foundation/security.ts index bd76d8f6..34d9d0d7 100644 --- a/src/lib/foundation/security.ts +++ b/src/lib/foundation/security.ts @@ -5,9 +5,15 @@ import { env } from "@/env"; import type { IpAddress } from "./types"; const CSRF_BYTES = 32; -const CSRF_COOKIE = "__Host-csrf-token"; const CSRF_COOKIE_MAX_AGE = 86400; // 24h +/** `__Host-` requires Secure; use a plain name on non-HTTPS local dev. */ +function csrfCookieName(): string { + return process.env.NODE_ENV === "production" + ? "__Host-csrf-token" + : "csrf-token"; +} + const ALLOWED_HOSTS: ReadonlySet = new Set( [ env.APP_URL ? new URL(env.APP_URL).host : "", @@ -58,7 +64,7 @@ export function redirectSafe( redirect(safeRedirect(destination, fallback)); } -function csrfCookieOpts(): { +function csrfCookieOpts(value: string): { name: string; value: string; httpOnly: boolean; @@ -67,11 +73,12 @@ function csrfCookieOpts(): { path: string; maxAge: number; } { + const isProd = process.env.NODE_ENV === "production"; return { - name: CSRF_COOKIE, - value: crypto.randomBytes(CSRF_BYTES).toString("hex"), + name: csrfCookieName(), + value, httpOnly: true, - secure: true, + secure: isProd, sameSite: "lax" as const, path: "/", maxAge: CSRF_COOKIE_MAX_AGE, @@ -80,19 +87,21 @@ function csrfCookieOpts(): { export async function setCsrfCookie(): Promise { const c = await cookies(); - const existing = c.get(CSRF_COOKIE); + const name = csrfCookieName(); + const existing = c.get(name); if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value; - const opts = csrfCookieOpts(); + const value = crypto.randomBytes(CSRF_BYTES).toString("hex"); + const opts = csrfCookieOpts(value); c.set(opts.name, opts.value, opts); - return opts.value; + return value; } export async function validateCsrfToken(token: string): Promise { if (!token || token.length !== CSRF_BYTES * 2) return false; try { const c = await cookies(); - const stored = c.get(CSRF_COOKIE)?.value; + const stored = c.get(csrfCookieName())?.value; if (!stored || stored.length !== CSRF_BYTES * 2) return false; return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored)); } catch { diff --git a/src/lib/validators/catalog.test.ts b/src/lib/validators/catalog.test.ts new file mode 100644 index 00000000..c3bd752e --- /dev/null +++ b/src/lib/validators/catalog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { translateItemsSchema } from "@/lib/validators/catalog"; + +describe("translateItemsSchema", () => { + it("accepts a valid payload", () => { + const parsed = translateItemsSchema.safeParse({ + items: [{ id: 1, publicName: "Chair", description: "A chair" }], + }); + expect(parsed.success).toBe(true); + }); + + it("rejects more than 500 items", () => { + const items = Array.from({ length: 501 }, (_, i) => ({ + id: i + 1, + publicName: `Item ${i + 1}`, + })); + const parsed = translateItemsSchema.safeParse({ items }); + expect(parsed.success).toBe(false); + }); + + it("rejects oversized public names", () => { + const parsed = translateItemsSchema.safeParse({ + items: [{ id: 1, publicName: "x".repeat(256) }], + }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/src/lib/validators/catalog.ts b/src/lib/validators/catalog.ts new file mode 100644 index 00000000..4e04d684 --- /dev/null +++ b/src/lib/validators/catalog.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const translateItemsSchema = z.object({ + items: z + .array( + z.object({ + id: z.coerce.number().int().positive(), + publicName: z.string().min(1, "Name is required").max(255), + description: z.string().max(1000).optional().default(""), + }), + ) + .min(1, "At least one item required") + .max(500), +}); + +export type TranslateItemsInput = z.infer;