diff --git a/app/api/staff/file/route.ts b/app/api/staff/file/route.ts new file mode 100644 index 0000000..f055e5d --- /dev/null +++ b/app/api/staff/file/route.ts @@ -0,0 +1,102 @@ +/** + * GET /api/staff/file?id=&key= + * + * Streams an attachment from CiviCRM to the caller. The Civi API user's + * credentials never leave the server. Auth is the same shared + * STAFF_REPORT_KEY used by /api/staff/report. + * + * In stub mode, returns a tiny placeholder PNG so the UI's preview path + * is exercisable in dev. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { isStaffKeyValid } from "@/lib/staff-auth"; +import { civi } from "@/lib/civicrm"; + +function isCiviStubMode(): boolean { + return !( + process.env.CIVI_BASE_URL && + process.env.CIVI_API_KEY && + process.env.CIVI_SITE_KEY + ); +} + +// 1x1 transparent PNG, base64-encoded — used as a stub attachment so the +// UI's image preview path renders something in dev. +const STUB_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const key = url.searchParams.get("key"); + const idStr = url.searchParams.get("id"); + + if (!isStaffKeyValid(key)) { + return new NextResponse("Not found", { status: 404 }); + } + + const id = Number(idStr); + if (!idStr || !Number.isFinite(id) || id <= 0) { + return new NextResponse("Bad request", { status: 400 }); + } + + if (isCiviStubMode()) { + const bytes = Buffer.from(STUB_PNG_B64, "base64"); + return new NextResponse(bytes, { + status: 200, + headers: { + "content-type": "image/png", + "content-disposition": `inline; filename="stub-${id}.png"`, + "cache-control": "private, max-age=60", + }, + }); + } + + try { + // Look up the attachment URL and metadata. + const meta = await civi<{ id: number; url: string; mime_type: string; name: string }>( + "Attachment", + "get", + { + select: ["id", "url", "mime_type", "name"], + where: [["id", "=", id]], + }, + ); + const row = meta.values?.[0]; + if (!row?.url) { + return new NextResponse("Not found", { status: 404 }); + } + + const headers: Record = {}; + if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) { + const creds = Buffer.from( + `${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`, + ).toString("base64"); + headers["Authorization"] = `Basic ${creds}`; + } + + const upstream = await fetch(row.url, { headers }); + if (!upstream.ok || !upstream.body) { + console.error( + `[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`, + ); + return new NextResponse("Upstream error", { status: 502 }); + } + + const safeName = (row.name || `file-${id}`).replace(/"/g, ""); + const mime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream"; + const isInline = mime.startsWith("image/") || mime === "application/pdf"; + return new NextResponse(upstream.body, { + status: 200, + headers: { + "content-type": mime, + "content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`, + "cache-control": "private, max-age=60", + }, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`[staff/file] fetch threw: ${msg}`); + return new NextResponse("Upstream error", { status: 502 }); + } +}