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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer",
| `ToolApproval` | Approve, approve always or deny a tool call waiting on a person |
| `GeneratedImages` | Images an agent made, with placeholders while generating |
| `Sources` | Numbered citations for an answer |
| `MediaViewer` | Pictures, video, audio, PDFs and text full size; `useMediaViewer` |
| `MediaViewer` | Pictures, video, audio and text full size; `useMediaViewer` |
| `Attachments` | Files as a stack, tiles or a list; `useAttachments` |
| `AttachmentDropZone` | Area that takes dropped files and folders; `useFileDrop` |
| `PromptInput` | The message box: `PromptInputTextarea`, `PromptInputSubmit`, `PromptInputSuggestions`; `usePromptInput` |
Expand Down
12 changes: 7 additions & 5 deletions src/lib/media-src.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("safeMediaSrc", () => {
"blob:https://example.com/0f6c1b52",
]) {
expect(safeMediaSrc(src, "media")).toBe(src);
expect(safeMediaSrc(src, "frame")).toBe(src);
expect(safeMediaSrc(src, "link")).toBe(src);
}
});

Expand All @@ -25,16 +25,18 @@ describe("safeMediaSrc", () => {
);
});

test("a frame never takes a data: URL", () => {
expect(safeMediaSrc("data:image/png;base64,AAAA", "frame")).toBe(undefined);
expect(safeMediaSrc("data:application/pdf;base64,AAAA", "frame")).toBe(
test("a link never takes a data: URL", () => {
expect(safeMediaSrc("data:image/svg+xml;base64,PHN2Zy8+", "link")).toBe(
undefined,
);
expect(safeMediaSrc("data:application/pdf;base64,AAAA", "link")).toBe(
undefined,
);
});

test("script and relative URLs are refused", () => {
expect(safeMediaSrc("javascript:alert(1)", "media")).toBe(undefined);
expect(safeMediaSrc("JavaScript:alert(1)", "frame")).toBe(undefined);
expect(safeMediaSrc("JavaScript:alert(1)", "link")).toBe(undefined);
expect(safeMediaSrc("/files/a.png", "media")).toBe(undefined);
});
});
8 changes: 4 additions & 4 deletions src/lib/media-src.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isSafeUrl } from "./url.js";

/** Where a media sink may load from. */
export type MediaSink = "media" | "frame";
export type MediaSink = "media" | "link";

const NETWORK_PROTOCOLS: readonly string[] = ["http:", "https:", "blob:"];

Expand All @@ -12,9 +12,9 @@ const DATA_MEDIA_TYPE = /^data:(image|video|audio)\/[a-z0-9.+-]+[;,]/i;
* `src` back if a media sink may load it, otherwise `undefined`.
*
* Image, video and audio elements (`"media"`) also accept a `data:` URL of
* their own kind, since they never run what they load. A frame (`"frame"`)
* takes only `http:`, `https:` and `blob:`: a `data:` document in a frame
* runs script.
* their own kind, since they never run what they load. A link (`"link"`)
* takes only `http:`, `https:` and `blob:`: it opens what it points at as a
* document, and a `data:` document, an SVG image among them, runs script.
*/
export function safeMediaSrc(src: string, sink: MediaSink): string | undefined {
if (sink === "media" && DATA_MEDIA_TYPE.test(src)) return src;
Expand Down
16 changes: 14 additions & 2 deletions src/ui/media-viewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,14 @@ describe("MediaViewer", () => {
download: true,
},
{
// Chrome shows no PDF in a sandboxed frame, and an unsandboxed one
// runs whatever the URL serves, so a PDF is a download only.
item: {
name: "report.pdf",
mediaType: "application/pdf",
src: "https://media.example.com/report.pdf",
},
slot: "media-viewer-page",
slot: "media-viewer-empty",
download: true,
},
{
Expand All @@ -256,7 +258,7 @@ describe("MediaViewer", () => {
download: true,
},
{
// A data: document would run script in the frame.
// A data: document would run script when opened.
item: {
name: "inline.pdf",
mediaType: "application/pdf",
Expand All @@ -265,6 +267,16 @@ describe("MediaViewer", () => {
slot: "media-viewer-empty",
download: false,
},
{
// Fine in an <img>, but as a link an SVG document runs script.
item: {
name: "inline.svg",
mediaType: "image/svg+xml",
src: "data:image/svg+xml;base64,PHN2Zy8+",
},
slot: "media-viewer-image",
download: false,
},
{
item: {
name: "script.pdf",
Expand Down
25 changes: 7 additions & 18 deletions src/ui/media-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export type MediaViewerItem = {
readonly mediaType: string;
/**
* Where to load it from: `http:`, `https:`, `blob:`, or a `data:` image,
* video or audio. Leave it out for an item that cannot be loaded; the
* viewer then says there is no preview and offers no download.
* video or audio (shown, but not offered for download). Leave it out for
* an item that cannot be loaded; the viewer then says there is no preview
* and offers no download.
*/
readonly src?: string;
/** What an image shows. Defaults to `name`. */
Expand All @@ -40,15 +41,15 @@ const CONTROL =
"grid size-9 shrink-0 place-items-center rounded-full bg-popover text-popover-foreground shadow-sm transition-colors hover:bg-muted [&_svg]:size-4";

/**
* Pictures, video, audio, PDFs and text full size, over everything, on the
* Pictures, video, audio and text full size, over everything, on the
* native modal `<dialog>`: it holds focus and closes on Escape, and a click
* outside the media closes it too. The page behind it does not scroll. With
* more than one item, the arrow keys and the Previous and Next buttons step
* through them, stopping at either end. Download opens a new tab where the
* browser will not download the file. On close, focus goes back to the
* element given to `viewer.open`, or else to whatever had it when the viewer
* opened. Escape pressed inside a PDF goes to the browser's PDF viewer, not
* the dialog; Tab leaves the PDF and reaches Close.
* opened. A PDF has no preview, only Download: Chrome will not show one in
* a sandboxed frame, and an unsandboxed frame runs whatever its URL serves.
*
* Renders nothing while `viewer` is closed. A `ref` gets the `<dialog>`
* while it is open.
Expand Down Expand Up @@ -142,7 +143,7 @@ function MediaViewerDialog({
const count = viewer.items.length;
const many = count > 1;
const position = many ? `${index + 1} of ${count}` : null;
const download = safeItemSrc(item, "media");
const download = safeItemSrc(item, "link");

return (
<dialog
Expand Down Expand Up @@ -344,18 +345,6 @@ function MediaViewerContent({ item }: { readonly item: MediaViewerItem }) {
/>
);
}
const page = safeItemSrc(item, "frame");
if (kind === "pdf" && page !== undefined) {
return (
<iframe
key={page}
src={page}
title={item.name}
data-slot="media-viewer-page"
className={PAGE_FRAME}
/>
);
}
return (
<p
data-slot="media-viewer-empty"
Expand Down
Loading