// Multipart upload endpoint. Used by the file field in FormRuntime. // Accepts: // form-data field "file" — the file blob // form-data field "formId" — the form this file is being uploaded for // // Returns: { id, name, size, contentType } — the client stores this as the // field value. The file is associated with a response when the response is // submitted (UploadedFile.responseId is set then). import { NextResponse, type NextRequest } from "next/server"; import { nanoid } from "nanoid"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { parseFields, parseSettings } from "@/lib/forms"; import { driver, currentDriver } from "@/lib/storage"; import { consume, clientIp } from "@/lib/ratelimit"; const MAX_DEFAULT_MB = 25; export const runtime = "nodejs"; export async function POST(req: NextRequest) { // Rate-limit uploads per IP. const ip = clientIp(req.headers); const rl = await consume(`upload:${ip}`, 20, 60_000); if (!rl.ok) { return NextResponse.json( { error: "Too many uploads. Slow down." }, { status: 429, headers: { "retry-after": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }, ); } const fd = await req.formData().catch(() => null); if (!fd) return NextResponse.json({ error: "Multipart body required" }, { status: 400 }); const file = fd.get("file"); const formId = String(fd.get("formId") ?? ""); if (!(file instanceof File) || !formId) { return NextResponse.json({ error: "Missing file or formId" }, { status: 400 }); } const form = await prisma.form.findUnique({ where: { id: formId } }); if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 }); const settings = parseSettings(form.settings); const fields = parseFields(form.fields); const session = await auth(); const isOwnerOrAdmin = !!session?.user && (session.user.id === form.ownerId || session.user.role === "admin"); // Public form fillers can only upload to published forms; owners/admins can // upload to draft forms too (branding images uploaded during editing). if (!form.published && !isOwnerOrAdmin) return NextResponse.json({ error: "Not found" }, { status: 404 }); if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Enforce per-form max size (cap by the most permissive file field, or default). const maxMB = Math.max(MAX_DEFAULT_MB, ...fields.filter((f) => f.type === "file").map((f) => f.maxSizeMB ?? MAX_DEFAULT_MB)); if (file.size > maxMB * 1024 * 1024) { return NextResponse.json({ error: `File too large (max ${maxMB} MB)` }, { status: 413 }); } const buf = Buffer.from(await file.arrayBuffer()); const id = nanoid(16); const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload"; const key = `${form.id}/${id}-${safeName}`; const drv = currentDriver(); await driver(drv).put(key, buf, { size: file.size, contentType: file.type || "application/octet-stream" }); const row = await prisma.uploadedFile.create({ data: { formId: form.id, uploaderId: session?.user?.id ?? null, storageKey: key, driver: drv, name: file.name || safeName, size: file.size, contentType: file.type || "application/octet-stream", }, select: { id: true, name: true, size: true, contentType: true }, }); return NextResponse.json(row); }