Staff report: compact rows, anchor nav, Civi file links, Y1 matrix

UX iteration after first live look:
- Sticky anchor strip below the header with a chip per section (incl.
  Submissions) so staff can jump around a long page.
- Compact one-line rows that show only the latest value; multi-history
  fields get a muted 'N earlier entries' toggle that reveals the rest
  inline. Same affordance for file fields.
- Empty fields collapse under a single 'N empty fields' toggle per
  section instead of taking a row each.
- Stage 5: Y1_Q<n>_<metric> fields render as a read-only matrix table
  (rows: metrics; columns: Q1..Q4) matching the form's matrix layout.

File proxy (/api/staff/file) deleted. APIv4 Attachment isn't exposed
on this Civi instance (per the June upload spike), which is why the
previous proxy returned broken images. Staff are already authenticated
to Civi when they arrive here, so file fields now render as outbound
links to CIVI_BASE_URL/civicrm/file?reset=1&id=<id> and the browser
uses the staff session. No more proxy auth, no more SSRF surface to
harden, no broken images.

CIVI_BASE_URL flows from the staff page (server component) into the
client as a prop. No secret material crosses the boundary.
This commit is contained in:
Joel Brock
2026-06-05 17:28:17 -07:00
parent 586cf14e75
commit b548b6425b
3 changed files with 329 additions and 292 deletions
-135
View File
@@ -1,135 +0,0 @@
/**
* GET /api/staff/file?id=<civi_file_id>&key=<secret>
*
* 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<string, string> = {};
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 });
}
}
+4 -1
View File
@@ -23,6 +23,9 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
const orgId = Number(org);
const orgValid = !!org && Number.isFinite(orgId) && orgId > 0;
// CIVI_BASE_URL flows from server config to client only as a base for
// outbound file links. No secret material is exposed.
const civiBaseUrl = process.env.CIVI_BASE_URL ?? "";
return (
<>
@@ -37,7 +40,7 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
{orgValid ? (
<Suspense fallback={null}>
<StaffReportView org={orgId} authKey={key!} />
<StaffReportView org={orgId} authKey={key!} civiBaseUrl={civiBaseUrl} />
</Suspense>
) : (
<MissingOrg />