Staff report: inline lightbox for image/PDF attachments
Adds a /api/staff/file proxy that re-streams Civi attachments with Content-Disposition: inline so a native <dialog> lightbox can preview images and PDFs in place. Office docs keep their plain download link and gain a "View in Google Docs" secondary link (uses the Civi-signed URL so Google can fetch without our staff key). Also threads mime through /api/staff/report (Attachment.get mime_type) so the dispatcher picks the right affordance without relying solely on filename inference.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
|
||||
*
|
||||
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
|
||||
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
||||
* preview images and PDFs in-place. Civi's own `/civicrm/file` handler
|
||||
* always sends `attachment`, which forces a download — that's correct for
|
||||
* its UI but wrong for an embedded preview.
|
||||
*
|
||||
* Authorization layers:
|
||||
* 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
|
||||
* 2. Server-side check that the requested file is actually linked to the
|
||||
* `org`. This prevents the staff key from being used to pull arbitrary
|
||||
* file ids out of CiviCRM — a file is reachable only if its
|
||||
* entity_table/entity_id ties back to the org (directly, for org
|
||||
* custom-field files; or via an activity's target_contact_id, for
|
||||
* activity custom-field files).
|
||||
*
|
||||
* The upstream fetch uses the URL Civi returns from `Attachment.get`,
|
||||
* which includes a freshly-minted `fcs` JWT. We don't carry any user
|
||||
* session cookies — that JWT is the auth for `/civicrm/file`.
|
||||
*
|
||||
* STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't
|
||||
* meaningful (there are no real bytes to serve).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||
import { civi3 } from "@/lib/civicrm";
|
||||
import { resolveMime } from "@/lib/mime.mjs";
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function isCiviStubMode(): boolean {
|
||||
return !(
|
||||
process.env.CIVI_BASE_URL &&
|
||||
process.env.CIVI_API_KEY &&
|
||||
process.env.CIVI_SITE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentRow {
|
||||
id: string | number;
|
||||
url?: string;
|
||||
mime_type?: string;
|
||||
name?: string;
|
||||
entity_table?: string;
|
||||
entity_id?: string | number;
|
||||
}
|
||||
|
||||
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
|
||||
const res = await civi3<AttachmentRow>("Attachment", "get", {
|
||||
id: fileId,
|
||||
return: "id,url,mime_type,name,entity_table,entity_id",
|
||||
sequential: 1,
|
||||
});
|
||||
return res.values?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** Confirm a file is reachable from `orgId`. Returns false on any uncertainty. */
|
||||
async function fileBelongsToOrg(row: AttachmentRow, orgId: number): Promise<boolean> {
|
||||
const entityId = Number(row.entity_id);
|
||||
if (!Number.isFinite(entityId) || entityId <= 0) return false;
|
||||
const entityTable = String(row.entity_table ?? "");
|
||||
|
||||
if (entityTable === "civicrm_contact") {
|
||||
return entityId === orgId;
|
||||
}
|
||||
if (entityTable === "civicrm_activity") {
|
||||
// The activity must have orgId in its target_contact_id list. APIv4
|
||||
// exposes this as `target_contact_id` array; we just need a hit-check.
|
||||
try {
|
||||
const probe = await civi3<{ id: string | number }>("Activity", "get", {
|
||||
id: entityId,
|
||||
target_contact_id: orgId,
|
||||
return: "id",
|
||||
sequential: 1,
|
||||
});
|
||||
return Array.isArray(probe.values) && probe.values.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const key = url.searchParams.get("key");
|
||||
const idStr = url.searchParams.get("id");
|
||||
const orgStr = url.searchParams.get("org");
|
||||
const wantsDownload = url.searchParams.get("dl") === "1";
|
||||
|
||||
if (!isStaffKeyValid(key)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const fileId = Number(idStr);
|
||||
const orgId = Number(orgStr);
|
||||
if (!idStr || !Number.isFinite(fileId) || fileId <= 0) {
|
||||
return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 });
|
||||
}
|
||||
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
|
||||
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isCiviStubMode()) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
let row: AttachmentRow | null;
|
||||
try {
|
||||
row = await fetchAttachment(fileId);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[staff/file] Attachment.get failed:", msg);
|
||||
return NextResponse.json({ error: "Could not look up the file." }, { status: 502 });
|
||||
}
|
||||
if (!row || !row.url) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const belongs = await fileBelongsToOrg(row, orgId);
|
||||
if (!belongs) {
|
||||
// Don't differentiate from "not found" — leaking link existence to a
|
||||
// probe-with-wrong-org gives no useful info to a legit caller and a
|
||||
// little to an attacker.
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
// The signed URL Civi returns is sometimes a relative path (depends on
|
||||
// Civi config). Normalise against CIVI_BASE_URL so fetch() has an
|
||||
// absolute URL.
|
||||
const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
const upstream = row.url.startsWith("http")
|
||||
? row.url
|
||||
: `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`;
|
||||
|
||||
let upstreamRes: Response;
|
||||
try {
|
||||
upstreamRes = await fetch(upstream, { cache: "no-store" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[staff/file] upstream fetch failed:", msg);
|
||||
return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 });
|
||||
}
|
||||
if (!upstreamRes.ok || !upstreamRes.body) {
|
||||
return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 });
|
||||
}
|
||||
|
||||
const contentLengthRaw = upstreamRes.headers.get("content-length");
|
||||
const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN;
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 });
|
||||
}
|
||||
|
||||
const mime = resolveMime(
|
||||
row.mime_type ?? upstreamRes.headers.get("content-type"),
|
||||
row.name,
|
||||
);
|
||||
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": mime,
|
||||
"Content-Disposition": `${wantsDownload ? "attachment" : "inline"}; filename="${safeName}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
if (Number.isFinite(contentLength)) {
|
||||
headers["Content-Length"] = String(contentLength);
|
||||
}
|
||||
|
||||
return new NextResponse(upstreamRes.body, { status: 200, headers });
|
||||
}
|
||||
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
|
||||
{
|
||||
activityId: 9012,
|
||||
date: daysAgo(3),
|
||||
value: { id: 4242, file_name: "co-op-vision.pdf" },
|
||||
value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -373,6 +373,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
||||
}
|
||||
|
||||
const urlByFileId = new Map<number, string>();
|
||||
const mimeByFileId = new Map<number, string>();
|
||||
if (fileIds.size > 0) {
|
||||
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
|
||||
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
|
||||
@@ -382,11 +383,15 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
||||
// small. Each request is independent; we issue them in parallel.
|
||||
const lookups = await Promise.allSettled(
|
||||
Array.from(fileIds).map((fid) =>
|
||||
civi3<{ id: string | number; url?: string }>("Attachment", "get", {
|
||||
civi3<{ id: string | number; url?: string; mime_type?: string }>(
|
||||
"Attachment",
|
||||
"get",
|
||||
{
|
||||
id: fid,
|
||||
return: "id,url",
|
||||
return: "id,url,mime_type",
|
||||
sequential: 1,
|
||||
}).then((r) => ({ fid, row: r.values?.[0] })),
|
||||
},
|
||||
).then((r) => ({ fid, row: r.values?.[0] })),
|
||||
),
|
||||
);
|
||||
for (const result of lookups) {
|
||||
@@ -401,6 +406,9 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
||||
if (row && typeof row.url === "string" && row.url.length > 0) {
|
||||
urlByFileId.set(fid, row.url);
|
||||
}
|
||||
if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) {
|
||||
mimeByFileId.set(fid, row.mime_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
||||
id: raw,
|
||||
file_name: typeof fname === "string" ? fname : undefined,
|
||||
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -480,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
||||
id: v,
|
||||
file_name: typeof fname === "string" ? fname : undefined,
|
||||
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
||||
};
|
||||
}
|
||||
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
computeDateRange,
|
||||
} from "./report/FieldHistory";
|
||||
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
||||
import { FileLink } from "./report/FileLink";
|
||||
|
||||
interface StaffReportViewProps {
|
||||
org: number;
|
||||
@@ -207,6 +208,8 @@ export function StaffReportView({
|
||||
section={section}
|
||||
options={data.options}
|
||||
civiBaseUrl={civiBaseUrl}
|
||||
org={org}
|
||||
authKey={authKey}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -271,10 +274,14 @@ function StaffSection({
|
||||
section,
|
||||
options,
|
||||
civiBaseUrl,
|
||||
org,
|
||||
authKey,
|
||||
}: {
|
||||
section: StaffReportSection;
|
||||
options: Record<number, SelectOption[]>;
|
||||
civiBaseUrl: string;
|
||||
org: number;
|
||||
authKey: string;
|
||||
}) {
|
||||
const filled = section.fields.filter((f) => f.history.length > 0);
|
||||
const empty = section.fields.filter((f) => f.history.length === 0);
|
||||
@@ -328,6 +335,8 @@ function StaffSection({
|
||||
field={f}
|
||||
options={options}
|
||||
civiBaseUrl={civiBaseUrl}
|
||||
org={org}
|
||||
authKey={authKey}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -374,10 +383,14 @@ function CompactFieldRow({
|
||||
field,
|
||||
options,
|
||||
civiBaseUrl,
|
||||
org,
|
||||
authKey,
|
||||
}: {
|
||||
field: StaffReportField;
|
||||
options: Record<number, SelectOption[]>;
|
||||
civiBaseUrl: string;
|
||||
org: number;
|
||||
authKey: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const latest = field.history[0];
|
||||
@@ -394,6 +407,8 @@ function CompactFieldRow({
|
||||
entry={latest}
|
||||
options={options}
|
||||
civiBaseUrl={civiBaseUrl}
|
||||
org={org}
|
||||
authKey={authKey}
|
||||
/>
|
||||
</span>
|
||||
{latest.date ? (
|
||||
@@ -431,6 +446,8 @@ function CompactFieldRow({
|
||||
entry={e}
|
||||
options={options}
|
||||
civiBaseUrl={civiBaseUrl}
|
||||
org={org}
|
||||
authKey={authKey}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
@@ -446,41 +463,33 @@ function FieldValue({
|
||||
entry,
|
||||
options,
|
||||
civiBaseUrl,
|
||||
org,
|
||||
authKey,
|
||||
}: {
|
||||
field: StaffReportField;
|
||||
entry: FieldHistoryEntry;
|
||||
options: Record<number, SelectOption[]>;
|
||||
civiBaseUrl: string;
|
||||
org: number;
|
||||
authKey: string;
|
||||
}) {
|
||||
if (field.descriptor.render === "file") {
|
||||
const v = entry.value as
|
||||
| { id?: number | string; file_name?: string; url?: string }
|
||||
| { id?: number | string; file_name?: string; url?: string; mime?: string }
|
||||
| null;
|
||||
if (!v || v.id === undefined) return <span>—</span>;
|
||||
const id = String(v.id);
|
||||
const name = v.file_name ?? `file-${id}`;
|
||||
// Prefer the Civi-signed URL (carries the fcs JWT) returned by
|
||||
// Attachment.get. If absent, fall back to the WebForm-mw Civi extension's
|
||||
// file-redirect route — it mints the fcs server-side and 302s to the
|
||||
// real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi
|
||||
// on a null fcs JWT decode.)
|
||||
let href = "#";
|
||||
if (v.url) {
|
||||
href = v.url.startsWith("http")
|
||||
? v.url
|
||||
: `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`;
|
||||
} else if (civiBaseUrl) {
|
||||
href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
<FileLink
|
||||
fileId={id}
|
||||
fileName={name}
|
||||
civiSignedUrl={v.url}
|
||||
mime={v.mime}
|
||||
org={org}
|
||||
authKey={authKey}
|
||||
civiBaseUrl={civiBaseUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Modal preview for image and PDF attachments.
|
||||
*
|
||||
* Uses the native <dialog> element for focus trap, Esc-to-close, and
|
||||
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
|
||||
* that we'd otherwise have to maintain.
|
||||
*
|
||||
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
|
||||
* <iframe>. Anything else should not reach this component — FileLink is
|
||||
* responsible for branching office/other types to plain download links.
|
||||
*/
|
||||
export function AttachmentLightbox({
|
||||
open,
|
||||
onClose,
|
||||
previewSrc,
|
||||
downloadHref,
|
||||
filename,
|
||||
mime,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
|
||||
previewSrc: string;
|
||||
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
|
||||
downloadHref: string;
|
||||
filename: string;
|
||||
/** Resolved mime; used to pick between <img> and <iframe>. */
|
||||
mime: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
// Drive the native <dialog>'s open state from our prop.
|
||||
useEffect(() => {
|
||||
const dlg = ref.current;
|
||||
if (!dlg) return;
|
||||
if (open && !dlg.open) {
|
||||
dlg.showModal();
|
||||
} else if (!open && dlg.open) {
|
||||
dlg.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
|
||||
// submit. Mirror that back into React state so the parent stays in sync.
|
||||
useEffect(() => {
|
||||
const dlg = ref.current;
|
||||
if (!dlg) return;
|
||||
const handle = () => onClose();
|
||||
dlg.addEventListener("close", handle);
|
||||
return () => dlg.removeEventListener("close", handle);
|
||||
}, [onClose]);
|
||||
|
||||
// Close when the user clicks the backdrop (everything outside the inner
|
||||
// panel). The dialog itself receives the click event when the backdrop
|
||||
// is hit because the panel uses pointer-events the same way.
|
||||
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
||||
if (e.target === ref.current) onClose();
|
||||
};
|
||||
|
||||
const isImage = mime.startsWith("image/");
|
||||
const isPdf = mime === "application/pdf";
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClick={onDialogClick}
|
||||
aria-label={`Preview: ${filename}`}
|
||||
className="m-0 h-full max-h-screen w-full max-w-screen-2xl rounded-none bg-transparent p-0 backdrop:bg-ink/70"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-3 shadow-sm sm:px-6">
|
||||
<p className="min-w-0 truncate font-display text-base text-ink">
|
||||
{filename}
|
||||
</p>
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<a
|
||||
href={downloadHref}
|
||||
className="text-sm font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded px-2 py-1 text-sm font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-4">
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt={filename}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : isPdf ? (
|
||||
<iframe
|
||||
src={previewSrc}
|
||||
title={filename}
|
||||
className="h-full w-full max-w-screen-lg border-0 bg-paper"
|
||||
/>
|
||||
) : (
|
||||
// Defensive: FileLink shouldn't open the lightbox for non-previewable
|
||||
// types, but if it does, surface a clear message instead of an empty box.
|
||||
<p className="px-6 text-paper">
|
||||
Preview not available. Use Download above to open the file.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||
import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
|
||||
|
||||
/**
|
||||
* Render an attachment row in the staff report with the right affordance
|
||||
* for its type:
|
||||
*
|
||||
* - image / pdf -> button opens the inline lightbox
|
||||
* - office -> download link + "View in Google Docs" secondary link
|
||||
* - other -> plain download link
|
||||
*
|
||||
* The proxy URL is `/api/staff/file?id=&org=&key=` (re-streams with
|
||||
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
|
||||
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
|
||||
* Google's Docs Viewer for office files; we deliberately don't proxy that
|
||||
* one because Google's servers must fetch it without our staff key.
|
||||
*/
|
||||
export function FileLink({
|
||||
fileId,
|
||||
fileName,
|
||||
civiSignedUrl,
|
||||
mime: explicitMime,
|
||||
org,
|
||||
authKey,
|
||||
civiBaseUrl,
|
||||
}: {
|
||||
fileId: number | string;
|
||||
fileName: string;
|
||||
/** From Attachment.get (includes fcs). Used for office Google Viewer + download fallback. */
|
||||
civiSignedUrl?: string;
|
||||
mime?: string;
|
||||
org: number;
|
||||
authKey: string;
|
||||
civiBaseUrl: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const mime = resolveMime(explicitMime, fileName);
|
||||
const category = categoryFromMime(mime);
|
||||
|
||||
const proxyBase =
|
||||
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
|
||||
`&org=${encodeURIComponent(String(org))}` +
|
||||
`&key=${encodeURIComponent(authKey)}`;
|
||||
const previewSrc = proxyBase;
|
||||
// Prefer Civi's signed URL for downloads when present (one fewer hop
|
||||
// through our Lambda); the proxy is the fallback.
|
||||
const downloadHref = civiSignedUrl
|
||||
? absUrl(civiSignedUrl, civiBaseUrl)
|
||||
: `${proxyBase}&dl=1`;
|
||||
|
||||
const linkClass =
|
||||
"text-ink underline decoration-rule underline-offset-4 hover:decoration-ink";
|
||||
|
||||
if (category === "image" || category === "pdf") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={
|
||||
"bg-transparent p-0 text-left " +
|
||||
linkClass +
|
||||
" focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
|
||||
}
|
||||
>
|
||||
{fileName}
|
||||
</button>
|
||||
<AttachmentLightbox
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
previewSrc={previewSrc}
|
||||
downloadHref={downloadHref}
|
||||
filename={fileName}
|
||||
mime={mime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "office") {
|
||||
// Google Docs Viewer renders DOC/DOCX/XLS/XLSX in a new tab. It fetches
|
||||
// the source URL server-side, so the URL must be reachable without our
|
||||
// staff key — that's why we pass the Civi-signed URL straight through.
|
||||
const gview = civiSignedUrl
|
||||
? `https://docs.google.com/viewer?url=${encodeURIComponent(absUrl(civiSignedUrl, civiBaseUrl))}`
|
||||
: null;
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<a
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
{fileName}
|
||||
</a>
|
||||
{gview && (
|
||||
<a
|
||||
href={gview}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Opens in Google Docs Viewer (file bytes are sent to Google to render)"
|
||||
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
|
||||
>
|
||||
View in Google Docs
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// "other" — unknown types: just a download link.
|
||||
return (
|
||||
<a
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
{fileName}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function absUrl(u: string, base: string): string {
|
||||
if (u.startsWith("http")) return u;
|
||||
if (!base) return u;
|
||||
return `${base.replace(/\/+$/, "")}${u.startsWith("/") ? "" : "/"}${u}`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Mime helpers for staff-report attachment rendering.
|
||||
*
|
||||
* The staff report shows file attachments with three different affordances:
|
||||
* - images / PDF -> inline lightbox preview
|
||||
* - office docs -> plain download + "View in Google Docs" link
|
||||
* - anything else -> plain download
|
||||
*
|
||||
* Civi can serve a mime via `Attachment.get`, but historical uploads may
|
||||
* have a stale or missing `mime_type` column. Fall back to extension-based
|
||||
* inference so we always reach a stable category.
|
||||
*
|
||||
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
|
||||
* import this file directly without any tooling — matches the pattern set
|
||||
* by lib/staff-field-mapping.mjs.
|
||||
*
|
||||
* @typedef {"image" | "pdf" | "office" | "other"} AttachmentCategory
|
||||
*/
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const EXT_TO_MIME = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
};
|
||||
|
||||
const OFFICE_MIMES = new Set([
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pull the lowercased extension from a filename, or "" if absent.
|
||||
* @param {string | undefined | null} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
export function extOf(filename) {
|
||||
if (!filename) return "";
|
||||
const dot = filename.lastIndexOf(".");
|
||||
if (dot < 0 || dot === filename.length - 1) return "";
|
||||
return filename.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a filename's extension to a known mime, or null if unrecognised.
|
||||
* @param {string | undefined | null} filename
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function mimeFromFilename(filename) {
|
||||
const ext = extOf(filename);
|
||||
return EXT_TO_MIME[ext] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mime by trusting the explicit value first, then falling back to
|
||||
* filename inference. Returns "application/octet-stream" if nothing matches.
|
||||
* @param {string | undefined | null} explicit
|
||||
* @param {string | undefined | null} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveMime(explicit, filename) {
|
||||
if (explicit && explicit !== "application/octet-stream") return explicit;
|
||||
return mimeFromFilename(filename) ?? explicit ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorise a mime for UI dispatch.
|
||||
* @param {string} mime
|
||||
* @returns {AttachmentCategory}
|
||||
*/
|
||||
export function categoryFromMime(mime) {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (OFFICE_MIMES.has(mime)) return "office";
|
||||
return "other";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Run with: npm run test:mime
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extOf,
|
||||
mimeFromFilename,
|
||||
resolveMime,
|
||||
categoryFromMime,
|
||||
} from "./mime.mjs";
|
||||
|
||||
test("extOf returns lowercased extension", () => {
|
||||
assert.equal(extOf("Photo.JPG"), "jpg");
|
||||
assert.equal(extOf("doc.tar.gz"), "gz");
|
||||
});
|
||||
|
||||
test("extOf handles missing or trailing-dot names", () => {
|
||||
assert.equal(extOf(""), "");
|
||||
assert.equal(extOf(null), "");
|
||||
assert.equal(extOf("no-extension"), "");
|
||||
assert.equal(extOf("trailing."), "");
|
||||
});
|
||||
|
||||
test("mimeFromFilename recognises known types", () => {
|
||||
assert.equal(mimeFromFilename("vision.pdf"), "application/pdf");
|
||||
assert.equal(mimeFromFilename("budget.XLSX"),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
assert.equal(mimeFromFilename("logo.PNG"), "image/png");
|
||||
});
|
||||
|
||||
test("mimeFromFilename returns null for unknown extensions", () => {
|
||||
assert.equal(mimeFromFilename("archive.zip"), null);
|
||||
assert.equal(mimeFromFilename("readme"), null);
|
||||
});
|
||||
|
||||
test("resolveMime trusts an explicit non-octet mime", () => {
|
||||
assert.equal(resolveMime("application/pdf", "anything.txt"), "application/pdf");
|
||||
});
|
||||
|
||||
test("resolveMime falls back to filename when explicit is missing", () => {
|
||||
assert.equal(resolveMime(undefined, "report.pdf"), "application/pdf");
|
||||
assert.equal(resolveMime(null, "image.gif"), "image/gif");
|
||||
});
|
||||
|
||||
test("resolveMime falls back to filename when explicit is octet-stream", () => {
|
||||
assert.equal(
|
||||
resolveMime("application/octet-stream", "spreadsheet.xlsx"),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveMime returns octet-stream when nothing is known", () => {
|
||||
assert.equal(resolveMime(null, null), "application/octet-stream");
|
||||
assert.equal(resolveMime(undefined, "unknownfile"), "application/octet-stream");
|
||||
});
|
||||
|
||||
test("categoryFromMime: image", () => {
|
||||
assert.equal(categoryFromMime("image/png"), "image");
|
||||
assert.equal(categoryFromMime("image/webp"), "image");
|
||||
});
|
||||
|
||||
test("categoryFromMime: pdf", () => {
|
||||
assert.equal(categoryFromMime("application/pdf"), "pdf");
|
||||
});
|
||||
|
||||
test("categoryFromMime: office variants", () => {
|
||||
assert.equal(categoryFromMime("application/msword"), "office");
|
||||
assert.equal(
|
||||
categoryFromMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||
"office",
|
||||
);
|
||||
assert.equal(categoryFromMime("application/vnd.ms-excel"), "office");
|
||||
assert.equal(
|
||||
categoryFromMime("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||
"office",
|
||||
);
|
||||
});
|
||||
|
||||
test("categoryFromMime: other", () => {
|
||||
assert.equal(categoryFromMime("application/zip"), "other");
|
||||
assert.equal(categoryFromMime("text/plain"), "other");
|
||||
assert.equal(categoryFromMime("application/octet-stream"), "other");
|
||||
});
|
||||
+3
-1
@@ -8,7 +8,9 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
||||
"test:mapping": "node --test lib/staff-field-mapping.test.mjs"
|
||||
"test:mapping": "node --test lib/staff-field-mapping.test.mjs",
|
||||
"test:mime": "node --test lib/mime.test.mjs",
|
||||
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.6",
|
||||
|
||||
Reference in New Issue
Block a user