// app/api/submit/settings/route.ts import { NextResponse } from "next/server"; import { uploadFile, createSettingsItem, bytesFromMB, dxGET } from "@/lib/directus"; import { requireBearer } from "@/app/api/_lib/auth"; /** ───────────────────────────────────────────────────────────── * Accepts EITHER: * - application/json * (photo/screen can be existing file ids on the body) * - multipart/form-data with: * - payload = JSON string (same shape as JSON body) * - photo = File (required if no photo id present) * - screen = File (optional) * * Targets (collections): * - settings_fiber (+ laser_soft, repeat_all) * - settings_co2gan * - settings_co2gal * - settings_uv * ──────────────────────────────────────────────────────────── */ export const runtime = "nodejs"; 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; } type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; function num(v: any, fallback: number | null = null) { if (v === "" || v == null) return fallback; const n = Number(v); return Number.isFinite(n) ? n : fallback; } type ReadResult = { mode: "json" | "multipart"; body: any; 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 as any).formData(); const payloadRaw = String(form.get("payload") ?? "{}"); let body: any = {}; try { body = JSON.parse(payloadRaw); } catch { throw new Error("Invalid JSON in 'payload'"); } 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 }; } const body = await (req as any).json().catch(() => ({})); return { mode: "json", body, photoFile: null, screenFile: null }; } // 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() as string) || "0.0.0.0"; if (!rateLimitOk(ip)) { return NextResponse.json({ error: "Rate limited" }, { status: 429 }); } // NEW: enforce user auth const bearer = requireBearer(req); const { mode, body, photoFile, screenFile } = 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 basics const setting_title = String(body?.setting_title || "").trim(); if (!setting_title) return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 }); // Derive uploader from the authenticated user (ignore any spoofed value in body) const me = await dxGET("/users/me?fields=username,display_name,first_name,last_name,email", bearer); const uploader = me?.display_name || me?.username || [me?.first_name, me?.last_name].filter(Boolean).join(" ") || me?.email || "user"; // 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(); // 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; // Upload / accept existing file ids let photo_id: string | null = body?.photo ?? null; let screen_id: string | null = body?.screen ?? null; // 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, bearer, { folderNamePath: folderPathFor(target, "photo"), title: setting_title, }); photo_id = up.id; } if (!photo_id) { return NextResponse.json({ error: "Missing required: photo" }, { status: 400 }); } 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, bearer, { folderNamePath: folderPathFor(target, "screen"), title: `${setting_title} (screen)`, }); screen_id = up.id; } // Repeaters (pass through; UI already coerces numbers/bools) 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 : []; // timestamps for required fields const nowIso = new Date().toISOString(); const payload: Record = { setting_title, uploader, // ← server-derived from /users/me setting_notes, submission_date: nowIso, // required by your schema last_modified_date: nowIso, // keep in sync photo: photo_id, screen: screen_id ?? null, mat, mat_coat, mat_color, mat_opacity, mat_thickness, source, lens, focus, fill_settings: fills, line_settings: lines, raster_settings: rasters, status: "pending", submitted_via: "makearmy-app", submitted_at: nowIso, }; if (target === "settings_fiber") { payload.laser_soft = laser_soft ?? null; payload.repeat_all = repeat_all ?? null; } const { data } = await createSettingsItem(target, payload, bearer); return NextResponse.json({ ok: true, id: data.id }); } catch (err: any) { console.error("[submit/settings] error", err?.message || err); return NextResponse.json({ error: err?.message || "Unknown error" }, { status: err?.status ?? 500 }); } finally { const ms = Date.now() - started; if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); } }