Files
WebForm-mw/app/api/preview-link/route.ts
T
Joel Brock 526422abed Give /api/preview-link its own Bearer secret; constant-time token checks
preview-link mints working form links for any contact, but was gated by
HEALTH_TOKEN (the diagnostics secret) passed in the query string, where
it lands in access logs. README already documented PREVIEW_ADMIN_TOKEN
as a Bearer header; make the code match.

- PREVIEW_ADMIN_TOKEN, when set, is required as Authorization: Bearer
  and is never accepted from the URL.
- Until it is set, HEALTH_TOKEN still works (Bearer or legacy ?token=)
  so the support runbook in EMAIL_DELIVERY.md keeps working.
- preview-link and /api/health compare tokens in constant time.
- redact() also scrubs PREVIEW_ADMIN_TOKEN and STAFF_REPORT_KEY.
2026-10-30 11:20:30 -07:00

150 lines
5.1 KiB
TypeScript

/**
* GET /api/preview-link?cid=<contactId>
* Authorization: Bearer <PREVIEW_ADMIN_TOKEN>
*
* Returns a ready-to-send survey URL for a given contact, plus enough
* context for staff to verify it's the right org. Used for:
* - Generating links to paste into ad-hoc emails (or external tools)
* - Testing a contact's setup before sending the real CiviCRM email
* - QA before rollout
*
* AUTH: this mints working form links for ANY contact, so it gets its own
* secret, PREVIEW_ADMIN_TOKEN, sent as a Bearer header to keep it out of
* URLs and access logs. Until PREVIEW_ADMIN_TOKEN is set, HEALTH_TOKEN is
* accepted instead (as a Bearer header or the legacy `?token=`) so the
* existing support workflow keeps working. With neither set, production
* returns 404 and development allows passthrough.
*
* Returns:
* {
* contactId, contactName,
* orgId, orgName, orgStage,
* url, // the full form URL with cid + cs
* checksum,
* ttlHours // how long the checksum is valid (Civi default 14d)
* }
*/
import { NextResponse } from "next/server";
import { civi } from "@/lib/civicrm";
import { FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import { appEnv } from "@/lib/env";
import { safeEqual } from "@/lib/staff-token.mjs";
interface PreviewLinkResponse {
contactId: number;
contactName: string;
orgId: number;
orgName: string;
orgStage: string;
url: string;
checksum: string;
ttlHours: number;
}
export async function GET(req: Request) {
const env = appEnv();
const url = new URL(req.url);
const cid = url.searchParams.get("cid");
// Auth gate — see header comment.
const secret = env.previewAdminToken || env.healthToken;
if (secret) {
const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
const presented = bearer ?? (env.previewAdminToken ? null : url.searchParams.get("token"));
if (!presented || !safeEqual(presented, secret)) {
return new NextResponse("Not Found", { status: 404 });
}
} else if (env.isProduction) {
return new NextResponse("Not Found", { status: 404 });
}
if (!cid) {
return NextResponse.json({ error: "Missing cid parameter." }, { status: 400 });
}
const contactId = Number(cid);
if (!Number.isFinite(contactId)) {
return NextResponse.json({ error: "cid must be an integer." }, { status: 400 });
}
if (env.isStub) {
return NextResponse.json(
{ error: "Stub mode active — set CIVI_* env vars to generate real links." },
{ status: 501 },
);
}
// Resolve org via the Primary Contact relationship.
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
select: ["contact_id_b"],
where: [
["contact_id_a", "=", contactId],
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
["is_active", "=", true],
],
limit: 2,
});
const orgs = relRes.values ?? [];
if (orgs.length === 0) {
return NextResponse.json(
{
error: `Contact ${contactId} has no active "${FORM_CONTACT_RELATIONSHIP}" relationship to an organization.`,
},
{ status: 404 },
);
}
if (orgs.length > 1) {
return NextResponse.json(
{
error: `Contact ${contactId} has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships. Resolve in CiviCRM first.`,
},
{ status: 409 },
);
}
const orgId = orgs[0].contact_id_b;
// Pull contact + org names + the org's current stage so the response gives
// staff everything they need to confirm "yes, this is the right link."
const [contactRes, orgRes, csRes] = await Promise.all([
civi<{ display_name: string }>("Contact", "get", {
select: ["display_name"],
where: [["id", "=", contactId]],
}),
civi<{ display_name: string; "Food_Co_op_Organizing.Stage": string | null }>("Contact", "get", {
select: ["display_name", "Food_Co_op_Organizing.Stage"],
where: [["id", "=", orgId]],
}),
civi<{ checksum: string }>("Contact", "getChecksum", { contactId }),
]);
const contactName = contactRes.values?.[0]?.display_name ?? `Contact ${contactId}`;
const orgName = orgRes.values?.[0]?.display_name ?? `Organization ${orgId}`;
const orgStage = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? "";
const checksum = csRes.values?.[0]?.checksum;
if (!checksum) {
return NextResponse.json(
{ error: "Could not generate a checksum for this contact." },
{ status: 500 },
);
}
// Build the URL. Prefer the explicit PUBLIC_ORIGIN env var; fall back to
// the request's own origin (handy for dev / first-deploy).
const origin =
env.publicOrigin ?? `${url.protocol}//${url.host}`;
const formUrl = new URL("/", origin);
formUrl.searchParams.set("cid", String(contactId));
formUrl.searchParams.set("cs", checksum);
const payload: PreviewLinkResponse = {
contactId,
contactName,
orgId,
orgName,
orgStage: orgStage ?? "",
url: formUrl.toString(),
checksum,
ttlHours: 14 * 24, // Civi default — adjust if your install changed cs_offset.
};
return NextResponse.json(payload);
}