mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(02-12.1): reach fenced document routes through the app transport
A browser-issued <img src>, <iframe src> or <a href download> cannot carry the record-session assertion, so the fence refused all three on any session that had been inside a shared record — permanently, since none of them will ever send a header. - add a structural guard: no client file may name a record-fenced route in a src=, href= or window.location sink - fetch the thumbnail, the inline preview and the download through the app transport and hand the DOM an object URL, revoked on unmount - make the two API probes in the managed-profiles browser spec assert the same context the app beside them asserts
This commit is contained in:
1 parent
8fcbdcc0df
commit
ffe38c9bb6
7 files changed
+739
-27
No files matched your search
@@ -118,7 +118,20 @@ describe("<DocumentCard>", () => {
|
||||
expect(html).toContain("befund.docx");
|
||||
});
|
||||
|
||||
it("renders the preview thumbnail image when hasThumbnail is set", () => {
|
||||
it("never emits a browser-issued /api subresource for the thumbnail", () => {
|
||||
// v1.37.0 — this used to assert `src="/api/documents/inbound/…/thumbnail"`
|
||||
// in the markup, and that contract is deliberately gone. A `<img src>` is
|
||||
// issued by the BROWSER, so it cannot carry the record-session assertion,
|
||||
// and the fence refuses an unasserted request on any session that has been
|
||||
// inside a shared record — a thumbnail that silently stops rendering for
|
||||
// good. The bytes now come through the app transport as an object URL
|
||||
// (`useFencedObjectUrl`), which means nothing paints on a server render.
|
||||
//
|
||||
// The assertion is inverted rather than deleted: the markup must contain no
|
||||
// `/api/` reference at all. `src/__tests__/record-fence-headerless-transport-guard.test.ts`
|
||||
// holds the same property across the whole client tree, and
|
||||
// `src/hooks/__tests__/use-fenced-object-url.test.ts` proves the request
|
||||
// really does carry the headers.
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ hasThumbnail: true })}
|
||||
@@ -128,9 +141,11 @@ describe("<DocumentCard>", () => {
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-thumbnail"');
|
||||
expect(html).toContain('src="/api/documents/inbound/doc-1/thumbnail"');
|
||||
expect(html).toContain('loading="lazy"');
|
||||
expect(html).not.toContain("/api/documents");
|
||||
// Until the blob lands the card shows its kind icon, exactly as a card
|
||||
// with no thumbnail does — never a broken image.
|
||||
expect(html).not.toContain('data-slot="document-thumbnail"');
|
||||
expect(html).toContain("lucide-scan-line");
|
||||
});
|
||||
|
||||
it("shows the kind icon (no thumbnail image) when hasThumbnail is false", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
@@ -75,6 +76,16 @@ export function DocumentCard({
|
||||
// A preview thumbnail that fails to load (still rendering, decrypt error,
|
||||
// 404) falls back to the kind icon — never a broken image.
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
// v1.37.0 — fetched through the app transport rather than issued by the
|
||||
// browser as a subresource, so it carries the record-session assertion. The
|
||||
// null path means "this card has no thumbnail, or one already failed", which
|
||||
// is what keeps the timeline from firing a request per card for documents
|
||||
// that have nothing to show.
|
||||
const { url: thumbnailUrl, failed: thumbnailFailed } = useFencedObjectUrl(
|
||||
document.hasThumbnail && !thumbFailed
|
||||
? `/api/documents/inbound/${document.id}/thumbnail`
|
||||
: null,
|
||||
);
|
||||
|
||||
// Touch long-press selects instead of opening; the click that the
|
||||
// browser fires after the release is swallowed once.
|
||||
@@ -128,24 +139,26 @@ export function DocumentCard({
|
||||
>
|
||||
<CardContent className="flex h-full flex-col gap-2 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
{document.hasThumbnail && !thumbFailed ? (
|
||||
// Leading-edge preview tile. `loading="lazy"` + the timeline's
|
||||
// virtualization means only cards near the viewport fetch their
|
||||
// thumbnail. Decorative (alt="") — the title beside it names the
|
||||
// document; an authed same-origin subresource, never cached
|
||||
// cross-user. onError falls back to the kind icon.
|
||||
{thumbnailUrl && !thumbnailFailed ? (
|
||||
// Leading-edge preview tile. Decorative (alt="") — the title
|
||||
// beside it names the document. The laziness that `loading="lazy"`
|
||||
// used to provide now lives in the hook's null path: a card with no
|
||||
// thumbnail, or one whose thumbnail already failed, issues no
|
||||
// request at all. onError falls back to the kind icon.
|
||||
<span
|
||||
data-slot="document-thumbnail"
|
||||
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
|
||||
>
|
||||
{/* Authed, private (no-store) same-origin subresource — next/image
|
||||
would try to proxy/optimize it, which is wrong for a PHI blob
|
||||
we deliberately never cache. */}
|
||||
{/* An object URL over bytes the app transport already fetched,
|
||||
not a `/api/…` subresource: the browser issues a subresource
|
||||
itself and so cannot carry the record-session assertion, which
|
||||
the fence refuses on any session that has been inside a shared
|
||||
record. `next/image` is wrong here for the older reason too —
|
||||
it would proxy a PHI blob we deliberately never cache. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/documents/inbound/${document.id}/thumbnail`}
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setThumbFailed(true)}
|
||||
className="size-full object-cover"
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
@@ -105,7 +106,12 @@ function InlinePreview({
|
||||
title: string;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const src = `/api/documents/inbound/${documentId}/original`;
|
||||
// v1.37.0 — the bytes come through the app transport, which carries the
|
||||
// record-session assertion; a browser-issued `src="/api/…"` cannot, and the
|
||||
// fence refuses it on any session that has been inside a shared record.
|
||||
const { url: src, failed } = useFencedObjectUrl(
|
||||
`/api/documents/inbound/${documentId}/original`,
|
||||
);
|
||||
const isPdf = mimeType === "application/pdf";
|
||||
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
|
||||
// frame's `load` event — stranding keyboard events inside the embedded
|
||||
@@ -144,12 +150,23 @@ function InlinePreview({
|
||||
|
||||
return (
|
||||
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
|
||||
{!loaded ? (
|
||||
{failed ? (
|
||||
// The preview could not be fetched — refused, offline, or still
|
||||
// rendering server-side. A permanent skeleton would read as "loading
|
||||
// forever"; the sheet's own metadata and the download control below
|
||||
// still work.
|
||||
<p
|
||||
data-slot="document-preview-unavailable"
|
||||
className="text-muted-foreground py-8 text-center text-sm"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
) : !loaded || src === null ? (
|
||||
<Skeleton
|
||||
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
|
||||
/>
|
||||
) : null}
|
||||
{isPdf ? (
|
||||
{src === null || failed ? null : isPdf ? (
|
||||
// No `sandbox` attribute by design: Chromium force-downloads PDFs
|
||||
// in sandboxed frames instead of rendering them. The serve
|
||||
// response itself carries `default-src 'none'` +
|
||||
@@ -487,7 +504,13 @@ export function DocumentDetailSheet({
|
||||
|
||||
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
|
||||
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
|
||||
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
|
||||
// v1.37.0 — an object URL over transport-fetched bytes rather than a bare
|
||||
// `/api/…` href. A download navigation carries no record-session assertion,
|
||||
// so the fence refuses it once the session has been inside a shared record.
|
||||
// Null until the blob lands; the anchor is disabled rather than dead.
|
||||
const { url: originalHref } = useFencedObjectUrl(
|
||||
doc ? `/api/documents/inbound/${doc.id}/original` : null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -590,7 +613,11 @@ export function DocumentDetailSheet({
|
||||
data-slot="document-download"
|
||||
aria-label={t("documents.detail.download")}
|
||||
>
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<a
|
||||
href={originalHref ?? "#"}
|
||||
download={doc.filename ?? undefined}
|
||||
aria-disabled={originalHref === null}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.download")}
|
||||
@@ -637,7 +664,11 @@ export function DocumentDetailSheet({
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<Button asChild size="sm" className="mt-1">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<a
|
||||
href={originalHref ?? "#"}
|
||||
download={doc.filename ?? undefined}
|
||||
aria-disabled={originalHref === null}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
|
||||
Reference in new issue
Block a user