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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user