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 new file mode 100644 index 0000000..1833d8c --- /dev/null +++ b/examples/binary-files.fixture.tsx @@ -0,0 +1,168 @@ +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", + }), + ) + 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([ + { + 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: "large-file.png", content: largeFileUrl, isBinary: true }, + { + path: "large-dimensions.svg", + content: largeDimensionsUrl, + 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) + URL.revokeObjectURL(largeFileUrl) + URL.revokeObjectURL(largeDimensionsUrl) + } + }, []) + + 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": , + "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 new file mode 100644 index 0000000..15b228b --- /dev/null +++ b/src/components/BinaryFilePreview.tsx @@ -0,0 +1,117 @@ +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 [preview, setPreview] = useState<{ + source: string + url?: string + error?: "large" | "unavailable" + } | null>(null) + const downloadUrl = file.downloadUrl || 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 ( +
+

{title}

+

{message}

+ {downloadUrl && } +
+ ) + } + + return ( +
+
+ {file.path} + setPreview({ source: imageUrl!, error: "unavailable" }) + } + /> +
+ {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/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 new file mode 100644 index 0000000..6e1ac59 --- /dev/null +++ b/tests/image-preview.test.tsx @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { renderToStaticMarkup } from "react-dom/server" +import { BinaryFilePreview } from "../src/components/BinaryFilePreview" +import { getImagePreviewUrl } from "../src/utils/getImagePreviewUrl" + +const file = { + path: "board.PNG", + content: "blob:https://example.com/board", + isBinary: true, + downloadUrl: "https://example.com/download", +} + +test.each([ + file.downloadUrl, + undefined, +])("waits for size checks and keeps a download link (%s)", (downloadUrl) => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup() + 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({ ...file, path: "board.svg", isBinary: false }), + ).toBeNull() +})