158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
// 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<string, { c: number; resetAt: number }>();
|
|
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: "<file-id>" }, ...]
|
|
const patch: Record<string, any> = {};
|
|
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 }
|
|
);
|
|
}
|
|
}
|
|
|