diff --git a/README.md b/README.md
index 07529823..d918cac5 100644
--- a/README.md
+++ b/README.md
@@ -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` |
diff --git a/src/lib/media-src.test.ts b/src/lib/media-src.test.ts
index c0f68d56..4f40a90b 100644
--- a/src/lib/media-src.test.ts
+++ b/src/lib/media-src.test.ts
@@ -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);
}
});
@@ -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);
});
});
diff --git a/src/lib/media-src.ts b/src/lib/media-src.ts
index 82670f9b..96ebab17 100644
--- a/src/lib/media-src.ts
+++ b/src/lib/media-src.ts
@@ -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:"];
@@ -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;
diff --git a/src/ui/media-viewer.test.tsx b/src/ui/media-viewer.test.tsx
index c13fd607..c809e34e 100644
--- a/src/ui/media-viewer.test.tsx
+++ b/src/ui/media-viewer.test.tsx
@@ -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,
},
{
@@ -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",
@@ -265,6 +267,16 @@ describe("MediaViewer", () => {
slot: "media-viewer-empty",
download: false,
},
+ {
+ // Fine in an
, 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",
diff --git a/src/ui/media-viewer.tsx b/src/ui/media-viewer.tsx
index 341a46ae..e78163bb 100644
--- a/src/ui/media-viewer.tsx
+++ b/src/ui/media-viewer.tsx
@@ -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`. */
@@ -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 `