Files
WebForm-mw/app/api/submit/route.ts
T
Joel Brock df471e0e88 Verify client-supplied file ids on submit
/api/submit wrote whatever {id} the client sent into file custom fields.
A link holder could point their org at any civicrm_file id, learn its
filename via /api/report, and make the staff file proxy treat it as
theirs. Each file id must now either be anchored to the submitting
contact (how /api/upload stores uploads) or equal the org's current value
for that field; otherwise the submit is rejected with a 400.
2026-10-30 11:19:07 -07:00

221 lines
8.0 KiB
TypeScript

/**
* POST /api/submit
*
* Body: SubmitPayload { cid, cs, values }.
*
* Verifies checksum, resolves org from cid (mirror of /api/data), then
* creates a new `Check-in (organizing)` activity. The activity's own Stage
* field is intentionally left null: the form is not the authority on stage.
* Staff set Stage on their own check-in activities to mark transitions, and
* /api/data derives the org's current stage from the most recent stage-
* bearing activity.
*
* STUB MODE: if CiviCRM env vars are unset, returns success without writing.
*/
import { NextResponse } from "next/server";
import { civi, civi3, verifyChecksum } from "@/lib/civicrm";
import { loadPrefill } from "@/lib/prefill";
import { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import type { SubmitPayload } from "@/types/form";
import { appEnv, redact } from "@/lib/env";
import { rateLimit, clientIp } from "@/lib/rate-limit";
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
const FILE_FIELDS = allFields.filter((f) => f.type === "file");
/**
* File fields arrive as {id, file_name} (or a bare id). Normalize to a
* positive integer civicrm_file id, or null when cleared/malformed.
*/
function fileIdOf(value: unknown): number | null {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as { id?: unknown }).id
: value;
if (typeof raw !== "number" && typeof raw !== "string") return null;
const n = Number(raw);
return Number.isSafeInteger(n) && n > 0 ? n : null;
}
/** The org's current file-field values, keyed by form field name. */
async function currentFileValues(orgId: number): Promise<Record<string, unknown>> {
const { values } = await loadPrefill(orgId, FILE_FIELDS, ACTIVITY_TYPE_NAME);
const contactRefs = FILE_FIELDS.map((f) => f.civiContactField).filter((s): s is string => Boolean(s));
if (contactRefs.length > 0) {
const res = await civi<Record<string, unknown>>("Contact", "get", {
select: ["id", ...contactRefs],
where: [["id", "=", orgId]],
});
const org = res.values?.[0] ?? {};
for (const f of FILE_FIELDS) {
if (f.civiContactField && org[f.civiContactField] != null) values[f.name] = org[f.civiContactField];
}
}
return values;
}
/**
* File ids come from the client, so prove each one belongs to this
* submitter: either uploaded by this contact via /api/upload (which
* anchors the file to civicrm_contact/<cid>) or unchanged from the org's
* current value. Otherwise a link holder could attach — and then read the
* filename of — any file in Civi, and the staff file proxy would treat it
* as belonging to their org.
*/
async function fileRefsAllowed(cid: number, orgId: number, values: Record<string, unknown>): Promise<boolean> {
let current: Record<string, unknown> | undefined;
for (const field of FILE_FIELDS) {
if (!(field.name in values)) continue;
const id = fileIdOf(values[field.name]);
if (id === null) continue; // cleared
const att = await civi3<{ entity_table?: string; entity_id?: string | number }>("Attachment", "get", {
id,
sequential: 1,
}).catch(() => ({ values: [] }));
const row = att.values?.[0];
if (row?.entity_table === "civicrm_contact" && Number(row.entity_id) === cid) continue;
current ??= await currentFileValues(orgId);
if (fileIdOf(current[field.name]) === id) continue;
return false;
}
return true;
}
export async function POST(req: Request) {
// Rate limit per client IP. Generous default — 10 submissions per minute.
const ip = clientIp(req);
const rl = rateLimit(`submit:${ip}`, { capacity: 10, windowMs: 60_000 });
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many submissions. Please wait a moment and try again." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
},
},
);
}
let body: SubmitPayload;
try {
body = (await req.json()) as SubmitPayload;
} catch {
return NextResponse.json({ error: "Malformed JSON body." }, { status: 400 });
}
const { cid, cs, values } = body;
if (!cid || !cs) {
return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 });
}
if (isStubMode()) {
console.warn("[submit:STUB] would create Check-in (organizing) activity with values:", values);
return NextResponse.json({ ok: true, stub: true });
}
try {
return await runSubmit(cid, cs, values);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[submit] failure:", redact(msg));
return NextResponse.json(
{
error: appEnv().isProduction
? "Could not save your survey. Please try again, or contact <a href=\"mailto:chris@fci.coop\">Chris @ FCI</a>."
: `Save failed: ${redact(msg)}`,
},
{ status: 500 },
);
}
}
async function runSubmit(cid: string, cs: string, values: Record<string, unknown>): Promise<NextResponse> {
const ok = await verifyChecksum(cid, cs);
if (!ok) {
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
}
// Resolve org via the relationship (mirror of /api/data).
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
select: ["contact_id_b"],
where: [
["contact_id_a", "=", Number(cid)],
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
["is_active", "=", true],
],
limit: 2,
});
const orgs = relRes.values ?? [];
if (orgs.length !== 1) {
return NextResponse.json(
{ error: "Could not resolve a unique organization for your contact." },
{ status: orgs.length === 0 ? 404 : 409 },
);
}
const orgId = orgs[0].contact_id_b;
if (!(await fileRefsAllowed(Number(cid), orgId, values ?? {}))) {
return NextResponse.json(
{ error: "One of the attached files couldn't be verified. Please re-upload it and try again." },
{ status: 400 },
);
}
// Split incoming values into:
// - activityRecord: fields with civiField → written to the new activity
// - orgContactValues: fields with civiContactField → written to the org
// contact via Contact.update
// Readonly fields are skipped entirely (display-only).
const activityRecord: Record<string, unknown> = {
"activity_type_id:name": ACTIVITY_TYPE_NAME,
"status_id:name": "Completed",
target_contact_id: orgId,
source_contact_id: Number(cid),
subject: "Co-op Survey (form submission)",
};
const orgContactValues: Record<string, unknown> = {};
for (const [name, value] of Object.entries(values)) {
const field = FIELD_BY_NAME.get(name);
if (!field) continue;
if (field.type === "readonly") continue; // never write read-only fields
// File fields: the renderer uploads to /api/upload on file-pick and
// stores {id, file_name} in form state. Submit only needs the id —
// that's what Civi stores in the custom column. If the user left a
// prior attachment alone, we receive the same prefill shape and
// still write the same id (no-op effectively).
// fileRefsAllowed() above has already vetted these ids.
const civiValue: unknown = field.type === "file" ? fileIdOf(value) : value;
if (field.civiContactField) {
orgContactValues[field.civiContactField] = civiValue;
} else if (field.civiField) {
activityRecord[field.civiField] = civiValue;
}
}
// Update the org contact first (if any contact-bound fields changed),
// then create the submission activity. Order matters: if the contact
// update fails we'd rather not have an orphan activity claiming the
// submission succeeded.
if (Object.keys(orgContactValues).length > 0) {
await civi("Contact", "update", {
where: [["id", "=", orgId]],
values: orgContactValues,
});
}
await civi("Activity", "create", { values: activityRecord });
return NextResponse.json({ ok: true });
}