Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).
Spike findings (see scripts/spike-file-upload.mjs):
- APIv4 Attachment is NOT exposed on this Civi
- APIv4 File + EntityFile ARE exposed; File.create accepts inline
base64 `content` and returns a usable file id
- Custom file fields store the file id directly in the custom column,
so EntityFile linkage is unnecessary for this use case
- Round-trip via Contact.update + Contact.get .file_name join verified
on a real org contact
Pipeline:
Renderer (FileField) picks up onChange →
POST /api/upload (multipart) with file + cid + cs + fieldRef →
verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap →
civi.File.create({ file_name, mime_type, content: base64 }) →
returns { id, file_name } →
renderer stores in RHF state via setValue
Form submit →
POST /api/submit (JSON) with the {id, file_name} value →
submit detects the file shape and writes the id as the value of
the activity/contact custom field
File changes:
app/api/upload/route.ts
Replaced the 501 stub with the real File.create call. Comment
documents that EntityFile linkage is intentionally skipped and that
orphan cleanup is owned by a CiviCRM scheduled job.
app/api/submit/route.ts
For type:"file" values shaped as {id, file_name}, write the id as
the custom field value (activity or contact, depending on the
civiField / civiContactField the field declares).
components/fields/FieldRenderer.tsx
Replaced the bare <input type=file> register() with FileField, an
upload-on-pick subcomponent. The native input is NOT register()'d:
its FileList value was the original bug. FileField owns its
uploading + error state and writes {id, file_name} via setValue on
success. Submit is blocked upstream while uploads are in flight.
components/StageSection.tsx, components/EngagementForm.tsx
Thread setValue, cid, cs, and an onUploadStateChange callback
through to FieldRenderer. EngagementForm tracks uploads-in-flight
count; onSubmit refuses to submit while the count is > 0.
config/form.ts
Promotes Certificate_of_Incorporation from readonly to a real
file field now that the pipeline works.
app/api/data/route.ts
Drops the readonly carveout that was only needed while the
certificate was readonly.
scripts/list-civi-entities.mjs (new)
APIv4 entity probe + APIv3 attachment-API probe. Used to determine
that File (not Attachment) was the right entity on this Civi.
scripts/spike-file-upload.mjs (new)
The actual end-to-end test that proved out the pipeline before
wiring. Safe to re-run on any Civi instance during future audits.
Not in this change:
- Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
- Per-field MIME allowlists (single global list for v1)
- S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
today to stay under Amplify Lambda's 6 MB sync payload limit)
254 lines
9.7 KiB
TypeScript
254 lines
9.7 KiB
TypeScript
/**
|
|
* 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<string>([
|
|
"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 });
|
|
}
|