/** * 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 }); } // SSRF guard: only follow URLs whose origin matches CIVI_BASE_URL. Civi // returns absolute URLs for attachments; if a compromised Civi (or DB row // tamper) ever set this to an attacker-controlled host, the basic-auth // creds attached below would leak. Validating the origin closes that. const civiOrigin = new URL(process.env.CIVI_BASE_URL!).origin; let upstreamUrl: URL; try { upstreamUrl = new URL(row.url); } catch { console.error(`[staff/file] malformed civi url id=${id}`); return new NextResponse("Upstream error", { status: 502 }); } if (upstreamUrl.origin !== civiOrigin) { console.error( `[staff/file] refused cross-origin upstream id=${id} origin=${upstreamUrl.origin}`, ); return new NextResponse("Upstream error", { status: 502 }); } 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(upstreamUrl, { headers, redirect: "manual" }); if (!upstream.ok || !upstream.body) { console.error( `[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`, ); return new NextResponse("Upstream error", { status: 502 }); } // XSS guard: only allow a fixed allowlist of MIME types to render inline // (browsers execute scripts inside SVGs and HTML, and will sniff some // ambiguous types). Everything else is forced to attachment with a // neutralised content-type. nosniff blocks MIME sniffing entirely. const SAFE_INLINE = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", ]); const safeName = (row.name || `file-${id}`).replace(/[\r\n"]/g, ""); const declaredMime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream"; const isInline = SAFE_INLINE.has(declaredMime); const servedMime = isInline ? declaredMime : "application/octet-stream"; return new NextResponse(upstream.body, { status: 200, headers: { "content-type": servedMime, "content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`, "cache-control": "private, max-age=60", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; sandbox; style-src 'unsafe-inline'", }, }); } 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 }); } }