84 lines
3.4 KiB
TypeScript
84 lines
3.4 KiB
TypeScript
// 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);
|
|
}
|