// 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 }; } /** Env-based folder IDs (no folder browsing) */ function folderIdFor( target: Target, kind: "photo" | "screen" | "notes" ): string | undefined { const E = process.env; const map = { settings_co2gal: { photo: E.DX_FOLDER_GALVO_PHOTOS, screen: E.DX_FOLDER_GALVO_SCREENS, notes: E.DX_FOLDER_GALVO_NOTES, }, settings_co2gan: { photo: E.DX_FOLDER_GANTRY_PHOTOS, screen: E.DX_FOLDER_GANTRY_SCREENS, notes: E.DX_FOLDER_GANTRY_NOTES, }, settings_fiber: { photo: E.DX_FOLDER_FIBER_PHOTOS, screen: E.DX_FOLDER_FIBER_SCREENS, notes: E.DX_FOLDER_FIBER_NOTES, }, settings_uv: { photo: E.DX_FOLDER_UV_PHOTOS, screen: E.DX_FOLDER_UV_SCREENS, notes: E.DX_FOLDER_UV_NOTES, }, } as const; // @ts-expect-error narrow map index return map[target]?.[kind]; } 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 }); } // Enforce user auth (everything uses the user's token) 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 } ); } // Current user (handle both {data:{...}} and {...} shapes) const meRes = await dxGET("/users/me?fields=id,username", bearer); const meId = meRes?.data?.id ?? meRes?.id ?? null; const meUsername = meRes?.data?.username ?? meRes?.username ?? null; // Attribution const uploader = meUsername || "user"; // string field mirrors owner.username // 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(); // Shared string fields const laser_soft = body?.laser_soft ?? null; // exact key: 'laser_soft' const repeat_all = num(body?.repeat_all, null); // ← universally applicable now // 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, { folderId: folderIdFor(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, { folderId: folderIdFor(target, "screen"), title: `${setting_title} (screen)`, }); screen_id = up.id; } // Repeaters (pass-through; UI 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 const nowIso = new Date().toISOString(); const payload: Record = { setting_title, // Ownership & attribution owner: meId || null, // M2O to directus_users uploader, // string mirror of username setting_notes, submission_date: nowIso, last_modified_date: nowIso, photo: photo_id, screen: screen_id ?? null, // exact keys laser_soft, repeat_all, // ← always included 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, }; 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`); } }