// app/api/submit/settings/route.ts import { NextResponse } from "next/server"; 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 * * Collections (targets): * - 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); // 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 || 15); 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; } function bool(v: any) { return !!v; } function sanitizeRepeaterRow(row: Record, allowed: Set) { 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]; } } 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 }; }> { const ct = req.headers.get("content-type") || ""; if (ct.includes("application/json")) { const body = await req.json(); return { mode: "json", body, files: {} }; } 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) || undefined; const screen = (form.get("screen") as File) || undefined; 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"]); 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 { 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 }); } // 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 }); } // 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`); } } 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"); } // 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 || []; 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)); // 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 { data } = await createSettingsItem(target, payload); const newId = data?.submission_id ?? data?.id ?? null; return NextResponse.json({ ok: true, id: newId }); } 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`); } }