From 940dabe13d2ca2b60e8742fc1864e35651d1b767 Mon Sep 17 00:00:00 2001 From: voyagerxyx Date: Mon, 22 Sep 2025 15:23:11 -0400 Subject: [PATCH] settings route fix for image validation --- app/api/submit/settings/route.ts | 419 ++++++++++++------------------- 1 file changed, 154 insertions(+), 265 deletions(-) diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts index 6d340bfe..049fe518 100644 --- a/app/api/submit/settings/route.ts +++ b/app/api/submit/settings/route.ts @@ -5,42 +5,28 @@ import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus"; /** ───────────────────────────────────────────────────────────── * Accepts EITHER: * - application/json - * { - * target: "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv", - * setting_title, uploader, setting_notes?, - * mat, mat_coat, mat_color, mat_opacity, mat_thickness?, - * source, lens, focus, - * laser_soft?, repeat_all?, - * fill_settings[], line_settings[], raster_settings[], - * preview_image_photo?: { name, data }, // dataURL base64 (optional) - * preview_image_screen?: { name, data } // dataURL base64 (optional) - * } - * + * (photo/screen can be existing file ids on the body) * - multipart/form-data with: - * - field "payload" = JSON string (same shape as JSON body above) - * - optional file "photo" - * - optional file "screen" + * - payload = JSON string (same shape as JSON body) + * - photo = File (required) + * - screen = File (optional) * - * On create, we set: - * - submission_date = now - * - last_modified_date = now - * - * Required (server enforced): - * setting_title, uploader, photo, source, lens, focus, mat, mat_coat, mat_color, - * mat_opacity, (fiber only) laser_soft, repeat_all + * Targets (collections): + * - settings_fiber (+ laser_soft, repeat_all) + * - settings_co2gan + * - settings_co2gal + * - settings_uv * ──────────────────────────────────────────────────────────── */ export const runtime = "nodejs"; -type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; - const MAX_MB = Number(process.env.FILE_MAX_MB || 25); const MAX_BYTES = bytesFromMB(MAX_MB); -// light in-memory rate limiter +// simple in-memory rate limiter const BUCKET = new Map(); const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW || 60) * 1000; -const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 30); +const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 15); function rateLimitOk(ip: string) { const now = Date.now(); const rec = BUCKET.get(ip); @@ -53,284 +39,187 @@ function rateLimitOk(ip: string) { return true; } -function sanitizeNumber(n: any, fallback: number | null = null) { - if (n === null || n === undefined || n === "") return fallback; - const v = Number(n); - return Number.isFinite(v) ? v : fallback; -} -const sanitizeBool = (v: any) => !!v; +type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; -function sanitizeRepeaterRow( - row: Record, - allowed: Set, - numKeys: string[] = [], - boolKeys: string[] = [] -): Record { - const out: Record = {}; - for (const k of Object.keys(row || {})) { - if (!allowed.has(k)) continue; - if (numKeys.includes(k)) out[k] = sanitizeNumber(row[k]); - else if (boolKeys.includes(k)) out[k] = sanitizeBool(row[k]); - else out[k] = row[k]; - } - return out; +function num(v: any, fallback: number | null = null) { + if (v === "" || v == null) return fallback; + const n = Number(v); + return Number.isFinite(n) ? n : fallback; } -async function readJsonOrMultipart(req: Request): Promise<{ +function bool(v: any) { + return !!v; +} + +type ReadResult = { mode: "json" | "multipart"; body: any; - files: { photo?: File | null; screen?: File | null }; -}> { - const ct = req.headers.get("content-type") || ""; - if (ct.includes("application/json")) { - const body = await req.json(); - // JSON mode: allow data URLs for images - return { mode: "json", body, files: { photo: null, screen: null } }; - } + photoFile: File | null; + screenFile: File | null; +}; + +async function readJsonOrMultipart(req: Request): Promise { + const ct = (req.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("multipart/form-data")) { - const form = await req.formData(); - const payloadRaw = String(form.get("payload") || "{}"); + const form = await (req as any).formData(); + + // payload JSON (required for the structured fields) + const payloadRaw = String(form.get("payload") ?? "{}"); let body: any = {}; try { body = JSON.parse(payloadRaw); } catch { - throw new Error("Invalid JSON in 'payload' field"); + throw new Error("Invalid JSON in 'payload'"); } - const photo = (form.get("photo") as File) || null; - const screen = (form.get("screen") as File) || null; - return { mode: "multipart", body, files: { photo, screen } }; + + // Files (File or null) + const p = form.get("photo"); + const s = form.get("screen"); + const photoFile = p instanceof File && p.size > 0 ? (p as File) : null; + const screenFile = s instanceof File && s.size > 0 ? (s as File) : null; + + return { mode: "multipart", body, photoFile, screenFile }; } - throw new Error("Unsupported content-type. Use JSON or multipart/form-data."); + + // JSON body + const body = await (req as any).json().catch(() => ({})); + return { mode: "json", body, photoFile: null, screenFile: null }; } -// Folders by target (parent/child), resolved dynamically in lib/directus.ts -const FOLDER_BY_TARGET: Record = { - settings_fiber: { photos: "le_fiber_settings/le_fiber_settings_photos", screens: "le_fiber_settings/le_fiber_settings_screenshots" }, - settings_co2gal: { photos: "le_co2gal_settings/le_co2gal_settings_photos", screens: "le_co2gal_settings/le_co2gal_settings_screenshots" }, - settings_co2gan: { photos: "le_co2gan_settings/le_co2gan_settings_photos", screens: "le_co2gan_settings/le_co2gan_settings_screenshots" }, - settings_uv: { photos: "le_uv_settings/le_uv_settings_photos", screens: "le_uv_settings/le_uv_settings_screenshots" }, -}; - -// Allowed keys per repeater (mirrors your read-side UI) -const FILL_KEYS = new Set([ - "name","power","speed","interval","pass","type","flood","air", - // non-gantry extras - "frequency","pulse","angle","auto","increment","cross", -]); -const LINE_KEYS = new Set([ - "name","power","speed","perf","cut","skip","pass","air", - // non-gantry extras - "frequency","pulse","wobble","step","size", -]); -const RASTER_KEYS = new Set([ - "name","power","speed","type","dither","halftone_cell","halftone_angle", - "inversion","interval","dot","pass","air", - // non-gantry extras - "frequency","pulse","cross", -]); +// map to your Directus folder paths +function folderPathFor(target: Target, kind: "photo" | "screen") { + switch (target) { + case "settings_fiber": + return kind === "photo" + ? "le_fiber_settings/le_fiber_settings_photos" + : "le_fiber_settings/le_fiber_settings_screenshots"; + case "settings_co2gan": + return kind === "photo" + ? "le_co2gan_settings/le_co2gan_settings_photos" + : "le_co2gan_settings/le_co2gan_settings_screenshots"; + case "settings_co2gal": + return kind === "photo" + ? "le_co2gal_settings/le_co2gal_settings_photos" + : "le_co2gal_settings/le_co2gal_settings_screenshots"; + case "settings_uv": + return kind === "photo" + ? "le_uv_settings/le_uv_settings_photos" + : "le_uv_settings/le_uv_settings_screenshots"; + } +} export async function POST(req: Request) { const started = Date.now(); try { const ip = - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - req.headers.get("x-real-ip") || + (req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() as string) || "0.0.0.0"; - if (!rateLimitOk(String(ip))) { - return NextResponse.json({ error: "Rate limited" }, { status: 429 }); - } + if (!rateLimitOk(ip)) { + return NextResponse.json({ error: "Rate limited" }, { status: 429 }); + } - const { mode, body, files } = await readJsonOrMultipart(req); + const { mode, body, photoFile, screenFile } = await readJsonOrMultipart(req); - // Validate target - const target: Target = body?.target; - if (!["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"].includes(String(target))) { - return NextResponse.json({ error: "Invalid target" }, { status: 400 }); - } + const target: Target = body?.target; + if (!target || !["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"].includes(target)) { + return NextResponse.json({ error: "Invalid target" }, { status: 400 }); + } - // Required base fields - const setting_title = String(body?.setting_title || "").trim(); - const uploader = String(body?.uploader || "").trim(); - if (!setting_title) return NextResponse.json({ error: "Missing field: setting_title" }, { status: 400 }); - if (!uploader) return NextResponse.json({ error: "Missing field: uploader" }, { status: 400 }); + // Required basics + const setting_title = String(body?.setting_title || "").trim(); + const uploader = String(body?.uploader || "").trim(); + if (!setting_title) return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 }); + if (!uploader) return NextResponse.json({ error: "Missing required: uploader" }, { status: 400 }); - // Required relations/inputs - const mat = body?.mat ?? null; - const mat_coat = body?.mat_coat ?? null; - const mat_color = body?.mat_color ?? null; - const mat_opacity = body?.mat_opacity ?? null; - const source = body?.source ?? null; - const lens = body?.lens ?? null; + // Relations & numerics + const mat = body?.mat ?? null; + const mat_coat = body?.mat_coat ?? null; + const mat_color = body?.mat_color ?? null; + const mat_opacity = body?.mat_opacity ?? null; + const mat_thickness = num(body?.mat_thickness, null); + const source = body?.source ?? null; + const lens = body?.lens ?? null; + const focus = num(body?.focus, null); + const setting_notes = String(body?.setting_notes || "").trim(); - // Focus can be -10..10; still required as number - const focus = sanitizeNumber(body?.focus, null); + // Fiber-only + const laser_soft = target === "settings_fiber" ? body?.laser_soft ?? null : undefined; + const repeat_all = target === "settings_fiber" ? num(body?.repeat_all, null) : undefined; - // Optional - const mat_thickness = sanitizeNumber(body?.mat_thickness, null); - const setting_notes = String(body?.setting_notes || "").trim(); + // Upload / accept existing file ids + let photo_id: string | null = body?.photo ?? null; + let screen_id: string | null = body?.screen ?? null; - // Fiber-only required - const isFiber = target === "settings_fiber"; - const laser_soft = isFiber ? (body?.laser_soft ?? null) : undefined; - const repeat_all = isFiber ? sanitizeNumber(body?.repeat_all, null) : undefined; + // photo is required: if no id on body, require file + if (!photo_id && photoFile) { + if (photoFile.size > MAX_BYTES) + return NextResponse.json({ error: `Photo exceeds ${MAX_MB} MB` }, { status: 400 }); + const up = await uploadFile(photoFile, (photoFile as File).name, { + folderNamePath: folderPathFor(target, "photo"), + title: setting_title, + }); + photo_id = up.id; + } + if (!photo_id) { + return NextResponse.json({ error: "Missing required: photo" }, { status: 400 }); + } - // Validate requireds - const missing: string[] = []; - for (const [key, val] of Object.entries({ - photo: null, // we validate after possible file/dataURL handling - source, - lens, - focus, - mat, - mat_coat, - mat_color, - mat_opacity, - ...(isFiber ? { laser_soft, repeat_all } : {}), - })) { - if (val === null || val === undefined || val === "") missing.push(key); - } + if (!screen_id && screenFile) { + if (screenFile.size > MAX_BYTES) + return NextResponse.json({ error: `Screenshot exceeds ${MAX_MB} MB` }, { status: 400 }); + const up = await uploadFile(screenFile, (screenFile as File).name, { + folderNamePath: folderPathFor(target, "screen"), + title: `${setting_title} (screen)`, + }); + screen_id = up.id; + } - // Handle photo/screen uploads - const folders = FOLDER_BY_TARGET[target]; - let photo_id: string | null = null; - let screen_id: string | null = null; + // Repeaters + const fills = Array.isArray(body?.fill_settings) ? body.fill_settings : []; + const lines = Array.isArray(body?.line_settings) ? body.line_settings : []; + const rasters = Array.isArray(body?.raster_settings) ? body.raster_settings : []; - async function doUploadBlobOrFile( - blobOrFile: Blob | File, - name: string, - folderPath: string - ): Promise { - const size = (blobOrFile as File).size ?? (blobOrFile as any)?.size ?? 0; - if (size && size > MAX_BYTES) { - throw new Error(`File "${name}" exceeds ${MAX_MB} MB`); - } - const up = await uploadFile(blobOrFile, name, { - folderNamePath: folderPath, - title: name, - }); - return up.id; - } + // (numbers coerced in your UI; keep as-is here) + const payload: Record = { + setting_title, + uploader, + setting_notes, + submission_date: new Date().toISOString(), - // multipart files - if (mode === "multipart") { - if (files.photo) { - photo_id = await doUploadBlobOrFile(files.photo, (files.photo as File).name || "photo", folders.photos); - } - if (files.screen) { - screen_id = await doUploadBlobOrFile(files.screen, (files.screen as File).name || "screen", folders.screens); - } - } else { - // JSON: accept data URLs in preview_image_* fields - const p = body?.preview_image_photo; - if (p?.data && p?.name) { - const base64 = String(p.data).split(",")[1] || ""; - const raw = Buffer.from(base64, "base64"); - if (raw.byteLength > MAX_BYTES) throw new Error(`Photo exceeds ${MAX_MB} MB`); - photo_id = await doUploadBlobOrFile(new Blob([raw]), p.name, folders.photos); - } - const s = body?.preview_image_screen; - if (s?.data && s?.name) { - const base64 = String(s.data).split(",")[1] || ""; - const raw = Buffer.from(base64, "base64"); - if (raw.byteLength > MAX_BYTES) throw new Error(`Screen exceeds ${MAX_MB} MB`); - screen_id = await doUploadBlobOrFile(new Blob([raw]), s.name, folders.screens); - } - } + photo: photo_id, + screen: screen_id ?? null, - // Now we can enforce required photo - if (!photo_id) missing.push("photo"); - if (missing.length) { - return NextResponse.json( - { error: `Missing required: ${missing.join(", ")}` }, - { status: 400 } - ); - } + mat, + mat_coat, + mat_color, + mat_opacity, + mat_thickness, + source, + lens, + focus, - // Repeaters (coerce) - const isGantry = target === "settings_co2gan"; + fill_settings: fills, + line_settings: lines, + raster_settings: rasters, - const fillsIn = Array.isArray(body?.fill_settings) ? body.fill_settings : []; - const linesIn = Array.isArray(body?.line_settings) ? body.line_settings : []; - const rastersIn = Array.isArray(body?.raster_settings) ? body.raster_settings : []; + status: "pending", + submitted_via: "makearmy-app", + submitted_at: new Date().toISOString(), + }; - const fillNum = ["power","speed","interval","pass","frequency","pulse","angle","increment"]; - const fillBool = ["flood","air","auto","cross"]; + if (target === "settings_fiber") { + payload.laser_soft = laser_soft ?? null; + payload.repeat_all = repeat_all ?? null; + } - const lineNum = ["power","speed","pass","frequency","pulse","step","size"]; - const lineBool = ["perf","air","wobble"]; - - const rasterNum = ["power","speed","halftone_cell","halftone_angle","interval","dot","pass","frequency","pulse"]; - const rasterBool = ["inversion","air","cross"]; - - const fill_settings = fillsIn.map((r: any) => - sanitizeRepeaterRow( - r, - FILL_KEYS, - isGantry ? fillNum.filter((k) => !["frequency","pulse","angle","increment"].includes(k)) : fillNum, - isGantry ? fillBool.filter((k) => !["auto","cross"].includes(k)) : fillBool - ) - ); - const line_settings = linesIn.map((r: any) => - sanitizeRepeaterRow( - r, - LINE_KEYS, - isGantry ? lineNum.filter((k) => !["frequency","pulse","step","size"].includes(k)) : lineNum, - isGantry ? lineBool.filter((k) => !["wobble"].includes(k)) : lineBool - ) - ); - const raster_settings = rastersIn.map((r: any) => - sanitizeRepeaterRow( - r, - RASTER_KEYS, - isGantry ? rasterNum.filter((k) => !["frequency","pulse"].includes(k)) : rasterNum, - isGantry ? rasterBool.filter((k) => !["cross"].includes(k)) : rasterBool - ) - ); - - // Build payload - const nowIso = new Date().toISOString(); - const payload: Record = { - setting_title, - uploader, - setting_notes, - photo: photo_id, - screen: screen_id ?? null, - mat, - mat_coat, - mat_color, - mat_opacity, - mat_thickness, - source, - lens, - focus, - fill_settings, - line_settings, - raster_settings, - status: "pending", - submitted_via: "makearmy-app", - submission_date: nowIso, - last_modified_date: nowIso, - }; - if (isFiber) { - payload.laser_soft = laser_soft ?? null; - payload.repeat_all = repeat_all ?? null; - } - - const { data } = await createSettingsItem(target, payload); - // Directus returns the primary key field; expose both for safety - const returnedId = - (data as any)?.id ?? - (data as any)?.submission_id ?? - (typeof data === "object" ? Object.values(data)[0] : undefined); - - return NextResponse.json({ ok: true, id: returnedId, data }); + const { data } = await createSettingsItem(target, payload); + return NextResponse.json({ ok: true, id: data.id }); } catch (err: any) { - return NextResponse.json( - { error: err?.message || "Unknown error" }, - { status: 500 } - ); + console.error("[submit/settings] error", err?.message || err); + return NextResponse.json({ error: err?.message || "Unknown error" }, { status: 500 }); } finally { const ms = Date.now() - started; - console.log(`[submit/settings] handled in ~${ms}ms`); + if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); } }