// app/api/submit/settings/route.ts import { NextResponse } from "next/server"; 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) * } * * - 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"; 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 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); function rateLimitOk(ip: string) { const now = Date.now(); const rec = BUCKET.get(ip); if (!rec || now > rec.resetAt) { BUCKET.set(ip, { c: 1, resetAt: now + WINDOW_MS }); return true; } if (rec.c >= MAX_REQ) return false; rec.c += 1; 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; 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; } async function readJsonOrMultipart(req: Request): Promise<{ 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 } }; } if (ct.includes("multipart/form-data")) { const form = await req.formData(); const payloadRaw = String(form.get("payload") || "{}"); let body: any = {}; try { body = JSON.parse(payloadRaw); } catch { throw new Error("Invalid JSON in 'payload' field"); } 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."); } // 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() || 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 { 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; } // 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); } } // 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 } ); } // Repeaters (coerce) const isGantry = target === "settings_co2gan"; 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 fillNum = ["power","speed","interval","pass","frequency","pulse","angle","increment"]; const fillBool = ["flood","air","auto","cross"]; 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) { return NextResponse.json( { error: err?.message || "Unknown error" }, { status: 500 } ); } finally { const ms = Date.now() - started; console.log(`[submit/settings] handled in ~${ms}ms`); } }