Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/web/src/components/files/AttachmentFilePreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ describe("attachment HTML preview recovery", () => {
});
};

// The rendered HTML frame probes its own URL with HEAD to report a document
// that fails to load. These tests are about the source view's own requests.
const sourceFetches = () =>
vi.mocked(fetch).mock.calls.filter(([, init]) => init?.method !== "HEAD");

const toggleMode = async (label: string) => {
await act(async () => {
renderer.root.findByProps({ "aria-label": label }).props.onClick();
Expand All @@ -75,30 +80,29 @@ describe("attachment HTML preview recovery", () => {
await openRemote();
now = 61 * 60_000;
await toggleMode("Show HTML source");
expect(fetch).toHaveBeenCalledExactlyOnceWith(renewedUrl, expect.any(Object));
expect(sourceFetches()).toEqual([[renewedUrl, expect.any(Object)]]);

await toggleMode("Show rendered page");
expect(renderer.root.findByType("iframe").props.src).toBe(renewedUrl);
await toggleMode("Show HTML source");
expect(refresh).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([renewedUrl, renewedUrl]);
expect(sourceFetches().map(([url]) => url)).toEqual([renewedUrl, renewedUrl]);
});

it("does not fetch or mark an expired URL fresh when reauthorization is unavailable", async () => {
await openRemote();
now = 61 * 60_000;
refresh.mockResolvedValue(null);
await toggleMode("Show HTML source");
expect(fetch).not.toHaveBeenCalled();
expect(sourceFetches()).toEqual([]);
expect(renderer.root.findByProps({ role: "alert" }).children).toEqual([
"Reconnect to the environment and try again.",
]);

await toggleMode("Show rendered page");
await toggleMode("Show HTML source");
expect(refresh).toHaveBeenCalledTimes(3);
expect(fetch).not.toHaveBeenCalled();
expect(sourceFetches()).toEqual([]);
});

it("can return to rendered HTML after local source decoding fails", async () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/files/BrowserDocumentFrame.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { BrowserDocumentFrame, isBlockedFrameViolation } from "./BrowserDocumentFrame";

const src = "https://environment.test/api/assets/thread/page.html?signature=abc";

describe("isBlockedFrameViolation", () => {
it("matches a refused frame reported with its full URL", () => {
expect(isBlockedFrameViolation(src, { directive: "frame-src", blockedURI: src })).toBe(true);
});

it("matches a refused frame reported as a bare origin", () => {
expect(
isBlockedFrameViolation(src, {
directive: "frame-src",
blockedURI: "https://environment.test",
}),
).toBe(true);
});

it("matches the legacy directive name browsers still report", () => {
expect(isBlockedFrameViolation(src, { directive: "child-src 'self'", blockedURI: src })).toBe(
true,
);
});

it("ignores a frame served by another environment", () => {
expect(
isBlockedFrameViolation(src, {
directive: "frame-src",
blockedURI: "https://other.test/page.html",
}),
).toBe(false);
});

it("ignores violations of other directives and unparseable reports", () => {
expect(isBlockedFrameViolation(src, { directive: "img-src", blockedURI: src })).toBe(false);
expect(isBlockedFrameViolation(src, { directive: "frame-src", blockedURI: "inline" })).toBe(
false,
);
});
});

vi.mock("./fileSurfaceChrome", () => ({
FileSurfaceFailure: ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div role="alert" onClick={onRetry}>
{message}
</div>
),
}));

describe("document preview failures", () => {
let renderer: ReactTestRenderer;

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
});

afterEach(async () => {
if (renderer) await act(() => renderer.unmount());
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

const open = async (source: string) => {
await act(async () => {
renderer = create(<BrowserDocumentFrame src={source} title="page.html" pdf={false} />);
});
};

const alert = () => renderer.root.findAllByProps({ role: "alert" })[0];

it("reports a document the environment no longer serves", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("Not Found", { status: 404 })),
);
await open(src);
expect(alert()?.props.children).toBe("This document is no longer available.");
});

it("reports an environment that cannot be reached", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))),
);
await open(src);
expect(alert()?.props.children).toBe("Could not reach the environment serving this document.");
});

it("retrying clears the failure and asks the environment again", async () => {
const fetchMock = vi
.fn<() => Promise<Response>>()
.mockResolvedValueOnce(new Response("Not Found", { status: 404 }))
.mockResolvedValue(new Response("<p>ok</p>"));
vi.stubGlobal("fetch", fetchMock);
await open(src);
await act(async () => alert()?.props.onClick());
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(alert()).toBeUndefined();
expect(renderer.root.findAllByType("iframe")).toHaveLength(1);
});

it("renders a local blob document without asking the network", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await open("blob:t3code://app/9f1c");
expect(fetchMock).not.toHaveBeenCalled();
expect(alert()).toBeUndefined();
expect(renderer.root.findAllByType("iframe")).toHaveLength(1);
});
});
93 changes: 86 additions & 7 deletions apps/web/src/components/files/BrowserDocumentFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { useEffect, useState } from "react";

import { FileSurfaceFailure } from "./fileSurfaceChrome";

/**
* Chromium's viewer opens with its own toolbar, a thumbnail rail and a small
* zoom. The panel header is the only chrome we want, so ask for the page
Expand All @@ -9,28 +13,103 @@ const PDF_VIEWER_FRAGMENT = "#toolbar=0&view=FitH";
export const isPdfPreviewFile = (path: string): boolean =>
/\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");

/**
* Whether a content security policy violation reported on the host document is
* this frame being refused. A refused frame renders an error page the parent
* cannot read, so this event is the only signal that it happened. Cross-origin
* violations report `blockedURI` stripped to the origin, so the origin is as
* much as the two URLs can be compared on.
*/
export function isBlockedFrameViolation(
src: string,
violation: { readonly directive: string; readonly blockedURI: string },
): boolean {
const directive = violation.directive.split(/[\s;]/, 1)[0];
if (directive !== "frame-src" && directive !== "child-src") return false;
try {
return new URL(violation.blockedURI).origin === new URL(src).origin;
} catch {
return false;
}
}

/**
* Renders an HTML or PDF document from its URL. HTML runs in a sandboxed frame
* with an opaque origin, so a page cannot reach the app's session or storage.
* The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts.
*
* A frame that never loads leaves a blank white pane, because its error page
* belongs to the frame and not to us. Report the failure and offer a reload
* rather than showing that empty pane. Remounting on `src` and on each reload
* clears a stale failure and retries the request.
*/
export function BrowserDocumentFrame(props: {
readonly src: string;
readonly title: string;
readonly pdf: boolean;
}) {
const [reloadCount, setReloadCount] = useState(0);
return (
<DocumentFrame
key={`${props.src}#${reloadCount}`}
src={props.src}
title={props.title}
pdf={props.pdf}
onReload={() => setReloadCount((count) => count + 1)}
/>
);
}

function DocumentFrame(props: {
readonly src: string;
readonly title: string;
readonly pdf: boolean;
readonly onReload: () => void;
}) {
const [failure, setFailure] = useState<string | null>(null);

useEffect(() => {
if (typeof document === "undefined") return;
const onViolation = (event: SecurityPolicyViolationEvent) => {
const directive = event.effectiveDirective || event.violatedDirective;
if (isBlockedFrameViolation(props.src, { directive, blockedURI: event.blockedURI })) {
setFailure("This document was blocked by the app's security policy.");
}
};
document.addEventListener("securitypolicyviolation", onViolation);
return () => document.removeEventListener("securitypolicyviolation", onViolation);
}, [props.src]);

// A frame reports nothing a parent can act on: a navigation that fails does
// not fire `error`, and the error page it commits is cross-origin. Ask the
// server the same question the frame is asking, which is one extra HEAD per
// document opened. A `blob:` document is already in memory and answers only
// GET, so there is nothing to ask about it.
useEffect(() => {
if (props.src.startsWith("blob:")) return;
const controller = new AbortController();
void fetch(props.src, { method: "HEAD", signal: controller.signal })
.then((response) => {
if (!response.ok) setFailure("This document is no longer available.");
})
.catch(() => {
if (!controller.signal.aborted) {
setFailure("Could not reach the environment serving this document.");
}
});
return () => controller.abort();
}, [props.src]);

if (failure !== null) {
return <FileSurfaceFailure message={failure} onRetry={props.onReload} />;
}

const className = "min-h-0 flex-1 border-0 bg-white";
return props.pdf ? (
// oxlint-disable-next-line react/iframe-missing-sandbox
<iframe
key={props.src}
src={`${props.src}${PDF_VIEWER_FRAGMENT}`}
title={props.title}
className={className}
/>
<iframe src={`${props.src}${PDF_VIEWER_FRAGMENT}`} title={props.title} className={className} />
) : (
<iframe
key={props.src}
src={props.src}
title={props.title}
className={className}
Expand Down
Loading