Files
WebForm-mw/app/api/upload/route.ts
T
Joel Brock f74fd07ba5 Upload: switch to APIv3 Attachment.create multipart (decode bytes properly)
APIv4 File.create on this Civi install stores the `content` field
verbatim — no base64 decoding. The hex dump of a downloaded file
confirms it: bytes start with 69 56 42 4f ("iVBO...") which is the
base64 encoding of the PNG header (89 50 4e 47), not the header itself.
Every file uploaded via the form has been corrupt on disk since launch.

JSON can't carry binary safely (high bytes break UTF-8), so the fix is
to stop trying. APIv3 Attachment.create accepts a multipart `file` part
the standard way (read from $_FILES on the server side) which preserves
bytes exactly.

Changes:
- lib/civicrm.ts: new civi3Upload() helper. POSTs multipart/form-data
  with `entity`, `action`, `json`, and `file` parts to /civicrm/ajax/rest
  using the same AuthX headers as civi3(). Wraps the Uint8Array into an
  ArrayBuffer slice so Blob's narrower BlobPart typing accepts it.
- app/api/upload/route.ts: replace the v4 File.create JSON call with
  civi3Upload("Attachment", "create", ...). Attachment.create requires
  an entity context, so anchor to the form-filler's contact id. Our
  custom-field flow uses the returned file id directly (no entity_file
  linkage needed for prefill/download), so the extra civicrm_entity_file
  row is metadata-only.

Note: existing files in Civi (uploaded via the buggy path) are still
corrupt on disk. New uploads will be intact. To recover the old ones
the user would need to re-upload via the form, or run a one-off
backfill that reads the base64 text out of /civicrm.files/upload/ and
rewrites each file with its decoded bytes.
2026-06-10 11:57:52 -07:00

262 lines
10 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 { civi3Upload, 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 },
);
}
// APIv3 Attachment.create with a multipart `file` part. The earlier
// approach used APIv4 File.create with `content: base64String` and that
// Civi on this install stores the literal base64 *text* on disk — every
// file came back corrupted (hex 6956... = "iVBO..." = base64 PNG header).
// APIv3 Attachment.create reads $_FILES['file'] from the multipart body
// and writes the bytes through unchanged.
//
// Attachment.create requires (entity_table, entity_id). Our custom-field
// flow stores the file id directly in the custom column (no
// civicrm_entity_file linkage needed for prefill/download), but the API
// still mandates an entity context for the upload call itself, so we
// anchor to the form-filler's contact id. The resulting civicrm_entity_file
// row is metadata-only — Civi's custom-field joins ignore it.
//
// 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 (stage-N) or to the org contact custom
// field (Food_Co_op_Organizing.*).
//
// Orphan files: if the user uploads and then abandons the form, the
// civicrm_file + civicrm_entity_file rows persist with no business
// reference. Cleanup is handled by a CiviCRM scheduled job (configured
// separately by the Civi admin) that prunes orphans older than ~24h.
let fileId: number;
try {
const res = await civi3Upload<{ id: string | number }>(
"Attachment",
"create",
{
entity_table: "civicrm_contact",
entity_id: Number(cid),
name: safeName,
mime_type: clientMime,
sequential: 1,
},
{ bytes, filename: safeName, mime: clientMime },
);
const idRaw = res.values?.[0]?.id;
const id = typeof idRaw === "string" ? Number(idRaw) : idRaw;
if (!id || !Number.isFinite(id)) {
throw new Error("Attachment.create returned no id");
}
fileId = id;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[upload] Attachment.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 });
}