// app/api/submit/project/route.ts import { NextRequest, NextResponse } from "next/server"; import { uploadFile, createProjectRow, patchProject, bytesFromMB, } from "@/lib/directus"; // Optional: tweak via env const MAX_MB = Number(process.env.FILE_MAX_MB || 25); const MAX_BYTES = bytesFromMB(MAX_MB); // ultra-simple in-memory rate limiter (per server instance) 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; } export const runtime = "nodejs"; export async function POST(req: NextRequest) { 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 ct = req.headers.get("content-type") || ""; if (!ct.includes("multipart/form-data")) { return NextResponse.json( { error: "Expected multipart/form-data" }, { status: 400 } ); } const form = await req.formData(); // Required read-side field names from your repo: // title, body (markdown), uploader, category, tags[], p_image (file), p_files (M2M to files) const title = String(form.get("title") || "").trim(); const uploader = String(form.get("uploader") || "").trim(); const category = String(form.get("category") || "").trim(); const body = String(form.get("body") || form.get("description") || "").trim(); if (!title || !uploader || !body) { return NextResponse.json( { error: "Missing required fields: title, uploader, body" }, { status: 400 } ); } // tags: allow comma-separated string or JSON array let tags: string[] = []; const rawTags = form.get("tags"); if (typeof rawTags === "string" && rawTags.trim()) { try { // Accept JSON array const maybeArray = JSON.parse(rawTags); if (Array.isArray(maybeArray)) { tags = maybeArray.map((t) => String(t).trim()).filter(Boolean); } else { // Fallback: comma list tags = rawTags .split(",") .map((t) => t.trim()) .filter(Boolean); } } catch { // Comma-separated tags = rawTags .split(",") .map((t) => t.trim()) .filter(Boolean); } } // Optional license (not shown on read pages, but harmless to store) const license = (form.get("license") && String(form.get("license")).trim()) || undefined; // Upload hero image (single) const hero = form.get("image") as File | null; // input name="image" let p_image_id: string | undefined; if (hero && typeof hero === "object" && "size" in hero) { if (hero.size > MAX_BYTES) { return NextResponse.json( { error: `Hero image exceeds ${MAX_MB} MB` }, { status: 400 } ); } const up = await uploadFile(hero, (hero as File).name || "project-image"); p_image_id = up.id; } // Upload attachments (multiple) const fileBlobs = form.getAll("files").filter(Boolean) as File[]; const attachIds: string[] = []; for (const f of fileBlobs.slice(0, 20)) { if (f.size > MAX_BYTES) { return NextResponse.json( { error: `One of the files exceeds ${MAX_MB} MB` }, { status: 400 } ); } const up = await uploadFile(f, (f as File).name || "attachment"); attachIds.push(up.id); } // 1) Create the project row const { data: created } = await createProjectRow({ title, body, // you render `project.body` in detail page uploader, // exact key used by your list/detail category, tags, // stored as array ...(license ? { license } : {}), status: "pending", submitted_via: "makearmy-app", submitted_at: new Date().toISOString(), }); // 2) Patch hero image + M2M attachments in one go // For M2M (p_files), Directus accepts nested objects to create junction rows // e.g. [{ directus_files_id: "" }, ...] const patch: Record = {}; if (p_image_id) patch.p_image = p_image_id; if (attachIds.length) { patch.p_files = attachIds.map((id) => ({ directus_files_id: id })); } if (Object.keys(patch).length) { await patchProject(created.id, patch); } return NextResponse.json({ ok: true, id: created.id }); } catch (err: any) { return NextResponse.json( { error: err?.message || "Unknown error" }, { status: 500 } ); } }