diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts index 5ae0b4dc..622b0c26 100644 --- a/app/api/submit/settings/route.ts +++ b/app/api/submit/settings/route.ts @@ -4,17 +4,30 @@ import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus"; /** ───────────────────────────────────────────────────────────── * Accepts EITHER: - * - application/json (photo/screen as data URLs via preview_image{ name,data }) - * - multipart/form-data with: - * - field "payload" = JSON string (same shape as JSON body) - * - optional field "photo" = single file to upload - * - optional field "screen" = single file to upload + * - 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) + * } * - * Collections (targets): - * - settings_fiber (+ laser_soft, repeat_all) - * - settings_co2gan - * - settings_co2gal - * - settings_uv + * - multipart/form-data with: + * - field "payload" = JSON string (same shape as JSON body above) + * - optional file "photo" + * - optional file "screen" + * + * 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 * ──────────────────────────────────────────────────────────── */ export const runtime = "nodejs"; @@ -24,10 +37,10 @@ type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settin const MAX_MB = Number(process.env.FILE_MAX_MB || 25); const MAX_BYTES = bytesFromMB(MAX_MB); -// simple in-memory rate limiter +// light 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 || 15); +const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 30); function rateLimitOk(ip: string) { const now = Date.now(); const rec = BUCKET.get(ip); @@ -45,49 +58,37 @@ function sanitizeNumber(n: any, fallback: number | null = null) { const v = Number(n); return Number.isFinite(v) ? v : fallback; } +const sanitizeBool = (v: any) => !!v; -function bool(v: any) { - return !!v; -} - -function sanitizeRepeaterRow(row: Record, allowed: Set) { +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 (["power","speed","interval","pass","halftone_cell","halftone_angle","dot","angle","increment","frequency","pulse","step","size"].includes(k)) { - out[k] = sanitizeNumber(row[k]); - } else { - out[k] = row[k]; - } + 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; } -// Based on your note: folders are "/" -function folderPath(target: Target, kind: "photo" | "screen"): string { - const parent = - target === "settings_fiber" ? "le_fiber_settings" : - target === "settings_co2gan" ? "le_co2gan_settings" : - target === "settings_co2gal" ? "le_co2gal_settings" : - "le_uv_settings"; - const child = kind === "photo" - ? `${parent}_photos` - : `${parent}_screenshots`; - return `${parent}/${child}`; -} - async function readJsonOrMultipart(req: Request): Promise<{ mode: "json" | "multipart"; body: any; - files: { photo?: File; screen?: File }; + files: { photo?: File | null; screen?: File | null }; }> { const ct = req.headers.get("content-type") || ""; if (ct.includes("application/json")) { const body = await req.json(); - return { mode: "json", body, files: {} }; + // JSON mode: allow data URL blobs for images + return { mode: "json", body, files: { photo: null, screen: null } }; } if (ct.includes("multipart/form-data")) { - const form = await req.formData(); + const form = await (req as any).formData(); const payloadRaw = String(form.get("payload") || "{}"); let body: any = {}; try { @@ -95,157 +96,240 @@ async function readJsonOrMultipart(req: Request): Promise<{ } catch { throw new Error("Invalid JSON in 'payload' field"); } - const photo = (form.get("photo") as File) || undefined; - const screen = (form.get("screen") as File) || undefined; + const photo = (form.get("photo") as File) || null; + const screen = (form.get("screen") as File) || null; return { mode: "multipart", body, files: { photo, screen } }; } throw new Error("Unsupported content-type. Use JSON or multipart/form-data."); } -// Whitelists for repeaters (align with read-side UI) -const FILL_KEYS = new Set(["name","power","speed","interval","pass","type","flood","air","frequency","pulse","angle","auto","increment","cross"]); -const LINE_KEYS = new Set(["name","power","speed","perf","cut","skip","pass","air","frequency","pulse","wobble","step","size"]); -const RASTER_KEYS = new Set(["name","power","speed","type","dither","halftone_cell","halftone_angle","inversion","interval","dot","pass","air","frequency","pulse","cross"]); +// 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", +]); export async function POST(req: Request) { const started = Date.now(); try { - const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0"; - if (!rateLimitOk(ip)) { - return NextResponse.json({ error: "Rate limited" }, { status: 429 }); + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + req.headers.get("x-real-ip") || + "0.0.0.0"; + if (!rateLimitOk(String(ip))) { + return NextResponse.json({ error: "Rate limited" }, { status: 429 }); + } + + const { mode, body, files } = 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 }); + } + + // 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 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; + + // Focus can be -10..10; still required as number + const focus = sanitizeNumber(body?.focus, null); + + // Optional + const mat_thickness = sanitizeNumber(body?.mat_thickness, null); + const setting_notes = String(body?.setting_notes || "").trim(); + + // 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; + + // 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); + } + + // Handle photo/screen uploads + const folders = FOLDER_BY_TARGET[target]; + let photo_id: string | null = null; + let screen_id: string | null = null; + + async function doUploadBlobOrFile( + blobOrFile: Blob | File, + name: string, + folderPath: string + ): Promise { + if ((blobOrFile as File).size && (blobOrFile as File).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; + } - const { mode, body, files } = await readJsonOrMultipart(req); - - 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 }); + // multipart files + if (mode === "multipart") { + if (files.photo) { + photo_id = await doUploadBlobOrFile(files.photo, (files.photo as File).name || "photo", folders.photos); } - - // Required base fields - const setting_title = String(body?.setting_title || body?.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 }); - - // 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 source = body?.source ?? null; - const lens = body?.lens ?? null; - const focus = sanitizeNumber(body?.focus, null); - const mat_thickness = sanitizeNumber(body?.mat_thickness, null); - const setting_notes = String(body?.setting_notes || body?.notes || "").trim() || ""; - - // Fiber-only requireds per your spec - const laser_soft = target === "settings_fiber" ? (body?.laser_soft ?? null) : undefined; - const repeat_all = target === "settings_fiber" ? sanitizeNumber(body?.repeat_all, null) : undefined; - - // Validate requireds per your list (server-side guardrails) - const missing: string[] = []; - for (const [k, v] of Object.entries({ - source, lens, focus, - mat, mat_coat, mat_color, mat_opacity, - ...(target === "settings_fiber" ? { laser_soft, repeat_all } : {}), - })) { if (v === null || v === "" || v === undefined) missing.push(k); } - if (missing.length) { - return NextResponse.json({ error: `Missing required field(s): ${missing.join(", ")}` }, { status: 400 }); + if (files.screen) { + screen_id = await doUploadBlobOrFile(files.screen, (files.screen as File).name || "screen", folders.screens); } - - // Handle files (multipart) OR data URLs (json) - let photo_id: string | null = null; - let screen_id: string | null = null; - - async function guardSize(name: string, file: File) { - if (file.size > MAX_BYTES) { - throw new Error(`${name} exceeds ${MAX_MB} MB`); - } + } 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); } - - if (mode === "multipart") { - if (files.photo) { - await guardSize("photo", files.photo); - const up = await uploadFile(files.photo, (files.photo as File).name || "photo", { - folderNamePath: folderPath(target, "photo"), - title: setting_title, - }); - photo_id = up.id; - } - if (files.screen) { - await guardSize("screen", files.screen); - const up = await uploadFile(files.screen, (files.screen as File).name || "screen", { - folderNamePath: folderPath(target, "screen"), - title: setting_title, - }); - screen_id = up.id; - } - } else { - // JSON mode supports preview_image: { name, data } for both "photo" and "screen" - const upFromDataUrl = async (obj: any, kind: "photo" | "screen") => { - const dataUrl: string = obj?.data; - const name: string = obj?.name || kind; - if (!dataUrl) return null; - const base64 = dataUrl.split(",")[1] || ""; - const raw = Buffer.from(base64, "base64"); - if (raw.byteLength > MAX_BYTES) throw new Error(`${kind} exceeds ${MAX_MB} MB`); - const blob = new Blob([raw]); - const up = await uploadFile(blob as any, name, { - folderNamePath: folderPath(target, kind), - title: setting_title, - }); - return up.id; - }; - if (body?.preview_image) photo_id = await upFromDataUrl(body.preview_image, "photo"); - if (body?.preview_screen) screen_id = await upFromDataUrl(body.preview_screen, "screen"); + 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); } + } - // Repeaters (sanitize) - const fills = Array.isArray(body?.fill_settings) ? body.fill_settings : body?.fills || []; - const lines = Array.isArray(body?.line_settings) ? body.line_settings : body?.lines || []; - const rasters = Array.isArray(body?.raster_settings) ? body.raster_settings : body?.rasters || []; + // 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 } + ); + } - const fill_settings = (fills as any[]).map((r) => sanitizeRepeaterRow(r, FILL_KEYS)); - const line_settings = (lines as any[]).map((r) => sanitizeRepeaterRow(r, LINE_KEYS)); - const raster_settings = (rasters as any[]).map((r) => sanitizeRepeaterRow(r, RASTER_KEYS)); + // Repeaters (coerce) + const isGantry = target === "settings_co2gan"; - // Build payload (use your read-side keys) - const nowIso = new Date().toISOString(); - const payload: Record = { - setting_title, - uploader, - setting_notes, - photo: photo_id, // null if none - screen: screen_id, // null if none - mat, mat_coat, mat_color, mat_opacity, - mat_thickness, - source, lens, focus, - fill_settings, line_settings, raster_settings, - status: "pending", - submitted_via: "makearmy-app", - submitted_at: nowIso, - submission_date: nowIso, - last_modified_date: nowIso, // harmless even if you later make it optional - }; - if (target === "settings_fiber") { - payload.laser_soft = laser_soft ?? null; - payload.repeat_all = repeat_all ?? null; - } + 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 : []; - const { data } = await createSettingsItem(target, payload); - const newId = data?.submission_id ?? data?.id ?? null; + const fillNum = ["power","speed","interval","pass","frequency","pulse","angle","increment"]; + const fillBool = ["flood","air","auto","cross"]; - return NextResponse.json({ ok: true, id: newId }); + 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 }); } catch (err: any) { - console.error("[submit] error", err?.message || err); return NextResponse.json( { error: err?.message || "Unknown error" }, { status: 500 } ); } finally { - // cheap request duration log - const ms = Date.now() - (globalThis as any).__start_ts ?? 0; - if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); + const ms = Date.now() - started; + console.log(`[submit/settings] handled in ~${ms}ms`); } }