From d55d3020ec7370423ded900a7e3e007199f5e390 Mon Sep 17 00:00:00 2001 From: Rushabh Patil Date: Mon, 12 Oct 2026 16:51:17 +0530 Subject: [PATCH 1/3] Preview binary images in the workspace editor with examples --- README.md | 31 ++++++ examples/binary-files.fixture.tsx | 143 +++++++++++++++++++++++++ src/components/BinaryFilePreview.tsx | 69 ++++++++++++ src/components/WorkspaceCodeEditor.tsx | 35 ++---- src/utils/getImagePreviewUrl.ts | 16 +++ tests/image-preview.test.tsx | 70 ++++++++++++ 6 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 examples/binary-files.fixture.tsx create mode 100644 src/components/BinaryFilePreview.tsx create mode 100644 src/utils/getImagePreviewUrl.ts create mode 100644 tests/image-preview.test.tsx diff --git a/README.md b/README.md index 92bfd67..01ceb6e 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,34 @@ The ref exposes: Press `Ctrl+P` or `Cmd+P` to fuzzy-search workspace files from the keyboard. Press `Ctrl+Shift+F` or `Cmd+Shift+F` to find and replace text across files. + +Binary image files (PNG, JPEG, GIF, WebP, AVIF, BMP, ICO, and SVG) open in an +image preview with a download link. Set `isBinary: true` and provide a blob URL +or image data URL in `content`, or a `downloadUrl` when no local URL is available. +Text SVG files remain editable. Other binary files show a download notice. + +```tsx +const files: EditorFile[] = [ + { + path: "board.png", + content: URL.createObjectURL(pngBlob), + isBinary: true, + downloadUrl: "/files/board.png", + }, + { + path: "photo.jpg", + content: "data:image/jpeg;base64,...", + isBinary: true, + }, +] +``` + +The file loader owns blob URLs and should revoke them when they are no longer +needed. The preview prefers `content` so authenticated files do not need a +second download request. + +Run `bun run dev` and open the `binary-files` fixtures to try PNG blob URLs, +JPEG data URLs, download URLs, failed and missing images, unsupported binary +files, and editable SVG source. Each fixture includes a file sidebar so you +can switch between images and code. Sample images are generated locally; +the examples do not need a network connection. diff --git a/examples/binary-files.fixture.tsx b/examples/binary-files.fixture.tsx new file mode 100644 index 0000000..66089d2 --- /dev/null +++ b/examples/binary-files.fixture.tsx @@ -0,0 +1,143 @@ +import "../src/styles.css" +import { useEffect, useState } from "react" +import { + type EditorFile, + WorkspaceCodeEditor, +} from "../src/components/WorkspaceCodeEditor" +import { useWorkspaceFiles } from "../src/hooks/useWorkspaceFiles" + +function createImageDataUrls() { + const canvas = document.createElement("canvas") + canvas.width = 1200 + canvas.height = 720 + const context = canvas.getContext("2d")! + + // Leave a transparent margin to demonstrate the preview's checkerboard. + context.fillStyle = "#186c43" + context.fillRect(100, 100, 1000, 520) + context.strokeStyle = "#e7c66b" + context.lineWidth = 16 + context.strokeRect(180, 180, 840, 360) + context.fillStyle = "#162e26" + context.fillRect(440, 240, 320, 240) + context.fillStyle = "#ffffff" + context.font = "24px sans-serif" + context.textAlign = "center" + context.fillText("Sample board", 600, 368) + + const png = canvas.toDataURL("image/png") + // JPEG has no alpha channel; give it an opaque background. + context.globalCompositeOperation = "destination-over" + context.fillStyle = "#f8fafc" + context.fillRect(0, 0, canvas.width, canvas.height) + const jpeg = canvas.toDataURL("image/jpeg") + + return { png, jpeg } +} + +function BinaryFilesFixture({ + initialCurrentFile, +}: { + initialCurrentFile: string +}) { + const [files, setFiles] = useState(null) + + useEffect(() => { + const { png, jpeg } = createImageDataUrls() + const pngBytes = Uint8Array.from(atob(png.split(",")[1]!), (char) => + char.charCodeAt(0), + ) + const blobUrl = URL.createObjectURL( + new Blob([pngBytes], { type: "image/png" }), + ) + const stepUrl = URL.createObjectURL( + new Blob(["ISO-10303-21;\nEND-ISO-10303-21;"], { + type: "application/octet-stream", + }), + ) + + setFiles([ + { + path: "board.PNG", + content: blobUrl, + isBinary: true, + downloadUrl: png, + }, + { path: "board.jpg", content: jpeg, isBinary: true }, + { + path: "download-only.png", + content: "", + isBinary: true, + downloadUrl: png, + }, + { + path: "broken.png", + content: "data:image/png;base64,broken", + isBinary: true, + downloadUrl: png, + }, + { path: "missing.png", content: "", isBinary: true }, + { + path: "board.step", + content: stepUrl, + isBinary: true, + downloadUrl: stepUrl, + }, + { + path: "board.svg", + content: + '\n \n\n', + }, + { + path: "index.tsx", + content: 'export const message = "Switch between images and code"\n', + }, + ]) + + return () => { + URL.revokeObjectURL(blobUrl) + URL.revokeObjectURL(stepUrl) + } + }, []) + + if (!files) return null + + return ( + + ) +} + +function ExampleWorkspace({ + initialFiles, + initialCurrentFile, +}: { + initialFiles: EditorFile[] + initialCurrentFile: string +}) { + const workspace = useWorkspaceFiles({ initialFiles, initialCurrentFile }) + + return ( +
+ +
+ ) +} + +export default { + "PNG blob URL": , + "JPEG data URL": , + "PNG download URL": ( + + ), + "Failed image": , + "Missing image URL": , + "Unsupported binary": , + "Editable SVG": , +} diff --git a/src/components/BinaryFilePreview.tsx b/src/components/BinaryFilePreview.tsx new file mode 100644 index 0000000..6bdd851 --- /dev/null +++ b/src/components/BinaryFilePreview.tsx @@ -0,0 +1,69 @@ +import { useState } from "react" +import { getImagePreviewUrl } from "../utils/getImagePreviewUrl" +import type { EditorFile } from "./WorkspaceCodeEditor" + +export function BinaryFilePreview({ file }: { file: EditorFile }) { + const imageUrl = getImagePreviewUrl(file) + const [failedImageUrl, setFailedImageUrl] = useState(null) + const downloadUrl = file.downloadUrl || imageUrl + + if (!imageUrl || failedImageUrl === imageUrl) { + return ( +
+

+ {imageUrl ? "Image preview unavailable" : "Binary file"} +

+

+ {imageUrl + ? "This image could not be loaded." + : "This file cannot be displayed in the editor."} +

+ {downloadUrl && } +
+ ) + } + + return ( +
+
+ {file.path} setFailedImageUrl(imageUrl)} + /> +
+ {downloadUrl && ( +
+ +
+ )} +
+ ) +} + +function DownloadLink({ url, path }: { url: string; path: string }) { + return ( + + Download file + + ) +} diff --git a/src/components/WorkspaceCodeEditor.tsx b/src/components/WorkspaceCodeEditor.tsx index 9e4c763..d73d63e 100644 --- a/src/components/WorkspaceCodeEditor.tsx +++ b/src/components/WorkspaceCodeEditor.tsx @@ -29,6 +29,7 @@ import { applyWorkspaceReplacements, type WorkspaceSearchMatch, } from "../utils/workspaceSearch" +import { BinaryFilePreview } from "./BinaryFilePreview" import { Breadcrumbs } from "./Breadcrumbs" import { FileSidebar } from "./FileSidebar" import { QuickOpen } from "./QuickOpen" @@ -37,6 +38,7 @@ import { WorkspaceSearch } from "./WorkspaceSearch" export type EditorFile = { path: string + /** Text content, or a blob/data URL for a binary file. */ content: string isBinary?: boolean downloadUrl?: string @@ -251,16 +253,19 @@ export const WorkspaceCodeEditor = forwardRef< let editorBody: React.ReactNode - if (isStreaming) { + if (currentEditorFile?.isBinary) { + editorBody = ( + + ) + } else if (isStreaming) { editorBody = (
         {currentContent}
       
) - } else if (currentFileIsBinary) { - editorBody = ( - - ) } else if (!isReady) { editorBody = Loading editor… } else if (currentFile) { @@ -378,23 +383,3 @@ function CenteredMessage({ children }: { children: React.ReactNode }) { ) } - -function BinaryFileNotice({ downloadUrl }: { downloadUrl?: string }) { - return ( -
-

Binary file

-

- This file cannot be displayed in the editor. -

- {downloadUrl && ( - - Download file - - )} -
- ) -} diff --git a/src/utils/getImagePreviewUrl.ts b/src/utils/getImagePreviewUrl.ts new file mode 100644 index 0000000..dab45bc --- /dev/null +++ b/src/utils/getImagePreviewUrl.ts @@ -0,0 +1,16 @@ +import type { EditorFile } from "../components/WorkspaceCodeEditor" + +export function getImagePreviewUrl(file: EditorFile): string | null { + if ( + !file.isBinary || + !/\.(png|apng|jpe?g|gif|webp|avif|bmp|ico|svg)$/i.test(file.path) + ) { + return null + } + + // Package loaders put the already-fetched binary in a blob URL. Prefer it + // because fetching downloadUrl again may require authorization headers. + if (/^(blob:|data:image\/)/i.test(file.content)) return file.content + + return file.downloadUrl || null +} diff --git a/tests/image-preview.test.tsx b/tests/image-preview.test.tsx new file mode 100644 index 0000000..b8584f0 --- /dev/null +++ b/tests/image-preview.test.tsx @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test" +import { renderToStaticMarkup } from "react-dom/server" +import { BinaryFilePreview } from "../src/components/BinaryFilePreview" +import type { EditorFile } from "../src/components/WorkspaceCodeEditor" +import { getImagePreviewUrl } from "../src/utils/getImagePreviewUrl" + +const pngFile: EditorFile = { + path: "assets/board.png", + content: "blob:https://example.com/board-image", + isBinary: true, + downloadUrl: "https://example.com/download?file=board", +} + +test("binary PNG preview uses already-fetched content instead of the authenticated download endpoint", () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain(`src="${pngFile.content}"`) + expect(markup).toContain('alt="assets/board.png"') + expect(markup).toContain(`href="${pngFile.downloadUrl}"`) + expect(markup).toContain('download="board.png"') + expect(markup).not.toContain("This file cannot be displayed") +}) + +test("binary images support data URLs, uppercase extensions, and a download URL fallback", () => { + const dataUrl = "data:image/png;base64,iVBORw0KGgo=" + expect(getImagePreviewUrl({ ...pngFile, content: dataUrl })).toBe(dataUrl) + expect(getImagePreviewUrl({ ...pngFile, path: "BOARD.PNG" })).toBe( + pngFile.content, + ) + expect(getImagePreviewUrl({ ...pngFile, content: "" })).toBe( + pngFile.downloadUrl!, + ) +}) + +test("blob-only images keep a usable download link", () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain(`src="${pngFile.content}"`) + expect(markup).toContain(`href="${pngFile.content}"`) +}) + +test("unsupported binary files retain the binary notice and download link", () => { + const file = { ...pngFile, path: "models/board.step" } + const markup = renderToStaticMarkup() + + expect(getImagePreviewUrl(file)).toBeNull() + expect(markup).toContain("This file cannot be displayed in the editor.") + expect(markup).toContain(`href="${pngFile.downloadUrl}"`) + expect(markup).not.toContain(" { + const file = { ...pngFile, content: "", downloadUrl: undefined } + const markup = renderToStaticMarkup() + + expect(getImagePreviewUrl(file)).toBeNull() + expect(markup).toContain("Binary file") + expect(markup).not.toContain("", + isBinary: false, + }), + ).toBeNull() +}) From 446be9d3ce8462c077756aab809e4a8a02fde876 Mon Sep 17 00:00:00 2001 From: Rushabh Patil Date: Mon, 12 Oct 2026 17:17:21 +0530 Subject: [PATCH 2/3] Remove README additions and simplify image preview tests --- README.md | 31 --------------- tests/image-preview.test.tsx | 73 +++++++++++------------------------- 2 files changed, 22 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 01ceb6e..92bfd67 100644 --- a/README.md +++ b/README.md @@ -64,34 +64,3 @@ The ref exposes: Press `Ctrl+P` or `Cmd+P` to fuzzy-search workspace files from the keyboard. Press `Ctrl+Shift+F` or `Cmd+Shift+F` to find and replace text across files. - -Binary image files (PNG, JPEG, GIF, WebP, AVIF, BMP, ICO, and SVG) open in an -image preview with a download link. Set `isBinary: true` and provide a blob URL -or image data URL in `content`, or a `downloadUrl` when no local URL is available. -Text SVG files remain editable. Other binary files show a download notice. - -```tsx -const files: EditorFile[] = [ - { - path: "board.png", - content: URL.createObjectURL(pngBlob), - isBinary: true, - downloadUrl: "/files/board.png", - }, - { - path: "photo.jpg", - content: "data:image/jpeg;base64,...", - isBinary: true, - }, -] -``` - -The file loader owns blob URLs and should revoke them when they are no longer -needed. The preview prefers `content` so authenticated files do not need a -second download request. - -Run `bun run dev` and open the `binary-files` fixtures to try PNG blob URLs, -JPEG data URLs, download URLs, failed and missing images, unsupported binary -files, and editable SVG source. Each fixture includes a file sidebar so you -can switch between images and code. Sample images are generated locally; -the examples do not need a network connection. diff --git a/tests/image-preview.test.tsx b/tests/image-preview.test.tsx index b8584f0..d543854 100644 --- a/tests/image-preview.test.tsx +++ b/tests/image-preview.test.tsx @@ -1,70 +1,41 @@ import { expect, test } from "bun:test" import { renderToStaticMarkup } from "react-dom/server" import { BinaryFilePreview } from "../src/components/BinaryFilePreview" -import type { EditorFile } from "../src/components/WorkspaceCodeEditor" import { getImagePreviewUrl } from "../src/utils/getImagePreviewUrl" -const pngFile: EditorFile = { - path: "assets/board.png", - content: "blob:https://example.com/board-image", +const file = { + path: "board.PNG", + content: "blob:https://example.com/board", isBinary: true, - downloadUrl: "https://example.com/download?file=board", + downloadUrl: "https://example.com/download", } -test("binary PNG preview uses already-fetched content instead of the authenticated download endpoint", () => { - const markup = renderToStaticMarkup() - - expect(markup).toContain(`src="${pngFile.content}"`) - expect(markup).toContain('alt="assets/board.png"') - expect(markup).toContain(`href="${pngFile.downloadUrl}"`) - expect(markup).toContain('download="board.png"') - expect(markup).not.toContain("This file cannot be displayed") -}) - -test("binary images support data URLs, uppercase extensions, and a download URL fallback", () => { - const dataUrl = "data:image/png;base64,iVBORw0KGgo=" - expect(getImagePreviewUrl({ ...pngFile, content: dataUrl })).toBe(dataUrl) - expect(getImagePreviewUrl({ ...pngFile, path: "BOARD.PNG" })).toBe( - pngFile.content, - ) - expect(getImagePreviewUrl({ ...pngFile, content: "" })).toBe( - pngFile.downloadUrl!, - ) -}) - -test("blob-only images keep a usable download link", () => { +test.each([ + file.downloadUrl, + undefined, +])("previews PNG content and keeps a download link (%s)", (downloadUrl) => { const markup = renderToStaticMarkup( - , + , ) - - expect(markup).toContain(`src="${pngFile.content}"`) - expect(markup).toContain(`href="${pngFile.content}"`) + expect(markup).toContain(`src="${file.content}"`) + expect(markup).toContain(`href="${downloadUrl ?? file.content}"`) }) -test("unsupported binary files retain the binary notice and download link", () => { - const file = { ...pngFile, path: "models/board.step" } +test.each([ + { ...file, path: "board.step" }, + { ...file, content: "", downloadUrl: undefined }, +])("shows the binary notice when there is no preview (%j)", (file) => { const markup = renderToStaticMarkup() - - expect(getImagePreviewUrl(file)).toBeNull() - expect(markup).toContain("This file cannot be displayed in the editor.") - expect(markup).toContain(`href="${pngFile.downloadUrl}"`) + expect(markup).toContain("Binary file") expect(markup).not.toContain(" { - const file = { ...pngFile, content: "", downloadUrl: undefined } - const markup = renderToStaticMarkup() - - expect(getImagePreviewUrl(file)).toBeNull() - expect(markup).toContain("Binary file") - expect(markup).not.toContain(" { + const dataUrl = "data:image/png;base64,iVBORw0KGgo=" + expect(getImagePreviewUrl({ ...file, content: dataUrl })).toBe(dataUrl) + expect(getImagePreviewUrl({ ...file, content: "" })).toBe(file.downloadUrl) expect( - getImagePreviewUrl({ - ...pngFile, - path: "board.svg", - content: "", - isBinary: false, - }), + getImagePreviewUrl({ ...file, path: "board.svg", isBinary: false }), ).toBeNull() }) From 40767618b9f9d3f47f6369d6c6f812e06fe2d431 Mon Sep 17 00:00:00 2001 From: Rushabh Patil Date: Mon, 12 Oct 2026 18:12:56 +0530 Subject: [PATCH 3/3] Check image bytes and dimensions before creating previews --- bun.lock | 3 ++ examples/binary-files.fixture.tsx | 25 ++++++++++ package.json | 1 + src/components/BinaryFilePreview.tsx | 74 +++++++++++++++++++++++----- src/utils/loadImagePreview.ts | 66 +++++++++++++++++++++++++ tests/image-preview-limits.test.ts | 65 ++++++++++++++++++++++++ tests/image-preview.test.tsx | 5 +- 7 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 src/utils/loadImagePreview.ts create mode 100644 tests/image-preview-limits.test.ts diff --git a/bun.lock b/bun.lock index 12fa8ef..c21f1b9 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@typescript/ata": "^0.9.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "image-size": "^2.0.2", "lucide-react": "^1.21.0", "monaco-editor": "^0.55.1", "radix-ui": "^1.6.0", @@ -1036,6 +1037,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "image-size": ["image-size@2.0.4", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], diff --git a/examples/binary-files.fixture.tsx b/examples/binary-files.fixture.tsx index 66089d2..1833d8c 100644 --- a/examples/binary-files.fixture.tsx +++ b/examples/binary-files.fixture.tsx @@ -55,6 +55,19 @@ function BinaryFilesFixture({ type: "application/octet-stream", }), ) + const largeFileUrl = URL.createObjectURL( + new Blob([pngBytes, new Uint8Array(5 * 1024 * 1024)], { + type: "image/png", + }), + ) + const largeDimensionsUrl = URL.createObjectURL( + new Blob( + [ + '', + ], + { type: "image/svg+xml" }, + ), + ) setFiles([ { @@ -77,6 +90,12 @@ function BinaryFilesFixture({ downloadUrl: png, }, { path: "missing.png", content: "", isBinary: true }, + { path: "large-file.png", content: largeFileUrl, isBinary: true }, + { + path: "large-dimensions.svg", + content: largeDimensionsUrl, + isBinary: true, + }, { path: "board.step", content: stepUrl, @@ -97,6 +116,8 @@ function BinaryFilesFixture({ return () => { URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(stepUrl) + URL.revokeObjectURL(largeFileUrl) + URL.revokeObjectURL(largeDimensionsUrl) } }, []) @@ -140,4 +161,8 @@ export default { "Missing image URL": , "Unsupported binary": , "Editable SVG": , + "Large file": , + "Large dimensions": ( + + ), } diff --git a/package.json b/package.json index d60ce4e..b5a1812 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@typescript/ata": "^0.9.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "image-size": "^2.0.2", "lucide-react": "^1.21.0", "monaco-editor": "^0.55.1", "radix-ui": "^1.6.0", diff --git a/src/components/BinaryFilePreview.tsx b/src/components/BinaryFilePreview.tsx index 6bdd851..15b228b 100644 --- a/src/components/BinaryFilePreview.tsx +++ b/src/components/BinaryFilePreview.tsx @@ -1,23 +1,68 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { getImagePreviewUrl } from "../utils/getImagePreviewUrl" +import { + ImagePreviewLimitError, + loadImagePreview, +} from "../utils/loadImagePreview" import type { EditorFile } from "./WorkspaceCodeEditor" export function BinaryFilePreview({ file }: { file: EditorFile }) { const imageUrl = getImagePreviewUrl(file) - const [failedImageUrl, setFailedImageUrl] = useState(null) + const [preview, setPreview] = useState<{ + source: string + url?: string + error?: "large" | "unavailable" + } | null>(null) const downloadUrl = file.downloadUrl || imageUrl - if (!imageUrl || failedImageUrl === imageUrl) { + useEffect(() => { + if (!imageUrl) return + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + let active = true + let objectUrl: string | undefined + void loadImagePreview(imageUrl, controller.signal) + .then((blob) => { + if (!active) return + objectUrl = URL.createObjectURL(blob) + setPreview({ source: imageUrl, url: objectUrl }) + }) + .catch((error) => { + if (!active) return + setPreview({ + source: imageUrl, + error: + error instanceof ImagePreviewLimitError ? "large" : "unavailable", + }) + }) + .finally(() => clearTimeout(timeout)) + return () => { + active = false + clearTimeout(timeout) + controller.abort() + if (objectUrl) URL.revokeObjectURL(objectUrl) + } + }, [imageUrl]) + + const currentPreview = preview?.source === imageUrl ? preview : null + if (!currentPreview?.url) { + const status = !imageUrl ? "binary" : (currentPreview?.error ?? "loading") + const [title, message] = { + binary: ["Binary file", "This file cannot be displayed in the editor."], + large: [ + "Image too large", + "This image is too large to preview. Download it to view.", + ], + unavailable: [ + "Image preview unavailable", + "This image could not be loaded.", + ], + loading: ["Loading image preview…", "Checking image size…"], + }[status] return (
-

- {imageUrl ? "Image preview unavailable" : "Binary file"} -

-

- {imageUrl - ? "This image could not be loaded." - : "This file cannot be displayed in the editor."} -

+

{title}

+

{message}

{downloadUrl && }
) @@ -35,8 +80,9 @@ export function BinaryFilePreview({ file }: { file: EditorFile }) { }} > {file.path} setFailedImageUrl(imageUrl)} + onError={() => + setPreview({ source: imageUrl!, error: "unavailable" }) + } /> {downloadUrl && ( diff --git a/src/utils/loadImagePreview.ts b/src/utils/loadImagePreview.ts new file mode 100644 index 0000000..992cd9b --- /dev/null +++ b/src/utils/loadImagePreview.ts @@ -0,0 +1,66 @@ +import { imageSize } from "image-size" + +export const MAX_IMAGE_PREVIEW_BYTES = 5 * 1024 * 1024 +export const MAX_IMAGE_PREVIEW_PIXELS = 16_000_000 +export class ImagePreviewLimitError extends Error {} + +export async function loadImagePreview( + url: string, + signal: AbortSignal, + fetchImage: (url: string, options: RequestInit) => Promise = fetch, +): Promise { + // Avoid decoding an oversized data URL inside fetch before checking bytes. + if (/^data:/i.test(url)) { + const base64 = url.slice(0, url.indexOf(",")).endsWith(";base64") + const maxLength = MAX_IMAGE_PREVIEW_BYTES * (base64 ? 4 / 3 : 3) + 1024 + if (url.length > maxLength) throw new ImagePreviewLimitError() + } + + const response = await fetchImage(url, { signal }) + if (!response.ok || !response.body) throw new Error("Image request failed") + if ( + Number(response.headers.get("content-length")) > MAX_IMAGE_PREVIEW_BYTES + ) { + await response.body.cancel() + throw new ImagePreviewLimitError() + } + + const reader = response.body.getReader() + const chunks: BlobPart[] = [] + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > MAX_IMAGE_PREVIEW_BYTES) { + await reader.cancel() + throw new ImagePreviewLimitError() + } + chunks.push(value) + } + } finally { + reader.releaseLock() + } + + signal.throwIfAborted() + const blob = new Blob(chunks, { + type: response.headers.get("content-type") || "application/octet-stream", + }) + // Read encoded headers, never decode pixels just to discover dimensions. + const bytes = new Uint8Array(await blob.arrayBuffer()) + signal.throwIfAborted() + const dimensions = imageSize(bytes) + for (const { width, height } of [dimensions, ...(dimensions.images ?? [])]) { + if (!Number.isFinite(width * height) || width <= 0 || height <= 0) { + throw new Error("Invalid image dimensions") + } + if ( + width * height > MAX_IMAGE_PREVIEW_PIXELS || + Math.max(width, height) > 8192 + ) { + throw new ImagePreviewLimitError() + } + } + return blob +} diff --git a/tests/image-preview-limits.test.ts b/tests/image-preview-limits.test.ts new file mode 100644 index 0000000..0218a50 --- /dev/null +++ b/tests/image-preview-limits.test.ts @@ -0,0 +1,65 @@ +import { expect, mock, test } from "bun:test" +import { + ImagePreviewLimitError, + loadImagePreview, + MAX_IMAGE_PREVIEW_BYTES, +} from "../src/utils/loadImagePreview" + +function pngHeader(width: number, height: number) { + const bytes = new Uint8Array(24) + bytes.set([137, 80, 78, 71, 13, 10, 26, 10]) + bytes.set([73, 72, 68, 82], 12) + const view = new DataView(bytes.buffer) + view.setUint32(16, width) + view.setUint32(20, height) + return bytes +} + +function load(response: Response) { + return loadImagePreview( + "/image.png", + new AbortController().signal, + async () => response, + ) +} + +test("accepts small image headers without decoding pixels", async () => { + expect((await load(new Response(pngHeader(1200, 720)))).size).toBe(24) +}) + +test.each([ + [8192, 8192], + [100_000, 1], +])("rejects huge dimensions in tiny PNG files (%s × %s)", async (w, h) => { + await expect(load(new Response(pngHeader(w!, h!)))).rejects.toBeInstanceOf( + ImagePreviewLimitError, + ) +}) + +test.each([ + true, + false, +])("cancels oversized responses (size header: %s)", async (hasSize) => { + const cancel = mock(() => {}) + const body = new ReadableStream({ + pull: (controller) => + controller.enqueue(new Uint8Array(MAX_IMAGE_PREVIEW_BYTES + 1)), + cancel, + }) + const headers = hasSize + ? { "content-length": String(MAX_IMAGE_PREVIEW_BYTES + 1) } + : undefined + await expect(load(new Response(body, { headers }))).rejects.toBeInstanceOf( + ImagePreviewLimitError, + ) + expect(cancel).toHaveBeenCalled() +}) + +test("rejects oversized data URLs before fetch decodes them", async () => { + const fetchImage = mock(async () => new Response()) + const url = `data:image/png;base64,${"A".repeat(MAX_IMAGE_PREVIEW_BYTES * 2)}` + await expect( + loadImagePreview(url, new AbortController().signal, fetchImage), + ).rejects.toBeInstanceOf(ImagePreviewLimitError) + expect(fetchImage).not.toHaveBeenCalled() +}) diff --git a/tests/image-preview.test.tsx b/tests/image-preview.test.tsx index d543854..6e1ac59 100644 --- a/tests/image-preview.test.tsx +++ b/tests/image-preview.test.tsx @@ -13,11 +13,12 @@ const file = { test.each([ file.downloadUrl, undefined, -])("previews PNG content and keeps a download link (%s)", (downloadUrl) => { +])("waits for size checks and keeps a download link (%s)", (downloadUrl) => { const markup = renderToStaticMarkup( , ) - expect(markup).toContain(`src="${file.content}"`) + expect(markup).not.toContain("