/** * POST /api/upload * * Multipart endpoint that accepts a single file plus the form's auth pair * (cid + cs) and the target Civi field reference. Verifies, validates, * stores in CiviCRM via Attachment.create, returns { id, file_name }. * * The form's file renderer calls this on file-pick (not at submit time) * so submit can stay a simple JSON POST. The returned { id, file_name } * is what gets put into RHF state and ultimately submitted as the field * value — the same shape /api/data uses for prefill, so the renderer's * FilePriorIndicator works unchanged for fresh uploads too. * * Request: multipart/form-data with parts: * - file the binary * - cid contact id (form auth) * - cs checksum (form auth) * - fieldRef the Civi field reference, e.g. "Stage_1.Vision_Upload" * or "Food_Co_op_Organizing.Certificate_of_Incorporation" * * Response: { id: number, file_name: string } OR { error: string } * * STUB MODE: if CiviCRM env vars are unset, returns a fake id + the * uploaded filename so frontend dev works without a live CRM. */ import { NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; import { allFields } from "@/config/form"; import { rateLimit, clientIp } from "@/lib/rate-limit"; // 5 MB hard cap. Sits under AWS Amplify Lambda's 6 MB sync invocation // payload limit with headroom for multipart envelope overhead. const MAX_BYTES = 5 * 1024 * 1024; // Per the v1 spec: PDF, DOC/DOCX, XLS/XLSX, common image types. const ALLOWED_MIME = new Set([ "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "image/jpeg", "image/png", "image/gif", "image/webp", ]); // Magic-byte sniff for the most common forgeries. Don't trust client- // reported MIME alone — a renamed .exe shouldn't slip past us on the // strength of a "Content-Type: application/pdf" header. function sniffMime(bytes: Uint8Array): string | null { if (bytes.length < 8) return null; const b = bytes; // %PDF if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) { return "application/pdf"; } // PK (ZIP container — docx/xlsx) if (b[0] === 0x50 && b[1] === 0x4b && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07)) { return "application/zip"; // accept-with-allowlist handles docx/xlsx } // OLE compound (legacy doc/xls) if ( b[0] === 0xd0 && b[1] === 0xcf && b[2] === 0x11 && b[3] === 0xe0 && b[4] === 0xa1 && b[5] === 0xb1 && b[6] === 0x1a && b[7] === 0xe1 ) { return "application/x-ole-storage"; } // JPEG if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; // PNG if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; // GIF if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return "image/gif"; // WEBP — "RIFF????WEBP" if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) { return "image/webp"; } return null; } // Office formats (docx/xlsx) sniff as application/zip via PK header but // are allowed at the client-reported MIME level. The mime check below // keeps both axes honest: client mime must be in ALLOWED_MIME, AND the // magic bytes must be plausible for that mime. function mimePlausible(clientMime: string, sniffed: string | null): boolean { if (!sniffed) return false; if (sniffed === clientMime) return true; // docx/xlsx are zips under the hood — accept the alias. const zipAliased = new Set([ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ]); if (sniffed === "application/zip" && zipAliased.has(clientMime)) return true; // Legacy doc/xls share the OLE container. const oleAliased = new Set(["application/msword", "application/vnd.ms-excel"]); if (sniffed === "application/x-ole-storage" && oleAliased.has(clientMime)) return true; return false; } // Path-traversal scrub + length cap. Civi will store its own normalized // name internally; this is purely defensive. function sanitizeFilename(name: string): string { const base = name.split(/[\\/]/).pop() ?? name; // Drop control chars and anything that's not letters/digits/dot/dash/underscore/space. const cleaned = base.replace(/[^\w.\- ]/g, "_").trim(); return cleaned.slice(0, 200) || "upload"; } function isStubMode(): boolean { return !( process.env.CIVI_BASE_URL && process.env.CIVI_API_KEY && process.env.CIVI_SITE_KEY ); } const FILE_FIELD_REFS = new Set( allFields .filter((f) => f.type === "file" && (f.civiField || f.civiContactField)) .map((f) => f.civiField ?? f.civiContactField!), ); export async function POST(req: Request) { // Generous-but-not-unlimited: 5 uploads per minute per IP. Captures // accidental retry loops without throttling legitimate use (a form // with 4 file fields fills in well under a minute). const ip = clientIp(req); const rl = rateLimit(`upload:${ip}`, { capacity: 5, windowMs: 60_000 }); if (!rl.allowed) { return NextResponse.json( { error: "Too many uploads. Please wait a moment and try again." }, { status: 429, headers: { "Retry-After": String(Math.ceil(rl.resetMs / 1000)) } }, ); } // Parse multipart. Next 16 supports Request.formData() natively. let form: FormData; try { form = await req.formData(); } catch { return NextResponse.json({ error: "Expected multipart/form-data." }, { status: 400 }); } const cid = (form.get("cid") as string | null) ?? ""; const cs = (form.get("cs") as string | null) ?? ""; const fieldRef = (form.get("fieldRef") as string | null) ?? ""; const fileEntry = form.get("file"); if (!cid || !cs) { return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 }); } if (!fieldRef || !FILE_FIELD_REFS.has(fieldRef)) { // Refusing unknown fieldRefs blocks the obvious abuse vector: a // client posting an upload pointed at an arbitrary Civi field. return NextResponse.json({ error: "Unknown or non-file field reference." }, { status: 400 }); } if (!(fileEntry instanceof File)) { return NextResponse.json({ error: "Missing file part." }, { status: 400 }); } if (fileEntry.size === 0) { return NextResponse.json({ error: "Empty file." }, { status: 400 }); } if (fileEntry.size > MAX_BYTES) { return NextResponse.json( { error: `File too large. Maximum is ${MAX_BYTES / (1024 * 1024)} MB.` }, { status: 413 }, ); } const clientMime = fileEntry.type || "application/octet-stream"; if (!ALLOWED_MIME.has(clientMime)) { return NextResponse.json( { error: `File type ${clientMime} is not allowed.` }, { status: 415 }, ); } const bytes = new Uint8Array(await fileEntry.arrayBuffer()); const sniffed = sniffMime(bytes); if (!mimePlausible(clientMime, sniffed)) { return NextResponse.json( { error: "File contents don't match the declared type." }, { status: 415 }, ); } const safeName = sanitizeFilename(fileEntry.name); if (isStubMode()) { console.warn( "[upload:STUB] would Attachment.create", JSON.stringify({ name: safeName, mime: clientMime, bytes: fileEntry.size, fieldRef }), ); return NextResponse.json({ id: -1, file_name: safeName, stub: true }); } // Auth: verify the form's checksum before doing anything Civi-side. const ok = await verifyChecksum(cid, cs); if (!ok) { return NextResponse.json( { error: "Link is invalid or has expired." }, { status: 401 }, ); } // APIv4 File.create with inline base64 content. Spike (June 2026) on // this Civi instance confirmed: // - APIv4 Attachment is NOT exposed // - APIv4 File + EntityFile ARE exposed // - File.create with file_name + mime_type + content (base64) returns // a usable file id // - Custom file fields store the file id directly in the custom // column, so EntityFile linkage is not needed for our use case // - Round-trip via Contact.update + Contact.get .file_name join works // // We do not create EntityFile rows here. Civi's custom-field renderer // joins through the custom column to civicrm_file directly, and the // form-side prefill/read code in /api/data uses the same join. // // The returned id is what the frontend stores in RHF state and // ultimately sends as the field value on /api/submit. /api/submit then // writes that id to the activity custom field (for stage-N file fields) // or to the org contact custom field (for Food_Co_op_Organizing.*). // // Orphan files: if the user uploads and then abandons the form, the // File row persists with no entity referencing it. Cleanup is handled // by a CiviCRM scheduled job (configured separately by the Civi admin) // that deletes File rows with no inbound references older than ~24h. let fileId: number; try { const res = await civi<{ id: number }>("File", "create", { values: { file_name: safeName, mime_type: clientMime, content: Buffer.from(bytes).toString("base64"), }, }); const id = res.values?.[0]?.id; if (!id) throw new Error("File.create returned no id"); fileId = Number(id); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error("[upload] File.create failed:", msg); return NextResponse.json( { error: "Could not save the upload. Please try again." }, { status: 502 }, ); } return NextResponse.json({ id: fileId, file_name: safeName }); }