Files
forms/src/app/api/files/upload/route.ts
T

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);
}