Files
Joel Brock 325615576a Upload: route through Civi extension multipart endpoint
Every JSON-based upload path on this Civi stores the `content` field
verbatim on disk — confirmed against both APIv4 File.create AND APIv3
Attachment.create (both came back as base64 text in hex dumps). The
multipart `file` part to /civicrm/ajax/rest is also a dead end: APIv3
Attachment.create on this install doesn't see $_FILES (rejected with
"Mandatory key(s) missing: id or content or options.move-file").

The one path Civi honors is APIv3 Attachment.create + options.move-file
— pointing at a filesystem path the Civi server can read. So expose a
tiny multipart endpoint in the WebForm-mw Civi extension that copies
PHP's $_FILES['file']['tmp_name'] into the API call, then return the
new file id as JSON. PHP's $_FILES preserves binary natively.

Civi extension (requires admin deploy):
- CRM/WebformMw/Page/Upload.php  : multipart POST handler. Validates
  the upload, requires `access CiviCRM`, whitelists entity_table to
  civicrm_contact|civicrm_activity, calls Attachment.create with
  move-file pointing at the tmp upload, returns {id, name} JSON.
- xml/Menu/webform_mw.xml        : registers civicrm/webform-mw/upload.

WebForm-mw side:
- lib/civicrm.ts : new civiMultipart() helper. POSTs multipart to an
  arbitrary Civi path (not /civicrm/ajax/rest) with the same AuthX
  headers. Returns the parsed JSON body.
- app/api/upload/route.ts : send the upload's bytes via civiMultipart
  to civicrm/webform-mw/upload. Comment-block now records all four
  upload paths we tried so a future reader doesn't repeat the cycle.

Deploy: admin syncs the updated civi-extension/webform-mw/ directory
and Disable/Re-enables the extension (or runs cv flush) so the new
menu route is registered.
2026-06-10 12:28:43 -07:00

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 { civiMultipart, 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 },
);
}
// Path history exhausted before we got here:
// 1. APIv4 File.create + content:base64 → stores base64 text on disk.
// 2. APIv3 Attachment.create + content:base64 → same; v3 doesn't decode either.
// 3. APIv3 Attachment.create + multipart file → /civicrm/ajax/rest drops $_FILES.
//
// The one path Civi reliably honors is options.move-file: APIv3
// Attachment.create with a server-side filesystem path. PHP's $_FILES
// preserves binary natively, so the WebForm-mw Civi extension exposes
// a tiny multipart endpoint that copies the upload's tmp_name into
// Attachment.create as options.move-file. We POST the file there.
//
// Attachment.create requires (entity_table, entity_id); our custom-field
// flow uses the returned file id directly in the custom column (no
// entity_file linkage needed), so we anchor to the form-filler's contact
// id and accept the metadata-only civicrm_entity_file row.
//
// Orphan files: civicrm_file rows linger if the user uploads then
// abandons. Cleanup is handled by a CiviCRM scheduled job (separately
// configured by the Civi admin).
let fileId: number;
try {
const res = await civiMultipart<{ id?: number; name?: string; error?: string }>(
"civicrm/webform-mw/upload",
{
entity_table: "civicrm_contact",
entity_id: String(Number(cid)),
name: safeName,
mime_type: clientMime,
},
{ bytes, filename: safeName, mime: clientMime },
);
if (!res || !res.id || !Number.isFinite(res.id)) {
throw new Error(res?.error ?? "Upload endpoint returned no id");
}
fileId = Number(res.id);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[upload] Civi extension upload failed:", msg);
return NextResponse.json(
{ error: "Could not save the upload. Please try again." },
{ status: 502 },
);
}
return NextResponse.json({ id: fileId, file_name: safeName });
}