From d34e16881c88c1f50c04c4810e68f0c15e99c42c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 03:30:36 +0000 Subject: [PATCH 01/12] feat(web): preview archives, sqlite databases, and decoded images --- apps/web/package.json | 3 + .../files/AttachmentFilePreview.tsx | 14 +- .../components/files/BinaryFilePreview.tsx | 17 + .../web/src/components/files/BinaryViewer.tsx | 356 ++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 39 +- .../web/src/components/files/PreviewImage.tsx | 56 +++ .../components/files/binaryPreview.worker.ts | 202 ++++++++++ .../components/files/binaryPreviewProtocol.ts | 24 ++ apps/web/src/components/files/boundedZip.ts | 78 ++++ .../components/files/decodeImagePreview.ts | 30 ++ .../components/files/imageDecode.worker.ts | 165 ++++++++ .../components/files/officePreview.worker.ts | 49 +-- .../src/components/files/readBinaryPreview.ts | 24 ++ .../src/components/media/AttachmentImage.tsx | 42 +-- packages/shared/src/filePreview.test.ts | 34 +- packages/shared/src/filePreview.ts | 47 ++- pnpm-lock.yaml | 59 ++- third-party-licenses.config.json | 15 + 18 files changed, 1142 insertions(+), 112 deletions(-) create mode 100644 apps/web/src/components/files/BinaryFilePreview.tsx create mode 100644 apps/web/src/components/files/BinaryViewer.tsx create mode 100644 apps/web/src/components/files/PreviewImage.tsx create mode 100644 apps/web/src/components/files/binaryPreview.worker.ts create mode 100644 apps/web/src/components/files/binaryPreviewProtocol.ts create mode 100644 apps/web/src/components/files/boundedZip.ts create mode 100644 apps/web/src/components/files/decodeImagePreview.ts create mode 100644 apps/web/src/components/files/imageDecode.worker.ts create mode 100644 apps/web/src/components/files/readBinaryPreview.ts diff --git a/apps/web/package.json b/apps/web/package.json index 41789655dd85..59638e53a9b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -73,7 +73,9 @@ "react-markdown": "^10.1.0", "read-excel-file": "9.3.12", "remark-gfm": "catalog:", + "sql.js": "1.15.0", "tailwind-merge": "^3.4.0", + "tiff": "7.1.3", "unbash": "^5.0.0", "use-sync-external-store": "^1.6.0", "zustand": "^5.0.11" @@ -88,6 +90,7 @@ "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/sql.js": "1.4.11", "@types/use-sync-external-store": "^0.0.6", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.1.2", diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index c8b40523b4dd..f0eaf8ed832a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -23,6 +23,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { BinaryFilePreview } from "./BinaryFilePreview"; +import { PreviewImage } from "./PreviewImage"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; @@ -248,6 +250,8 @@ export function AttachmentFilePreview(props: { ) : ( ) + ) : kind === "archive" || kind === "sqlite" ? ( + ) : kind === "font" ? ( ) : officeKind ? ( @@ -274,15 +278,7 @@ export function AttachmentFilePreview(props: { /> ) : kind === "image" ? ( - // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls. -
- {props.name} setError("Unable to load image.")} - /> -
+ ) : (

No preview for this file

diff --git a/apps/web/src/components/files/BinaryFilePreview.tsx b/apps/web/src/components/files/BinaryFilePreview.tsx new file mode 100644 index 000000000000..e20f259a8755 --- /dev/null +++ b/apps/web/src/components/files/BinaryFilePreview.tsx @@ -0,0 +1,17 @@ +import { lazy, Suspense } from "react"; + +import { FileSurfaceLoading } from "./fileSurfaceChrome"; + +const BinaryViewer = lazy(() => import("./BinaryViewer")); + +export function BinaryFilePreview(props: { + readonly src: string; + readonly name: string; + readonly kind: "archive" | "sqlite"; +}) { + return ( + }> + + + ); +} diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx new file mode 100644 index 000000000000..79b56d08c8d2 --- /dev/null +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -0,0 +1,356 @@ +/* oxlint-disable react/no-array-index-key -- Read-only cells retain their database row and column positions. */ +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { ChevronLeftIcon, ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; + +import { + FileSurfaceAction, + FileSurfaceFailure, + FileSurfaceLoading, + FileSurfaceNotice, +} from "./fileSurfaceChrome"; +import { + SQLITE_PAGE_SIZE, + type BinaryWorkerRequest, + type BinaryWorkerResult, +} from "./binaryPreviewProtocol"; +import { PreviewImage } from "./PreviewImage"; +import { readBinaryPreview } from "./readBinaryPreview"; + +const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); +type Archive = Extract; +type Database = Extract; +type Rows = Extract; + +function ArchiveFile(props: { readonly path: string; readonly bytes: Uint8Array }) { + const [url, setUrl] = useState(null); + const kind = filePreviewKind({ name: props.path }); + const text = (() => { + if (!["text", "markdown", "html", "mermaid"].includes(kind)) return null; + try { + return decodeFilePreviewText(props.bytes); + } catch { + return null; + } + })(); + useEffect(() => { + if (kind !== "image") return; + const extension = props.path.slice(props.path.lastIndexOf(".")); + const objectUrl = URL.createObjectURL( + new Blob([props.bytes], { + type: hostPreviewMimeTypeFromExtension(extension) ?? "application/octet-stream", + }), + ); + // oxlint-disable-next-line react/set-state-in-effect -- Publish a Blob URL only after registering its cleanup. + setUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [props.bytes, props.path, kind]); + if (kind === "image") + return url ? : ; + if (!text) + return ( + + ); + return ( + <> + {text.truncated ? ( + Preview limited to the first 1 MB. + ) : null} + }> + + + + ); +} + +export default function BinaryViewer(props: { + readonly src: string; + readonly name: string; + readonly kind: "archive" | "sqlite"; +}) { + const [archive, setArchive] = useState(null); + const [database, setDatabase] = useState(null); + const [rows, setRows] = useState(null); + const [entry, setEntry] = useState | null>(null); + const [folder, setFolder] = useState(""); + const [selectedPath, setSelectedPath] = useState(null); + const [selectedTable, setSelectedTable] = useState(""); + const [page, setPage] = useState(0); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + const workerRef = useRef(null); + const timerRef = useRef | null>(null); + const send = (request: BinaryWorkerRequest) => { + setBusy(true); + setError(null); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setError("This file took too long to preview. Close it and try again."); + setBusy(false); + workerRef.current?.terminate(); + workerRef.current = null; + }, 15_000); + workerRef.current?.postMessage(request); + }; + useEffect(() => { + const controller = new AbortController(); + const worker = new Worker(new URL("./binaryPreview.worker.ts", import.meta.url), { + type: "module", + }); + workerRef.current = worker; + const fail = (message: string) => { + if (controller.signal.aborted) return; + setError(message); + setBusy(false); + controller.abort(); + worker.terminate(); + workerRef.current = null; + if (timerRef.current) clearTimeout(timerRef.current); + }; + timerRef.current = setTimeout( + () => fail("This file took too long to preview. Close it and try again."), + 30_000, + ); + worker.addEventListener("error", () => + fail("Could not load the file viewer. Close it and try again."), + ); + worker.addEventListener("message", (event: MessageEvent) => { + if (controller.signal.aborted) return; + if (timerRef.current) clearTimeout(timerRef.current); + const result = event.data; + setBusy(false); + if (result.type === "error") setError(result.message); + else if (result.type === "archive") setArchive(result); + else if (result.type === "entry") setEntry(result); + else if (result.type === "rows") setRows(result); + else { + setDatabase(result); + const table = result.tables[0]; + if (table) { + setSelectedTable(table.name); + send({ type: "rows", table: table.name, page: 0 }); + } + } + }); + void readBinaryPreview(props.src, controller.signal) + .then((bytes) => { + if (!controller.signal.aborted) + worker.postMessage({ type: "open", name: props.name, kind: props.kind, bytes }, [bytes]); + }) + .catch((cause: unknown) => + fail(cause instanceof Error ? cause.message : "Could not read this file."), + ); + return () => { + controller.abort(); + worker.terminate(); + workerRef.current = null; + if (timerRef.current) clearTimeout(timerRef.current); + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- One worker owns this keyed file and its message stream. + }, [props.src, props.kind, props.name]); + const table = database?.tables.find((item) => item.name === selectedTable); + const selectPage = (next: number, name = selectedTable) => { + setSelectedTable(name); + setPage(next); + setRows(null); + send({ type: "rows", table: name, page: next }); + }; + const children = new Map< + string, + { path: string; directory: boolean; size: number; modified: string | null } + >(); + for (const item of archive?.entries ?? []) { + if (!item.path.startsWith(folder)) continue; + const rest = item.path.slice(folder.length).replace(/\/$/, ""); + if (!rest) continue; + const slash = rest.indexOf("/"); + const path = slash < 0 ? item.path.replace(/\/$/, "") : `${folder}${rest.slice(0, slash)}`; + const directory = slash >= 0 || item.directory; + const previous = children.get(path); + if (previous) previous.size += item.size; + else children.set(path, { path, directory, size: item.size, modified: item.modified }); + } + return ( +
+ {archive ? ( +
+ { + setError(null); + setEntry(null); + if (selectedPath) setSelectedPath(null); + else setFolder(folder.replace(/[^/]+\/$/, "")); + }} + > + + + + {selectedPath ?? (folder || "Archive contents")} + + + {archive.entries.length.toLocaleString()} entries + +
+ ) : null} + {database && table ? ( + <> +
+ + selectPage(page - 1)} + > + + + + {table.count === 0 + ? "0 rows" + : `${page * SQLITE_PAGE_SIZE + 1}–${Math.min(table.count, (page + 1) * SQLITE_PAGE_SIZE)} of ${table.count.toLocaleString()}`} + + = table.count} + onPress={() => selectPage(page + 1)} + > + + +
+ + Read-only. Up to 50 columns and 1,024 characters per cell. Blob contents are omitted. + + + ) : null} + {error ? ( + + ) : busy ? ( + + ) : selectedPath ? ( + entry ? ( + + ) : null + ) : archive ? ( +
+ + + + + + + + + + {[...children.values()] + .sort( + (a, b) => + Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path), + ) + .map((item) => ( + + + + + + ))} + +
+ Name + + Size + + Modified +
+ + + {formatAttachmentSize(item.size)} + + {item.modified ? new Date(item.modified).toLocaleDateString() : "—"} +
+ {children.size === 0 ? ( +

This folder is empty.

+ ) : null} +
+ ) : rows ? ( +
+ + + + {rows.columns.map((name) => ( + + ))} + + + + {rows.rows.map((row, index) => ( + + {row.map((cell, column) => ( + + ))} + + ))} + +
+ {name} +
+ {cell} +
+ {rows.rows.length === 0 ? ( +

This table is empty.

+ ) : null} +
+ ) : ( +

This database has no readable tables.

+ )} +
+ ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e6fb4d1628f2..89b4e823b7f1 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -14,6 +14,7 @@ import { isWorkspaceFontPreviewPath, isWorkspaceVideoPreviewPath, officePreviewKind, + binaryPreviewKind, } from "@t3tools/shared/filePreview"; import { DEFAULT_TOKENIZE_MAX_LENGTH, @@ -77,6 +78,8 @@ import { AttachmentFilePreview } from "./AttachmentFilePreview"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; +import { BinaryFilePreview } from "./BinaryFilePreview"; +import { PreviewImage } from "./PreviewImage"; import { OfficeFilePreview } from "./OfficeFilePreview"; import { FontFilePreview } from "./FontFilePreview"; import { MermaidFilePreview } from "./MermaidFilePreview"; @@ -164,14 +167,16 @@ function WorkspaceImagePreview(props: { readonly workspaceMutationId: string | null; }) { const resource = workspaceAssetResource({ - kind: "workspace-file", + kind: + mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined + ? "workspace-file" + : "media-file", threadRef: props.threadRef, draft: props.draft, workspaceRoot: props.workspaceRoot, absolutePath: props.absolutePath, }); const assetUrl = useAssetUrlState(props.environmentId, resource); - const [failedUrl, setFailedUrl] = useState(null); const revisionSuffix = props.workspaceMutationId === null ? "" @@ -185,7 +190,7 @@ function WorkspaceImagePreview(props: { asset: { environmentId: props.environmentId, resource }, }; - if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { + if (assetUrl._tag === "Failure") { return (
@@ -196,17 +201,11 @@ function WorkspaceImagePreview(props: { } return assetUrl._tag === "Success" && imageUrl !== null ? ( - // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls. -
- - {props.alt} setFailedUrl(imageUrl)} - /> - -
+ +
+ +
+
) : (
@@ -256,6 +255,15 @@ function WorkspaceBrowserPreview(props: {
); } + const binaryKind = binaryPreviewKind({ name: props.absolutePath }); + if (binaryKind) + return ( + + ); const officeKind = officePreviewKind({ name: props.absolutePath }); if (isWorkspaceFontPreviewPath(props.absolutePath)) return ; @@ -1026,7 +1034,8 @@ export default function FilePreviewPanel({ const isPdf = relativePath !== null && isPdfPreviewFile(relativePath); const isDocument = relativePath !== null && - (officePreviewKind({ name: relativePath }) !== null || + (binaryPreviewKind({ name: relativePath }) !== null || + officePreviewKind({ name: relativePath }) !== null || isWorkspaceFontPreviewPath(relativePath)); const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath); const isHostFile = diff --git a/apps/web/src/components/files/PreviewImage.tsx b/apps/web/src/components/files/PreviewImage.tsx new file mode 100644 index 000000000000..b77e2e8bf038 --- /dev/null +++ b/apps/web/src/components/files/PreviewImage.tsx @@ -0,0 +1,56 @@ +import { lazy, Suspense, useState } from "react"; +import { AttachmentImage } from "../media/AttachmentImage"; + +import { FileSurfaceFailure } from "./fileSurfaceChrome"; + +const ExpandedImageDialog = lazy(() => + import("~/components/chat/ExpandedImageDialog").then((module) => ({ + default: module.ExpandedImageDialog, + })), +); + +export function PreviewImage(props: { + readonly src: string; + readonly name: string; + readonly mimeType?: string; +}) { + const [failed, setFailed] = useState(false); + const [expanded, setExpanded] = useState(false); + if (failed) return ; + return ( + <> + + {expanded ? ( + + setExpanded(false)} + /> + + ) : null} + + ); +} diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts new file mode 100644 index 000000000000..c730b78b4496 --- /dev/null +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -0,0 +1,202 @@ +import type { Database } from "sql.js"; + +import { boundedZip } from "./boundedZip"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_ENTRIES, + ARCHIVE_MAX_RATIO, + BINARY_PREVIEW_MAX_BYTES, + SQLITE_PAGE_SIZE, + type ArchiveEntry, + type BinaryWorkerRequest, + type BinaryWorkerResult, +} from "./binaryPreviewProtocol"; + +let database: Database | null = null; +let tables: string[] = []; +let files = new Map>(); +const reply = (result: BinaryWorkerResult) => self.postMessage(result, { transfer: [] }); +const quote = (name: string) => `"${name.replaceAll('"', '""')}"`; + +async function gunzip(bytes: ArrayBuffer) { + const reader = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream("gzip")) + .getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + length += value.length; + if (length > BINARY_PREVIEW_MAX_BYTES || length > bytes.byteLength * ARCHIVE_MAX_RATIO) + throw new Error( + "The expanded archive exceeds the preview size or compression ratio limit.", + ); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return new Uint8Array(await new Blob(chunks).arrayBuffer()); +} + +function tarEntries(bytes: Uint8Array) { + const entries: ArchiveEntry[] = []; + const text = (offset: number, length: number) => + new TextDecoder().decode(bytes.subarray(offset, offset + length)).split("\0", 1)[0] ?? ""; + const number = (offset: number, length: number) => { + const value = text(offset, length).trim(); + if (!/^[0-7]*$/.test(value)) throw new Error("This TAR uses an unsupported numeric format."); + return Number.parseInt(value || "0", 8); + }; + let longPath: string | null = null; + let offset = 0; + while (offset + 512 <= bytes.length) { + if (bytes.subarray(offset, offset + 512).every((byte) => byte === 0)) break; + if (entries.length >= ARCHIVE_MAX_ENTRIES) + throw new Error("This archive contains more than 2,048 entries."); + let checksum = 0; + for (let index = 0; index < 512; index++) + checksum += index >= 148 && index < 156 ? 32 : bytes[offset + index]!; + if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged."); + const size = number(offset + 124, 12); + const type = text(offset + 156, 1); + const prefix = text(offset + 345, 155); + const path = longPath ?? [prefix, text(offset, 100)].filter(Boolean).join("/"); + longPath = null; + if (path.length > 1024 || size > BINARY_PREVIEW_MAX_BYTES || offset + 512 + size > bytes.length) + throw new Error("An archive entry is too large or damaged."); + const content = bytes.subarray(offset + 512, offset + 512 + size); + if (type === "L") longPath = new TextDecoder().decode(content).replace(/\0.*$/s, ""); + else if (type === "x" || type === "g") { + if (size > 65536) throw new Error("Archive metadata is too large."); + for (const line of new TextDecoder().decode(content).split("\n")) { + const match = /^\d+ path=(.*)$/.exec(line); + if (match && type === "x") longPath = match[1]!; + } + } else if (type === "" || type === "0" || type === "5") { + const modified = number(offset + 136, 12) * 1000; + const date = new Date(modified); + entries.push({ + path, + size, + directory: type === "5", + modified: Number.isNaN(date.getTime()) ? null : date.toISOString(), + }); + if (type !== "5") files.set(path, content); + } + offset += 512 + Math.ceil(size / 512) * 512; + } + if (entries.length === 0) throw new Error("This archive has no readable entries."); + return entries; +} + +async function handle(input: BinaryWorkerRequest) { + if (input.type === "entry") { + const bytes = files.get(input.path); + if (!bytes) throw new Error("This archive entry is unavailable."); + if (bytes.length > ARCHIVE_ENTRY_MAX_BYTES) + throw new Error("Entry previews are limited to 10 MB."); + reply({ type: "entry", path: input.path, bytes }); + return; + } + if (input.type === "rows") { + if ( + !database || + !tables.includes(input.table) || + !Number.isSafeInteger(input.page) || + input.page < 0 + ) + throw new Error("This table is unavailable."); + const columns = + database + .exec(`PRAGMA table_info(${quote(input.table)})`)[0] + ?.values.map((row) => String(row[1])) + .slice(0, 50) ?? []; + const fields = columns.map((name) => { + const field = quote(name); + return `CASE WHEN typeof(${field}) = 'blob' THEN '[blob: ' || length(${field}) || ' bytes]' ELSE substr(CAST(${field} AS TEXT), 1, 1024) END`; + }); + const result = database.exec( + `SELECT ${fields.join(",")} FROM ${quote(input.table)} LIMIT ${SQLITE_PAGE_SIZE} OFFSET ${input.page * SQLITE_PAGE_SIZE}`, + )[0]; + reply({ + type: "rows", + table: input.table, + page: input.page, + columns, + rows: + result?.values.map((row) => row.map((cell) => (cell === null ? "NULL" : String(cell)))) ?? + [], + }); + return; + } + if (input.bytes.byteLength > BINARY_PREVIEW_MAX_BYTES) + throw new Error("Preview limited to files under 50 MB."); + if (input.kind === "sqlite") { + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + database = new SQL.Database(new Uint8Array(input.bytes)); + database.run("PRAGMA query_only=ON; PRAGMA trusted_schema=OFF;"); + tables = + database + .exec( + "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name LIMIT 101", + )[0] + ?.values.map((row) => String(row[0])) ?? []; + if (tables.length > 100) throw new Error("Database previews are limited to 100 tables."); + reply({ + type: "sqlite", + tables: tables.map((name) => ({ + name, + count: Number( + database!.exec(`SELECT count(*) FROM ${quote(name)}`)[0]?.values[0]?.[0] ?? 0, + ), + })), + }); + } else { + files = new Map(); + let entries: ArchiveEntry[]; + if ( + /\.(zip|jar)$/i.test(input.name) || + new DataView(input.bytes).getUint32(0, true) === 0x04034b50 + ) { + const archive = await boundedZip(input.bytes, ARCHIVE_ENTRY_MAX_BYTES); + entries = [...archive].map(([path, file]) => { + files.set(path, file.bytes); + return { + path, + size: file.bytes.length, + directory: file.directory, + modified: Number.isNaN(file.date.getTime()) ? null : file.date.toISOString(), + }; + }); + } else if (/\.(gz|tgz)$/i.test(input.name) || new Uint8Array(input.bytes)[0] === 0x1f) { + const bytes = await gunzip(input.bytes); + if (/\.(tar\.gz|tgz)$/i.test(input.name)) entries = tarEntries(bytes); + else { + const path = input.name.replace(/\.gz$/i, "") || "contents"; + if (/\.tar$/i.test(path)) entries = tarEntries(bytes); + else { + files.set(path, bytes); + entries = [{ path, size: bytes.length, directory: false, modified: null }]; + } + } + } else entries = tarEntries(new Uint8Array(input.bytes)); + reply({ type: "archive", entries }); + } +} + +self.addEventListener("message", (event: MessageEvent) => { + void handle(event.data).catch((cause: unknown) => + reply({ + type: "error", + message: cause instanceof Error ? cause.message : "Could not preview this file.", + }), + ); +}); diff --git a/apps/web/src/components/files/binaryPreviewProtocol.ts b/apps/web/src/components/files/binaryPreviewProtocol.ts new file mode 100644 index 000000000000..137c021ae965 --- /dev/null +++ b/apps/web/src/components/files/binaryPreviewProtocol.ts @@ -0,0 +1,24 @@ +export const BINARY_PREVIEW_MAX_BYTES = 50 * 1024 * 1024; +export const ARCHIVE_ENTRY_MAX_BYTES = 10 * 1024 * 1024; +export const ARCHIVE_MAX_ENTRIES = 2048; +export const ARCHIVE_MAX_RATIO = 200; +export const SQLITE_PAGE_SIZE = 50; + +export interface ArchiveEntry { + path: string; + size: number; + modified: string | null; + directory: boolean; +} + +export type BinaryWorkerRequest = + | { type: "open"; kind: "archive" | "sqlite"; name: string; bytes: ArrayBuffer } + | { type: "entry"; path: string } + | { type: "rows"; table: string; page: number }; + +export type BinaryWorkerResult = + | { type: "error"; message: string } + | { type: "archive"; entries: ArchiveEntry[] } + | { type: "entry"; path: string; bytes: Uint8Array } + | { type: "sqlite"; tables: { name: string; count: number }[] } + | { type: "rows"; table: string; page: number; columns: string[]; rows: string[][] }; diff --git a/apps/web/src/components/files/boundedZip.ts b/apps/web/src/components/files/boundedZip.ts new file mode 100644 index 000000000000..612e3775f6c8 --- /dev/null +++ b/apps/web/src/components/files/boundedZip.ts @@ -0,0 +1,78 @@ +import JSZip from "jszip"; + +import { + ARCHIVE_MAX_ENTRIES, + ARCHIVE_MAX_RATIO, + BINARY_PREVIEW_MAX_BYTES, +} from "./binaryPreviewProtocol"; + +export async function boundedZip(bytes: ArrayBuffer, entryLimit = BINARY_PREVIEW_MAX_BYTES) { + if (bytes.byteLength > BINARY_PREVIEW_MAX_BYTES) + throw new Error("Preview limited to files under 50 MB."); + // Reject oversized directories before JSZip allocates objects for their entries. + const view = new DataView(bytes); + let directoryFound = false; + for ( + let offset = bytes.byteLength - 22; + offset >= Math.max(0, bytes.byteLength - 65557); + offset-- + ) { + if (view.getUint32(offset, true) !== 0x06054b50) continue; + if (offset + 22 + view.getUint16(offset + 20, true) !== bytes.byteLength) continue; + if (view.getUint16(offset + 10, true) > ARCHIVE_MAX_ENTRIES) + throw new Error("This archive contains more than 2,048 entries."); + directoryFound = true; + break; + } + if (!directoryFound) throw new Error("This is not a readable ZIP archive."); + const zip = await JSZip.loadAsync(bytes); + const entries = Object.values(zip.files); + if (entries.length > ARCHIVE_MAX_ENTRIES) + throw new Error("This archive contains more than 2,048 entries."); + const files = new Map< + string, + { bytes: Uint8Array; date: Date; directory: boolean } + >(); + let total = 0; + for (const entry of entries) { + if (entry.name.length > 1024) throw new Error("An archive path is too long to preview."); + const chunks: Uint8Array[] = []; + let length = 0; + await new Promise((resolve, reject) => { + const stream = ( + entry as JSZip.JSZipObject & { + internalStream(type: "uint8array"): JSZip.JSZipStreamHelper>; + } + ).internalStream("uint8array"); + stream + .on("data", (chunk) => { + total += chunk.length; + length += chunk.length; + if ( + total > BINARY_PREVIEW_MAX_BYTES || + total > Math.max(1, bytes.byteLength) * ARCHIVE_MAX_RATIO || + length > entryLimit || + (entry.name.endsWith(".xml") && length > 8 * 1024 * 1024) + ) { + stream.pause(); + reject( + new Error( + "The expanded archive exceeds the preview size or compression ratio limit.", + ), + ); + } else chunks.push(chunk); + }) + .on("error", reject) + .on("end", resolve) + .resume(); + }); + const content = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + content.set(chunk, offset); + offset += chunk.length; + } + files.set(entry.name, { bytes: content, date: entry.date, directory: entry.dir }); + } + return files; +} diff --git a/apps/web/src/components/files/decodeImagePreview.ts b/apps/web/src/components/files/decodeImagePreview.ts new file mode 100644 index 000000000000..db8b19ee4842 --- /dev/null +++ b/apps/web/src/components/files/decodeImagePreview.ts @@ -0,0 +1,30 @@ +import { readBinaryPreview } from "./readBinaryPreview"; + +export async function decodeImagePreview(src: string, heif: boolean, signal: AbortSignal) { + const worker = new Worker(new URL("./imageDecode.worker.ts", import.meta.url), { + type: "module", + }); + const controller = new AbortController(); + try { + return await new Promise((resolve, reject) => { + const fail = (message: string) => reject(new Error(message)); + const abort = () => fail("Image decoding cancelled."); + signal.addEventListener("abort", abort, { signal: controller.signal }); + if (signal.aborted) return abort(); + const timer = setTimeout(() => fail("This image took too long to decode."), 30_000); + controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + worker.addEventListener("error", () => fail("Could not load the image decoder.")); + worker.addEventListener("message", (event: MessageEvent<{ blob?: Blob; error?: string }>) => { + if (event.data.blob) resolve(event.data.blob); + else fail(event.data.error ?? "Could not decode this image."); + }); + void readBinaryPreview(src, controller.signal).then( + (bytes) => worker.postMessage({ bytes, heif }, [bytes]), + reject, + ); + }); + } finally { + controller.abort(); + worker.terminate(); + } +} diff --git a/apps/web/src/components/files/imageDecode.worker.ts b/apps/web/src/components/files/imageDecode.worker.ts new file mode 100644 index 000000000000..2460a2289fc7 --- /dev/null +++ b/apps/web/src/components/files/imageDecode.worker.ts @@ -0,0 +1,165 @@ +const MAX_PIXELS = 32_000_000; + +function dimensions(width: number, height: number) { + if (!(width > 0 && height > 0) || width > 16384 || height > 16384 || width > MAX_PIXELS / height) + throw new Error("Image previews are limited to 32 megapixels and 16,384 pixels per side."); +} + +function validateHeif(bytes: ArrayBuffer) { + const view = new DataView(bytes); + let found = false; + let count = 0; + const boxes = (start: number, end: number, depth: number) => { + if (depth > 4) return; + let offset = start; + while (offset + 8 <= end) { + if (++count > 2048) throw new Error("This image has too much metadata."); + let size = view.getUint32(offset); + let header = 8; + if (size === 1) { + if (offset + 16 > end) throw new Error("Invalid HEIF metadata."); + const large = view.getBigUint64(offset + 8); + if (large > BigInt(bytes.byteLength)) throw new Error("Invalid HEIF metadata."); + size = Number(large); + header = 16; + } else if (size === 0) size = end - offset; + if (size < header || offset + size > end) throw new Error("Invalid HEIF metadata."); + const type = view.getUint32(offset + 4); + if (type === 0x69737065) { + if (size < header + 12) throw new Error("Invalid HEIF dimensions."); + dimensions(view.getUint32(offset + header + 4), view.getUint32(offset + header + 8)); + found = true; + } else if (type === 0x6d657461 || type === 0x69707270 || type === 0x6970636f) + boxes(offset + header + (type === 0x6d657461 ? 4 : 0), offset + size, depth + 1); + offset += size; + } + }; + boxes(0, bytes.byteLength, 0); + if (!found) throw new Error("This HEIF image has no readable dimensions."); +} + +// Count LZW output without building strings before the decoder allocates its strip buffer. +function validateLzw(bytes: Uint8Array, limit: number) { + const lengths = new Uint16Array(4096).fill(1, 0, 256); + let bits = 9; + let bit = 0; + let next = 258; + let previous = 0; + let total = 0; + while (bit + bits <= bytes.length * 8) { + let code = 0; + for (let index = 0; index < bits; index++, bit++) + code = (code << 1) | ((bytes[bit >> 3]! >> (7 - (bit & 7))) & 1); + if (code === 257) return; + if (code === 256) { + bits = 9; + next = 258; + previous = 0; + continue; + } + const length = code < next ? lengths[code]! : code === next && previous ? previous + 1 : 0; + if (!length || (total += length) > limit) throw new Error("Invalid or oversized TIFF strip."); + if (previous) { + if (next >= 4096) throw new Error("Invalid TIFF LZW dictionary."); + lengths[next++] = previous + 1; + if (next === (1 << bits) - 1 && bits < 12) bits++; + } + previous = length; + } + throw new Error("Truncated TIFF strip."); +} + +async function validateTiffStrips(bytes: ArrayBuffer, image: import("tiff").TiffIfd) { + const offsets = image.tiled ? image.tileOffsets : image.stripOffsets; + const counts = image.tiled ? image.tileByteCounts : image.stripByteCounts; + const width = image.tiled ? image.tileWidth! : image.width; + const height = image.tiled ? image.tileHeight! : Math.min(image.rowsPerStrip, image.height); + dimensions(width, height); + const limit = width * height * image.components * (image.bitsPerSample / 8); + if (!offsets?.length || offsets.length > 2048 || counts?.length !== offsets.length) + throw new Error("Unsupported TIFF strip layout."); + let expanded = 0; + for (let index = 0; index < offsets.length; index++) { + const offset = offsets[index]!; + const size = counts[index]!; + if (offset < 0 || size < 1 || offset + size > bytes.byteLength) + throw new Error("Invalid TIFF strip."); + const strip = new Uint8Array(bytes, offset, size); + if (image.compression === 5) validateLzw(strip, limit); + else if (image.compression === 8 || image.compression === 32946) { + const reader = new Blob([strip]) + .stream() + .pipeThrough(new DecompressionStream("deflate")) + .getReader(); + let length = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if ((length += value.length) > limit) throw new Error("Oversized TIFF strip."); + } + } finally { + await reader.cancel(); + } + } else if (image.compression !== 1 && image.compression !== 32773) + throw new Error("This TIFF compression is not supported."); + // Bound repeated strips and padding as well as the visible image dimensions. + expanded += limit; + if (expanded > 256 * 1024 * 1024) throw new Error("This TIFF has too much image data."); + } +} + +async function decode(bytes: ArrayBuffer, heif: boolean) { + if (bytes.byteLength > 50 * 1024 * 1024) throw new Error("Preview limited to files under 50 MB."); + if (heif) { + validateHeif(bytes); + const { heicTo } = await import("heic-to/next"); + return await heicTo({ blob: new Blob([bytes]), type: "image/png" }); + } + const { decode: decodeTiff } = await import("tiff"); + const metadata = decodeTiff(bytes, { ignoreImageData: true, pages: [0] })[0]; + if (!metadata) throw new Error("This TIFF has no readable image."); + dimensions(metadata.width, metadata.height); + if ( + ![1, 2, 3, 4].includes(metadata.components) || + ![8, 16].includes(metadata.bitsPerSample) || + ![0, 1, 2, 3].includes(metadata.type) + ) + throw new Error("This TIFF color format is not supported."); + await validateTiffStrips(bytes, metadata); + const image = decodeTiff(bytes, { pages: [0] })[0]!; + const rgba = new Uint8ClampedArray(image.width * image.height * 4); + const scale = image.bitsPerSample === 16 ? 257 : 1; + const palette = image.palette; + for (let pixel = 0; pixel < image.width * image.height; pixel++) { + const index = pixel * image.components; + if (palette) { + const color = palette[image.data[index]!] ?? [0, 0, 0]; + for (let channel = 0; channel < 3; channel++) + rgba[pixel * 4 + channel] = color[channel]! / 257; + } else if (image.type === 2) { + for (let channel = 0; channel < 3; channel++) + rgba[pixel * 4 + channel] = image.data[index + channel]! / scale; + } else { + const gray = image.data[index]! / scale; + rgba.fill(image.type === 0 ? 255 - gray : gray, pixel * 4, pixel * 4 + 3); + } + rgba[pixel * 4 + 3] = image.alpha ? image.data[index + image.components - 1]! / scale : 255; + } + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Image decoding is unavailable."); + context.putImageData(new ImageData(rgba, image.width, image.height), 0, 0); + return await canvas.convertToBlob({ type: "image/png" }); +} + +self.addEventListener("message", (event: MessageEvent<{ bytes: ArrayBuffer; heif: boolean }>) => { + void decode(event.data.bytes, event.data.heif).then( + (blob) => self.postMessage({ blob }, { transfer: [] }), + (cause: unknown) => + self.postMessage( + { error: cause instanceof Error ? cause.message : "Could not decode this image." }, + { transfer: [] }, + ), + ); +}); diff --git a/apps/web/src/components/files/officePreview.worker.ts b/apps/web/src/components/files/officePreview.worker.ts index 7b63a5b6ba76..e101dc8ff72c 100644 --- a/apps/web/src/components/files/officePreview.worker.ts +++ b/apps/web/src/components/files/officePreview.worker.ts @@ -1,8 +1,8 @@ import JSZip from "jszip"; +import { boundedZip } from "./boundedZip"; import type { PresentationHandle, SlidePrimitive } from "@betteroffice/pptx"; import { - OFFICE_PREVIEW_MAX_BYTES, OFFICE_PREVIEW_MAX_COLUMNS, OFFICE_PREVIEW_MAX_ROWS, type OfficeWorkerRequest, @@ -15,52 +15,11 @@ function reply(result: OfficeWorkerResult) { self.postMessage(result, { transfer: [] }); } -// Stream each entry before handing it to an engine. A tiny compressed archive -// must not turn into an unbounded allocation on the UI thread. async function boundedArchive(bytes: ArrayBuffer) { - if (bytes.byteLength > OFFICE_PREVIEW_MAX_BYTES) - throw new Error("Preview limited to files under 50 MB."); - const source = await JSZip.loadAsync(bytes).catch(() => { - throw new Error("This file is damaged, encrypted, or not a valid Office document."); - }); - const entries = Object.values(source.files).filter((entry) => !entry.dir); - if (entries.length > 2048) - throw new Error("This file contains too many archive entries to preview."); + const files = await boundedZip(bytes); const archive = new JSZip(); - let total = 0; - for (const entry of entries) { - const chunks: Uint8Array[] = []; - let length = 0; - await new Promise((resolve, reject) => { - // JSZip exposes this streaming API at runtime but omits it from its types. - const stream = ( - entry as JSZip.JSZipObject & { - internalStream(type: "uint8array"): JSZip.JSZipStreamHelper; - } - ).internalStream("uint8array"); - stream - .on("data", (chunk: Uint8Array) => { - total += chunk.length; - length += chunk.length; - if ( - total > OFFICE_PREVIEW_MAX_BYTES || - (entry.name.endsWith(".xml") && length > 8 * 1024 * 1024) - ) { - stream.pause(); - reject(new Error("The expanded document is too large to preview safely.")); - } else chunks.push(chunk); - }) - .on("error", reject) - .on("end", resolve) - .resume(); - }); - const content = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - content.set(chunk, offset); - offset += chunk.length; - } - archive.file(entry.name, content); + for (const [name, file] of files) { + if (!file.directory) archive.file(name, file.bytes); } return archive; } diff --git a/apps/web/src/components/files/readBinaryPreview.ts b/apps/web/src/components/files/readBinaryPreview.ts new file mode 100644 index 000000000000..e721d5d4af53 --- /dev/null +++ b/apps/web/src/components/files/readBinaryPreview.ts @@ -0,0 +1,24 @@ +import { BINARY_PREVIEW_MAX_BYTES } from "./binaryPreviewProtocol"; + +export async function readBinaryPreview(src: string, signal: AbortSignal) { + const response = await fetch(src, { signal }); + if (!response.ok) throw new Error("The file could not be loaded. Close it and try again."); + if (Number(response.headers.get("content-length")) > BINARY_PREVIEW_MAX_BYTES) + throw new Error("Preview limited to files under 50 MB."); + const reader = response.body?.getReader(); + if (!reader) throw new Error("The file is unavailable."); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > BINARY_PREVIEW_MAX_BYTES) throw new Error("Preview limited to files under 50 MB."); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return new Blob(chunks).arrayBuffer(); +} diff --git a/apps/web/src/components/media/AttachmentImage.tsx b/apps/web/src/components/media/AttachmentImage.tsx index dbe813a2ec0e..6fe2e8fcc552 100644 --- a/apps/web/src/components/media/AttachmentImage.tsx +++ b/apps/web/src/components/media/AttachmentImage.tsx @@ -5,57 +5,47 @@ import { type ComponentProps, type SyntheticEvent, } from "react"; -import { - isHeicImageFile, - MAX_COMPRESSIBLE_SOURCE_BYTES, - prepareImageForAttachment, -} from "../../lib/imageCompression"; type AttachmentImageProps = ComponentProps<"img"> & { name: string; mimeType?: string | undefined }; export function AttachmentImage({ name, mimeType, ...props }: AttachmentImageProps) { - return isHeicImageFile({ name, type: mimeType ?? "" }) ? ( - + const fallback = + /\.(tiff?|hei[cf])$/i.test(name) || /^image\/(tiff|hei[cf])$/i.test(mimeType ?? ""); + return fallback ? ( + ) : ( ); } -function HeicImage({ name, src, onError, ...props }: Omit) { +function DecodedImage({ name, mimeType, src, onError, ...props }: AttachmentImageProps) { const [decodeError, setDecodeError] = useState | null>(null); const [previewUrl, setPreviewUrl] = useState(); - const reportError = useEffectEvent((event: SyntheticEvent) => onError?.(event)); - useEffect(() => { if (!decodeError || !src) return; const controller = new AbortController(); let objectUrl: string | undefined; - const decode = async () => { - try { - const response = await fetch(src, { signal: controller.signal }); - if (!response.ok) return reportError(decodeError); - const blob = await response.blob(); + void import("../files/decodeImagePreview") + .then(async ({ decodeImagePreview }) => { if (controller.signal.aborted) return; - const result = await prepareImageForAttachment( - new File([blob], name, { type: "image/heic" }), - MAX_COMPRESSIBLE_SOURCE_BYTES, + const blob = await decodeImagePreview( + src, + /\.hei[cf]$/i.test(name) || /^image\/hei[cf]$/i.test(mimeType ?? ""), + controller.signal, ); if (controller.signal.aborted) return; - if (!result.ok) return reportError(decodeError); - objectUrl = URL.createObjectURL(result.file); + objectUrl = URL.createObjectURL(blob); setPreviewUrl(objectUrl); - } catch { + }) + .catch(() => { if (!controller.signal.aborted) reportError(decodeError); - } - }; - void decode(); + }); return () => { controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [decodeError, name, src]); - + }, [decodeError, name, src, mimeType]); return ( ); diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 57214d85ba72..e28845693df8 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -16,6 +16,29 @@ import { } from "./filePreview.ts"; describe("workspace file previews", () => { + it.each([ + ["zip", "archive", "application/zip"], + ["jar", "archive", "application/java-archive"], + ["tar", "archive", "application/x-tar"], + ["gz", "archive", "application/gzip"], + ["tgz", "archive", "application/gzip"], + ["sqlite", "sqlite", "application/vnd.sqlite3"], + ["sqlite3", "sqlite", "application/vnd.sqlite3"], + ["db", "sqlite", "application/vnd.sqlite3"], + ])("recognizes binary preview %s", (extension, kind, mimeType) => { + const name = `files#draft/sample.${extension.toUpperCase()}`; + expect(filePreviewKind({ name, mimeType: "application/octet-stream" })).toBe(kind); + expect(filePreviewKind({ name: "download", mimeType })).toBe(kind); + expect(isWorkspacePreviewEntryPath(name)).toBe(true); + expect(isWorkspacePreviewEntryPath(`${name}?download=1`)).toBe(false); + expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(mimeType); + }); + it("recognizes compound archives and MIME aliases", () => { + expect(filePreviewKind({ name: "archive.tar.gz" })).toBe("archive"); + expect(filePreviewKind({ name: "download", mimeType: "application/x-gzip" })).toBe("archive"); + expect(filePreviewKind({ name: "download", mimeType: "application/x-sqlite3" })).toBe("sqlite"); + expect(filePreviewKind({ name: "misleading.zip", mimeType: "application/pdf" })).toBe("pdf"); + }); it.each(["ttf", "otf", "woff", "woff2"])( "previews %s fonts through signed host assets", (extension) => { @@ -28,7 +51,7 @@ describe("workspace file previews", () => { expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(`font/${extension}`); expect(isWorkspaceFontPreviewPath(`sample.${extension}?download=1`)).toBe(false); expect(filePreviewKind({ name: `sample.${extension}`, mimeType: "application/zip" })).toBe( - "unsupported", + "archive", ); }, ); @@ -54,7 +77,7 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(name)).toBe(true); expect(hostPreviewMimeTypeFromExtension(`.${kind}`)).toBe(mimeType); expect(filePreviewKind({ name: "download", mimeType })).toBe(kind); - expect(filePreviewKind({ name, mimeType: "application/zip" })).toBe("unsupported"); + expect(filePreviewKind({ name, mimeType: "application/zip" })).toBe("archive"); }); it.each(["report.html", "report.HTM", "document#draft.pdf", "reports?old/document.pdf"])( "recognizes browser preview path %s", @@ -75,6 +98,7 @@ describe("workspace file previews", () => { "image.avif", "picture.bmp", "photo.heic", + "photo.heif", "scan.tif", "scan.TIFF", ])("recognizes image preview path %s", (path) => { @@ -176,9 +200,9 @@ describe("attachment preview classification", () => { ["recording.webm-audio", "", "audio"], ["recording.webm", "audio/webm", "audio"], ["payload", "application/problem+json", "text"], - ["archive.zip", "application/zip", "unsupported"], + ["archive.zip", "application/zip", "archive"], ["misleading.json", "application/pdf", "pdf"], - ["misleading.pdf", "application/zip", "unsupported"], + ["misleading.pdf", "application/zip", "archive"], ])("classifies %s (%s) as %s", (name, mimeType, expected) => { expect(filePreviewKind({ name, mimeType })).toBe(expected); }); @@ -186,7 +210,7 @@ describe("attachment preview classification", () => { expect(() => decodeFilePreviewText(new Uint8Array([65, 0, 66]))).toThrow("binary"); expect(() => decodeFilePreviewText(new Uint8Array([255]))).toThrow("UTF-8"); }); - it.each(["doc", "xls", "ppt", "odt", "ods", "odp", "epub", "zip", "tar", "gz", "7z", "rar"])( + it.each(["doc", "xls", "ppt", "odt", "ods", "odp", "epub", "7z", "rar"])( "retains the binary fallback for %s, which opencode does not render", (extension) => { expect(filePreviewKind({ name: `sample.${extension}` })).toBe("unsupported"); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index 1bf7559304c1..352e26164802 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -28,6 +28,8 @@ export type FilePreviewKind = | "markdown" | "mermaid" | "font" + | "archive" + | "sqlite" | "docx" | "xlsx" | "pptx" @@ -44,6 +46,8 @@ export function filePreviewKind(file: { const generic = !mime || mime === "application/octet-stream" || mime === "text/plain"; const office = officePreviewKind(file); if (office) return office; + const binary = binaryPreviewKind(file); + if (binary) return binary; if (mime === "application/pdf") return "pdf"; if (mime === "text/html") return "html"; if (mime === "text/markdown" || mime === "text/x-markdown") return "markdown"; @@ -87,6 +91,43 @@ export function filePreviewKind(file: { return "unsupported"; } +const BINARY_MIME_TYPES = new Map([ + [".zip", "application/zip"], + [".jar", "application/java-archive"], + [".tar", "application/x-tar"], + [".gz", "application/gzip"], + [".tgz", "application/gzip"], + [".sqlite", "application/vnd.sqlite3"], + [".sqlite3", "application/vnd.sqlite3"], + [".db", "application/vnd.sqlite3"], +]); + +export function binaryPreviewKind(file: { + readonly name: string; + readonly mimeType?: string; +}): "archive" | "sqlite" | null { + const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (["application/vnd.sqlite3", "application/x-sqlite3", "application/x-sqlite"].includes(mime)) + return "sqlite"; + if ( + [ + "application/zip", + "application/x-zip-compressed", + "application/java-archive", + "application/x-java-archive", + "application/x-tar", + "application/gzip", + "application/x-gzip", + "application/x-compressed-tar", + ].includes(mime) + ) + return "archive"; + if (mime && mime !== "application/octet-stream" && mime !== "text/plain") return null; + const extension = file.name.slice(file.name.lastIndexOf(".")).toLowerCase(); + const inferred = BINARY_MIME_TYPES.get(extension); + return inferred === "application/vnd.sqlite3" ? "sqlite" : inferred ? "archive" : null; +} + export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024; const OFFICE_MIME_TYPES = { @@ -136,6 +177,7 @@ export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ ".gif", ".ico", ".heic", + ".heif", ".jpeg", ".jpg", ".png", @@ -149,6 +191,7 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ [".avif", "image/avif"], [".bmp", "image/bmp"], [".heic", "image/heic"], + [".heif", "image/heif"], [".gif", "image/gif"], [".ico", "image/x-icon"], [".jpeg", "image/jpeg"], @@ -212,6 +255,7 @@ export function hostPreviewMimeTypeFromExtension(extension: string): string | nu audioMimeTypeFromExtension(extension) ?? BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? FONT_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? + BINARY_MIME_TYPES.get(extension.toLowerCase()) ?? (Object.hasOwn(OFFICE_MIME_TYPES, extension.toLowerCase().slice(1)) ? OFFICE_MIME_TYPES[extension.toLowerCase().slice(1) as OfficePreviewKind] : null) ?? @@ -283,6 +327,7 @@ export function isWorkspacePreviewEntryPath(path: string): boolean { isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path) || isWorkspaceFontPreviewPath(path) || - officePreviewKind({ name: path }) !== null + officePreviewKind({ name: path }) !== null || + binaryPreviewKind({ name: path }) !== null ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a7151256d5c..e2caa292a475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -964,9 +964,15 @@ importers: remark-gfm: specifier: 'catalog:' version: 4.0.1 + sql.js: + specifier: 1.15.0 + version: 1.15.0 tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + tiff: + specifier: 7.1.3 + version: 7.1.3 unbash: specifier: ^5.0.0 version: 5.0.0 @@ -1004,6 +1010,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.3.0(@types/react@19.3.0) + '@types/sql.js': + specifier: 1.4.11 + version: 1.4.11 '@types/use-sync-external-store': specifier: ^0.0.6 version: 0.0.6 @@ -1066,10 +1075,10 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.80 - version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(c218479f13e455b978e1beb907da1b95) + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(9b11ff50c902293c0cba5b2c39b51a35) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(valibot@1.2.0(typescript@7.0.2))(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(valibot@1.2.0(typescript@7.0.2))(zod@4.4.3) effect: specifier: 4.0.5 version: 4.0.5 @@ -6751,6 +6760,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/sql.js@1.4.11': + resolution: {integrity: sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==} + '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} @@ -9601,6 +9613,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@6.0.1: + resolution: {integrity: sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==} + ioredis@5.11.0: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} @@ -11678,6 +11693,9 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql.js@1.15.0: + resolution: {integrity: sha512-54VT5h9/VGlLaQan5pX/Zx8k6Vbykwl5hRUtl4h4+VYnaZCTraUs4P1i2WLz21O6oPVp6Gvg+d1OyPo01ZG5ig==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -11863,6 +11881,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiff@7.1.3: + resolution: {integrity: sha512-YEEq3fT++2pdta/9P/vGG4QRMdZQoe6W6JNaWnIi6NvAsbeNITwFCtmWwL/BZvOi+uo2I3ohyOkD3sZfme+c6g==} + timestring@6.0.0: resolution: {integrity: sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==} engines: {node: '>=8'} @@ -17433,6 +17454,11 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.4 + '@types/sql.js@1.4.11': + dependencies: + '@types/emscripten': 1.41.5 + '@types/node': 24.12.4 + '@types/stats.js@0.17.4': {} '@types/statuses@2.0.6': @@ -17589,7 +17615,7 @@ snapshots: '@vitest/mocker': 5.0.1(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) playwright: 1.60.0 tinyrainbow: 3.1.1 - vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw @@ -17614,7 +17640,7 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.1) '@vitest/browser': 5.0.1(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@5.0.1) - vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw @@ -17650,7 +17676,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -18017,7 +18043,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(c218479f13e455b978e1beb907da1b95): + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(9b11ff50c902293c0cba5b2c39b51a35): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) @@ -18060,7 +18086,7 @@ snapshots: '@effect/sql-pg': 4.0.5(effect@4.0.5) '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(valibot@1.2.0(typescript@7.0.2))(zod@4.4.3) + drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(valibot@1.2.0(typescript@7.0.2))(zod@4.4.3) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) pg: 8.23.0 vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' @@ -19218,7 +19244,7 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(valibot@1.2.0(typescript@7.0.2))(zod@4.4.3): + drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(valibot@1.2.0(typescript@7.0.2))(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-d1': 4.0.0(effect@4.0.5) @@ -19227,10 +19253,12 @@ snapshots: '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) '@electric-sql/pglite': 0.3.15 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@types/sql.js': 1.4.11 bun-types: 1.3.14 effect: 4.0.5 expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) pg: 8.23.0 + sql.js: 1.15.0 valibot: 1.2.0(typescript@7.0.2) zod: 4.4.3 @@ -20481,6 +20509,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@6.0.1: {} + ioredis@5.11.0: dependencies: '@ioredis/commands': 1.10.0 @@ -23225,6 +23255,8 @@ snapshots: sprintf-js@1.1.3: optional: true + sql.js@1.15.0: {} + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -23424,6 +23456,11 @@ snapshots: through@2.3.8: {} + tiff@7.1.3: + dependencies: + fflate: 0.8.3 + iobuffer: 6.0.1 + timestring@6.0.0: {} tiny-async-pool@1.3.0: @@ -23782,7 +23819,7 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) optionalDependencies: - '@vitest/browser-playwright': 5.0.1(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(playwright@1.60.0)(utf-8-validate@6.0.6)(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@5.0.1) + '@vitest/browser-playwright': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(playwright@1.60.0)(utf-8-validate@6.0.6)(vitest@5.0.1) '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 @@ -23838,7 +23875,7 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' vitest: 5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) optionalDependencies: - '@vitest/browser-playwright': 5.0.1(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(playwright@1.60.0)(utf-8-validate@6.0.6)(vitest@5.0.1) + '@vitest/browser-playwright': 5.0.1(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(playwright@1.60.0)(utf-8-validate@6.0.6)(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@5.0.1) '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 @@ -23922,7 +23959,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vitest@5.0.1(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/browser-preview@5.0.1)(@vitest/ui@5.0.1)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.1(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(vite@8.3.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 3c20678566ac..65d7a2b65862 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -660,6 +660,21 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2019-present Fabio Spampinato, Andrew Maney"] } + }, + { + "name": "sql.js", + "license": "MIT", + "sourceUrl": "https://github.com/sql-js/sql.js" + }, + { + "name": "tiff", + "license": "MIT", + "sourceUrl": "https://github.com/image-js/tiff" + }, + { + "name": "iobuffer", + "license": "MIT", + "sourceUrl": "https://github.com/image-js/iobuffer" } ] } From 26597b3a407091f26f5ea2dc40b27eacd47d090c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 03:57:18 +0000 Subject: [PATCH 02/12] fix(web): keep binary previews lazy and align heif file icons --- .../web/src/components/files/PreviewImage.tsx | 51 +++--------------- .../components/files/PreviewImageContent.tsx | 49 +++++++++++++++++ .../media/AttachmentImage.browser.test.tsx | 54 +++++++++---------- .../src/components/media/AttachmentImage.tsx | 5 +- apps/web/src/pierre-icons.test.ts | 7 +++ apps/web/src/pierre-icons.ts | 4 ++ apps/web/vite.config.ts | 10 ++++ 7 files changed, 106 insertions(+), 74 deletions(-) create mode 100644 apps/web/src/components/files/PreviewImageContent.tsx diff --git a/apps/web/src/components/files/PreviewImage.tsx b/apps/web/src/components/files/PreviewImage.tsx index b77e2e8bf038..7f707032b755 100644 --- a/apps/web/src/components/files/PreviewImage.tsx +++ b/apps/web/src/components/files/PreviewImage.tsx @@ -1,56 +1,17 @@ -import { lazy, Suspense, useState } from "react"; -import { AttachmentImage } from "../media/AttachmentImage"; +import { lazy, Suspense } from "react"; -import { FileSurfaceFailure } from "./fileSurfaceChrome"; +import { FileSurfaceLoading } from "./fileSurfaceChrome"; -const ExpandedImageDialog = lazy(() => - import("~/components/chat/ExpandedImageDialog").then((module) => ({ - default: module.ExpandedImageDialog, - })), -); +const PreviewImageContent = lazy(() => import("./PreviewImageContent")); export function PreviewImage(props: { readonly src: string; readonly name: string; readonly mimeType?: string; }) { - const [failed, setFailed] = useState(false); - const [expanded, setExpanded] = useState(false); - if (failed) return ; return ( - <> - - {expanded ? ( - - setExpanded(false)} - /> - - ) : null} - + }> + + ); } diff --git a/apps/web/src/components/files/PreviewImageContent.tsx b/apps/web/src/components/files/PreviewImageContent.tsx new file mode 100644 index 000000000000..f0214287914e --- /dev/null +++ b/apps/web/src/components/files/PreviewImageContent.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog"; +import { AttachmentImage } from "../media/AttachmentImage"; + +import { FileSurfaceFailure } from "./fileSurfaceChrome"; + +export default function PreviewImageContent(props: { + readonly src: string; + readonly name: string; + readonly mimeType?: string; +}) { + const [failed, setFailed] = useState(false); + const [expanded, setExpanded] = useState(false); + if (failed) return ; + return ( + <> + + {expanded ? ( + setExpanded(false)} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/media/AttachmentImage.browser.test.tsx b/apps/web/src/components/media/AttachmentImage.browser.test.tsx index a99c64dff9ab..bda9b63450a1 100644 --- a/apps/web/src/components/media/AttachmentImage.browser.test.tsx +++ b/apps/web/src/components/media/AttachmentImage.browser.test.tsx @@ -2,30 +2,22 @@ import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; import { AttachmentImage } from "./AttachmentImage"; -import { prepareImageForAttachment } from "../../lib/imageCompression"; +import { decodeImagePreview } from "../files/decodeImagePreview"; -vi.mock("../../lib/imageCompression", async (importOriginal) => ({ - ...(await importOriginal()), - prepareImageForAttachment: vi.fn(), +vi.mock("../files/decodeImagePreview", () => ({ + decodeImagePreview: vi.fn(), })); let root: Root | undefined; let container: HTMLDivElement | undefined; -const fetchImage = vi.fn(); const revokeUrl = vi.fn(); -const jpeg = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" }); +const bitmap = new Blob(["png"], { type: "image/png" }); beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("fetch", fetchImage); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); - fetchImage.mockResolvedValue(new Response(new Blob(["heic"]))); - vi.mocked(prepareImageForAttachment).mockResolvedValue({ - ok: true, - file: jpeg, - recompressed: true, - }); + vi.mocked(decodeImagePreview).mockResolvedValue(bitmap); }); afterEach(async () => { @@ -58,6 +50,7 @@ async function failDecode() { await act(() => { img.dispatchEvent(new Event("error")); }); + await act(() => vi.dynamicImportSettled()); return img; } @@ -66,20 +59,24 @@ function expectDecodeErrorFor(onError: ReturnType, img: HTMLImageE } it.each([ - { name: "photo.HEIC", mimeType: undefined }, - { name: "photo", mimeType: "image/heif" }, + { name: "photo.HEIC", mimeType: undefined, heif: true }, + { name: "photo.heif", mimeType: undefined, heif: true }, + { name: "photo", mimeType: "image/heif", heif: true }, + { name: "scan.TIFF", mimeType: undefined, heif: false }, + { name: "scan", mimeType: "image/tiff", heif: false }, ])( "converts $name after native decoding fails and releases its preview", - async ({ name, mimeType }) => { + async ({ name, mimeType, heif }) => { await render( , ); - expect(fetchImage).not.toHaveBeenCalled(); + expect(decodeImagePreview).not.toHaveBeenCalled(); await failDecode(); expect(image().getAttribute("src")).toBe("blob:jpeg"); - expect(prepareImageForAttachment).toHaveBeenCalledWith( - expect.objectContaining({ name, type: "image/heic" }), - 50 * 1024 * 1024, + expect(decodeImagePreview).toHaveBeenCalledWith( + "https://environment.test/photo", + heif, + expect.any(AbortSignal), ); await act(() => root!.unmount()); root = undefined; @@ -91,29 +88,30 @@ it("leaves PNG errors to the caller without fetching or converting", async () => const onError = vi.fn(); await render(); expectDecodeErrorFor(onError, await failDecode()); - expect(fetchImage).not.toHaveBeenCalled(); + expect(decodeImagePreview).not.toHaveBeenCalled(); }); it("reports failed HEIC conversion through the existing error handler", async () => { const onError = vi.fn(); - vi.mocked(prepareImageForAttachment).mockResolvedValue({ ok: false, reason: "unreadable" }); + vi.mocked(decodeImagePreview).mockRejectedValue(new Error("unreadable")); await render(); expectDecodeErrorFor(onError, await failDecode()); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); it("discards an old decode when navigating to a different source", async () => { - let finishDecode!: (result: Awaited>) => void; - vi.mocked(prepareImageForAttachment).mockReturnValue( + let finishDecode!: (result: Blob) => void; + vi.mocked(decodeImagePreview).mockReturnValue( new Promise((resolve) => { finishDecode = resolve; }), ); await render(); await failDecode(); - await render(); - await act(() => finishDecode({ ok: true, file: jpeg, recompressed: true })); - expect(image().getAttribute("src")).toBe("blob:second"); + const secondSource = "data:image/svg+xml,"; + await render(); + await act(() => finishDecode(bitmap)); + expect(image().getAttribute("src")).toBe(secondSource); expect(URL.createObjectURL).not.toHaveBeenCalled(); - expect(fetchImage.mock.calls[0]?.[1].signal.aborted).toBe(true); + expect(vi.mocked(decodeImagePreview).mock.calls[0]?.[2].aborted).toBe(true); }); diff --git a/apps/web/src/components/media/AttachmentImage.tsx b/apps/web/src/components/media/AttachmentImage.tsx index 6fe2e8fcc552..8693e1c2d38e 100644 --- a/apps/web/src/components/media/AttachmentImage.tsx +++ b/apps/web/src/components/media/AttachmentImage.tsx @@ -5,12 +5,15 @@ import { type ComponentProps, type SyntheticEvent, } from "react"; +import { isHeicImageFile } from "../../lib/imageCompression"; type AttachmentImageProps = ComponentProps<"img"> & { name: string; mimeType?: string | undefined }; export function AttachmentImage({ name, mimeType, ...props }: AttachmentImageProps) { const fallback = - /\.(tiff?|hei[cf])$/i.test(name) || /^image\/(tiff|hei[cf])$/i.test(mimeType ?? ""); + isHeicImageFile({ name, type: mimeType ?? "" }) || + /\.tiff?$/i.test(name) || + /^image\/tiff$/i.test(mimeType ?? ""); return fallback ? ( ) : ( diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 9910f361ca87..bfac57cb246c 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -8,6 +8,13 @@ import { } from "./pierre-icons"; describe("Pierre file icons", () => { + it.each(["photo.heic", "photo.heif", "PHOTO.HEIC", "folder/photo.HEIF"])( + "uses the image icon for %s", + (name) => { + assert.equal(resolvePierreIconForEntry(name, "file")?.token, "image"); + assert.isTrue(hasSpecificPierreIconForFileName(name)); + }, + ); it("uses detected types for extensionless files while keeping known filename icons", () => { assert.equal( resolvePierreIconForEntry("custom-script", "file", "text/x-python")?.token, diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 800e4951ce29..cdfd6d08111d 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -103,6 +103,8 @@ export const T3_PIERRE_ICONS = { ex: "t3-file-icon-elixir", exs: "t3-file-icon-elixir", hs: "t3-file-icon-haskell", + heic: "file-tree-builtin-image", + heif: "file-tree-builtin-image", lhs: "t3-file-icon-haskell", java: "t3-file-icon-java", kt: "t3-file-icon-kotlin", @@ -168,6 +170,8 @@ export function resolvePierreIconForEntry( ): PierreIconResolution | null { if (kind === "directory") return null; const icon = completeIconResolver.resolveIcon("file-tree-icon-file", pathValue); + if (icon?.name === "file-tree-builtin-image" && icon.token === undefined) + return { ...icon, token: "image" }; return icon?.token === "default" && mimeType ? completeIconResolver.resolveIcon( "file-tree-icon-file", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a48338bcdaf2..1f8d0c490573 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -284,6 +284,16 @@ export default defineConfig(() => { emptyOutDir: true, manifest: true, sourcemap: buildSourcemap, + rolldownOptions: { + output: { + codeSplitting: { + // Preview imports must not fold shared file icons back into main. + groups: [ + { name: "PierreEntryIcon", test: /\/components\/chat\/PierreEntryIcon\.tsx$/ }, + ], + }, + }, + }, }, worker: { format: "es" }, test: { From 1b88a01847c631b4ea0fb4e311759367b99fdbde Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 04:45:34 +0000 Subject: [PATCH 03/12] fix(web): keep lazy image decoding compatible with react compiler --- apps/desktop/src/ipc/methods/window.test.ts | 1 + apps/server/src/assets/AssetAccess.test.ts | 16 ++++++++++++---- .../web/src/components/media/AttachmentImage.tsx | 5 ++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 66c706ababfb..a068a5bd01f7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -293,6 +293,7 @@ describe("pickProjectFavicon", () => { "gif", "ico", "heic", + "heif", "jpeg", "jpg", "png", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42ec8ff0c020..9f70a941eb3b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1188,6 +1188,14 @@ describe("AssetAccess", () => { ["woff", "font/woff"], ["woff2", "font/woff2"], ["weba", "audio/webm"], + ["zip", "application/zip"], + ["jar", "application/java-archive"], + ["tar", "application/x-tar"], + ["gz", "application/gzip"], + ["tgz", "application/gzip"], + ["sqlite", "application/vnd.sqlite3"], + ["sqlite3", "application/vnd.sqlite3"], + ["db", "application/vnd.sqlite3"], ] as const) { const attachmentId = `thread-1-00000000-0000-4000-8000-000000000001-${extension}`; const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.${extension}`); @@ -1198,7 +1206,7 @@ describe("AssetAccess", () => { _tag: "attachment", attachmentId, fileName: `report.${extension}`, - mimeType: "application/octet-stream", + mimeType: "text/html", disposition: "inline", }, }); @@ -1256,8 +1264,8 @@ describe("AssetAccess", () => { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-zip"; - const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`); + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-7z"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.7z`); yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); @@ -1265,7 +1273,7 @@ describe("AssetAccess", () => { resource: { _tag: "attachment", attachmentId, - fileName: "archive.zip", + fileName: "archive.7z", mimeType: "text/html", disposition: "inline", }, diff --git a/apps/web/src/components/media/AttachmentImage.tsx b/apps/web/src/components/media/AttachmentImage.tsx index 8693e1c2d38e..294a59309fd1 100644 --- a/apps/web/src/components/media/AttachmentImage.tsx +++ b/apps/web/src/components/media/AttachmentImage.tsx @@ -9,6 +9,9 @@ import { isHeicImageFile } from "../../lib/imageCompression"; type AttachmentImageProps = ComponentProps<"img"> & { name: string; mimeType?: string | undefined }; +// Keep import expressions outside hooks until React Compiler supports them. +const loadImageDecoder = () => import("../files/decodeImagePreview"); + export function AttachmentImage({ name, mimeType, ...props }: AttachmentImageProps) { const fallback = isHeicImageFile({ name, type: mimeType ?? "" }) || @@ -29,7 +32,7 @@ function DecodedImage({ name, mimeType, src, onError, ...props }: AttachmentImag if (!decodeError || !src) return; const controller = new AbortController(); let objectUrl: string | undefined; - void import("../files/decodeImagePreview") + void loadImageDecoder() .then(async ({ decodeImagePreview }) => { if (controller.signal.aborted) return; const blob = await decodeImagePreview( From 98422abe87e20ea48a323a12f01285dce2e7651f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 04:51:37 +0000 Subject: [PATCH 04/12] test(shared): cover binary file downloads across clients --- apps/mobile/src/features/threads/fileChipMenu.test.ts | 10 +++++++++- apps/server/src/assets/AssetAccess.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index 1627a72da5de..21f57f01ce95 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -56,6 +56,14 @@ describe("file chip downloads", () => { ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"], ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"], ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"], + ["/tmp/archive.zip", "/tmp/archive.zip", "application/zip"], + ["/tmp/archive.jar", "/tmp/archive.jar", "application/java-archive"], + ["/tmp/archive.tar", "/tmp/archive.tar", "application/x-tar"], + ["/tmp/archive.gz", "/tmp/archive.gz", "application/gzip"], + ["/tmp/archive.tgz", "/tmp/archive.tgz", "application/gzip"], + ["/tmp/data.sqlite", "/tmp/data.sqlite", "application/vnd.sqlite3"], + ["/tmp/data.sqlite3", "/tmp/data.sqlite3", "application/vnd.sqlite3"], + ["/tmp/data.db", "/tmp/data.db", "application/vnd.sqlite3"], ])("offers a host download for %s", (href, path, mimeType) => { const target = resolveFileChipTarget(href, "/repo")!; expect(fileChipMenu(target).actions).toContainEqual({ @@ -78,7 +86,7 @@ describe("file chip downloads", () => { }); it("does not offer downloads the host asset endpoint cannot serve", () => { - for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { + for (const href of ["src/app.ts", "/tmp/archive.7z", "/tmp/clip.mp4.txt"]) { const target = resolveFileChipTarget(href, "/repo")!; expect(fileChipShareSource(target, threadId)).toBeNull(); expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 9f70a941eb3b..f11061f07665 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -268,6 +268,14 @@ describe("AssetAccess", () => { ["report.pdf", "application/pdf"], ["tone.wav", "audio/wav"], ["tone.webm-audio", "audio/webm"], + ["archive.zip", "application/zip"], + ["archive.jar", "application/java-archive"], + ["archive.tar", "application/x-tar"], + ["archive.gz", "application/gzip"], + ["archive.tgz", "application/gzip"], + ["data.sqlite", "application/vnd.sqlite3"], + ["data.sqlite3", "application/vnd.sqlite3"], + ["data.db", "application/vnd.sqlite3"], ] as const) { const filePath = path.join(outside, name); yield* fs.writeFileString(filePath, "media"); From 5b24af604060dab6f57a42c3602dd4c5d639450f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 18:32:24 +0000 Subject: [PATCH 05/12] fix(web): bound binary parsers and preserve archive entries --- .../AttachmentFilePreview.browser.test.tsx | 137 ++++++++++++++++++ .../files/AttachmentFilePreview.tsx | 8 +- .../web/src/components/files/BinaryViewer.tsx | 10 +- .../web/src/components/files/PreviewImage.tsx | 1 + .../components/files/PreviewImageContent.tsx | 6 +- .../components/files/binaryPreview.worker.ts | 10 +- apps/web/src/components/files/boundedZip.ts | 65 ++++++++- .../components/files/imageDecode.worker.ts | 49 ++++++- 8 files changed, 270 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 40c4bc079bbd..6d87b8a8d8ea 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -18,6 +18,143 @@ vi.mock(import("./ReadOnlySourcePreview"), () => ({ import { AttachmentFilePreview } from "./AttachmentFilePreview"; import LargeTextViewer from "./LargeTextViewer"; import VirtualTablePreview from "./VirtualTablePreview"; +import JSZip from "jszip"; +import { boundedZip } from "./boundedZip"; +import BinaryViewer from "./BinaryViewer"; +import { decodeImagePreview } from "./decodeImagePreview"; + +describe("bounded binary previews", () => { + it("reauthorizes a failed image when retry is clicked", async () => { + const image = + 'data:image/svg+xml,'; + refresh + .mockReset() + .mockResolvedValueOnce("data:image/png;base64,broken") + .mockResolvedValue(image); + await using screen = await render( + , + ); + await vi.waitFor(() => + expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain( + "Unable to load image.", + ), + ); + await act(() => + screen.container.querySelector('[role="alert"] button')!.click(), + ); + await vi.waitFor(() => + expect(screen.container.querySelector("img")?.getAttribute("src")).toBe(image), + ); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it("reads a small ZIP64 archive and rejects understated directory counts", async () => { + const zip = new JSZip().file("readme.txt", "hello"); + const bytes = await zip.generateAsync({ type: "uint8array" }); + const end = bytes.length - 22; + const legacy = new DataView(bytes.buffer); + const extended = new Uint8Array(bytes.length + 76); + extended.set(bytes.subarray(0, end)); + extended.set(bytes.subarray(end), end + 76); + const view = new DataView(extended.buffer); + view.setUint32(end, 0x06064b50, true); + view.setBigUint64(end + 4, 44n, true); + view.setBigUint64(end + 24, 1n, true); + view.setBigUint64(end + 32, 1n, true); + view.setBigUint64(end + 40, BigInt(legacy.getUint32(end + 12, true)), true); + view.setBigUint64(end + 48, BigInt(legacy.getUint32(end + 16, true)), true); + view.setUint32(end + 56, 0x07064b50, true); + view.setBigUint64(end + 64, BigInt(end), true); + view.setUint32(end + 72, 1, true); + view.setUint16(end + 76 + 10, 0xffff, true); + expect( + new TextDecoder().decode((await boundedZip(extended.buffer)).get("readme.txt")?.bytes), + ).toBe("hello"); + + const many = new JSZip(); + for (let i = 0; i < 2049; i++) many.file(`file-${i}`, ""); + const crowded = await many.generateAsync({ type: "arraybuffer" }); + new DataView(crowded).setUint16(crowded.byteLength - 12, 1, true); + await expect(boundedZip(crowded)).rejects.toThrow("more than 2,048 entries"); + }); + + it("keeps a file and directory with the same basename accessible", async () => { + const bytes = await new JSZip() + .file("foo.txt", "root file") + .file("foo.txt/bar.txt", "child") + .generateAsync({ type: "arraybuffer" }); + const src = URL.createObjectURL(new Blob([bytes])); + try { + await using screen = await render(); + await vi.waitFor(() => + expect(screen.container.querySelectorAll("tbody button")).toHaveLength(2), + ); + const buttons = [...screen.container.querySelectorAll("tbody button")]; + expect(buttons.map((button) => button.textContent)).toEqual(["foo.txt", "foo.txt"]); + await act(() => buttons[1]!.click()); + await vi.waitFor(() => expect(screen.container.textContent).toContain("root file")); + } finally { + URL.revokeObjectURL(src); + } + }); + + it("keeps WhiteIsZero TIFF brightness and rejects oversized metadata before decoding", async () => { + const tags = [ + [256, 2], + [257, 1], + [258, 8], + [259, 1], + [262, 0], + [273, 134], + [277, 1], + [278, 1], + [279, 2], + [284, 1], + ]; + const bytes = new ArrayBuffer(136); + const view = new DataView(bytes); + view.setUint16(0, 0x4949); + view.setUint16(2, 42, true); + view.setUint32(4, 8, true); + view.setUint16(8, tags.length, true); + tags.forEach(([tag, value], index) => { + const offset = 10 + index * 12; + view.setUint16(offset, tag!, true); + view.setUint16(offset + 2, 4, true); + view.setUint32(offset + 4, 1, true); + view.setUint32(offset + 8, value!, true); + }); + view.setUint8(135, 255); + const src = URL.createObjectURL(new Blob([bytes])); + try { + const blob = await decodeImagePreview(src, false, new AbortController().signal); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(2, 1); + const context = canvas.getContext("2d")!; + context.drawImage(bitmap, 0, 0); + expect([...context.getImageData(0, 0, 2, 1).data]).toEqual([ + 255, 255, 255, 255, 0, 0, 0, 255, + ]); + bitmap.close(); + } finally { + URL.revokeObjectURL(src); + } + view.setUint32(14, 1_000_000, true); + const invalid = URL.createObjectURL(new Blob([bytes])); + try { + await expect( + decodeImagePreview(invalid, false, new AbortController().signal), + ).rejects.toThrow("metadata"); + } finally { + URL.revokeObjectURL(invalid); + } + }); +}); describe("large text preview recovery", () => { it("recovers from a failed worker load when its source changes", async () => { diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 64ea8b230a02..2187130a6deb 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -293,7 +293,13 @@ export function AttachmentFilePreview(props: { />
) : kind === "image" ? ( - + setError("Unable to load image.")} + /> ) : (

No preview for this file

diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx index 79b56d08c8d2..c324247f2fe9 100644 --- a/apps/web/src/components/files/BinaryViewer.tsx +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -171,9 +171,10 @@ export default function BinaryViewer(props: { const slash = rest.indexOf("/"); const path = slash < 0 ? item.path.replace(/\/$/, "") : `${folder}${rest.slice(0, slash)}`; const directory = slash >= 0 || item.directory; - const previous = children.get(path); + const key = `${path}${directory ? "/" : ""}`; + const previous = children.get(key); if (previous) previous.size += item.size; - else children.set(path, { path, directory, size: item.size, modified: item.modified }); + else children.set(key, { path, directory, size: item.size, modified: item.modified }); } return (
@@ -274,7 +275,10 @@ export default function BinaryViewer(props: { Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path), ) .map((item) => ( - + {expanded ? ( diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index c730b78b4496..5635db119fc3 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -63,7 +63,10 @@ function tarEntries(bytes: Uint8Array) { if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged."); const size = number(offset + 124, 12); const type = text(offset + 156, 1); - const prefix = text(offset + 345, 155); + const prefix = + text(offset + 257, 6) === "ustar" && text(offset + 263, 2) === "00" + ? text(offset + 345, 155) + : ""; const path = longPath ?? [prefix, text(offset, 100)].filter(Boolean).join("/"); longPath = null; if (path.length > 1024 || size > BINARY_PREVIEW_MAX_BYTES || offset + 512 + size > bytes.length) @@ -164,9 +167,10 @@ async function handle(input: BinaryWorkerRequest) { let entries: ArchiveEntry[]; if ( /\.(zip|jar)$/i.test(input.name) || - new DataView(input.bytes).getUint32(0, true) === 0x04034b50 + (input.bytes.byteLength >= 4 && + [0x04034b50, 0x06054b50].includes(new DataView(input.bytes).getUint32(0, true))) ) { - const archive = await boundedZip(input.bytes, ARCHIVE_ENTRY_MAX_BYTES); + const archive = await boundedZip(input.bytes, BINARY_PREVIEW_MAX_BYTES); entries = [...archive].map(([path, file]) => { files.set(path, file.bytes); return { diff --git a/apps/web/src/components/files/boundedZip.ts b/apps/web/src/components/files/boundedZip.ts index 5721fa59a133..e099294e33fb 100644 --- a/apps/web/src/components/files/boundedZip.ts +++ b/apps/web/src/components/files/boundedZip.ts @@ -15,20 +15,75 @@ export async function boundedZip( throw new Error("Preview limited to files under 50 MB."); // Reject oversized directories before JSZip allocates objects for their entries. const view = new DataView(bytes); - let directoryFound = false; + let directoryEnd = -1; + let directorySize = 0; for ( let offset = bytes.byteLength - 22; offset >= Math.max(0, bytes.byteLength - 65557); offset-- ) { if (view.getUint32(offset, true) !== 0x06054b50) continue; - if (offset + 22 + view.getUint16(offset + 20, true) !== bytes.byteLength) continue; - if (view.getUint16(offset + 10, true) > ARCHIVE_MAX_ENTRIES) + if (offset + 22 + view.getUint16(offset + 20, true) !== bytes.byteLength) + throw new Error("This is not a readable ZIP archive."); + let count = view.getUint16(offset + 10, true); + directorySize = view.getUint32(offset + 12, true); + let directoryOffset = view.getUint32(offset + 16, true); + directoryEnd = offset; + if ( + [4, 6, 8, 10].some((field) => view.getUint16(offset + field, true) === 0xffff) || + directorySize === 0xffffffff || + directoryOffset === 0xffffffff + ) { + const locator = offset - 20; + const record = locator - 56; + if ( + record < 0 || + view.getUint32(locator, true) !== 0x07064b50 || + view.getUint32(record, true) !== 0x06064b50 || + view.getBigUint64(record + 4, true) !== 44n || + view.getUint32(record + 16, true) !== 0 || + view.getUint32(record + 20, true) !== 0 || + view.getUint32(locator + 4, true) !== 0 || + view.getUint32(locator + 16, true) !== 1 || + view.getBigUint64(locator + 8, true) > BigInt(record) + ) + throw new Error("Unsupported ZIP64 directory."); + const records = view.getBigUint64(record + 32, true); + const size = view.getBigUint64(record + 40, true); + const start = view.getBigUint64(record + 48, true); + if ( + records > BigInt(ARCHIVE_MAX_ENTRIES) || + size > BigInt(record) || + start > BigInt(record) || + view.getBigUint64(locator + 8, true) !== start + size || + view.getBigUint64(record + 24, true) !== records + ) + throw new Error("This ZIP64 directory is too large or damaged."); + count = Number(records); + directorySize = Number(size); + directoryOffset = Number(start); + directoryEnd = record; + } + if (count > ARCHIVE_MAX_ENTRIES) throw new Error("This archive contains more than 2,048 entries."); - directoryFound = true; + if (directoryOffset > directoryEnd - directorySize) + throw new Error("This ZIP directory is damaged."); break; } - if (!directoryFound) throw new Error("This is not a readable ZIP archive."); + if (directoryEnd < 0 || directorySize > directoryEnd) + throw new Error("This is not a readable ZIP archive."); + let records = 0; + for (let offset = directoryEnd - directorySize; offset < directoryEnd;) { + if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) + throw new Error("This ZIP directory is damaged."); + if (++records > ARCHIVE_MAX_ENTRIES) + throw new Error("This archive contains more than 2,048 entries."); + const nameLength = view.getUint16(offset + 28, true); + if (nameLength > 1024) throw new Error("An archive path is too long to preview."); + offset += + 46 + nameLength + view.getUint16(offset + 30, true) + view.getUint16(offset + 32, true); + if (offset > directoryEnd) throw new Error("This ZIP directory is damaged."); + } const zip = await JSZip.loadAsync(bytes); const entries = Object.values(zip.files); if (entries.length > ARCHIVE_MAX_ENTRIES) diff --git a/apps/web/src/components/files/imageDecode.worker.ts b/apps/web/src/components/files/imageDecode.worker.ts index 2460a2289fc7..cf3c9c121f9b 100644 --- a/apps/web/src/components/files/imageDecode.worker.ts +++ b/apps/web/src/components/files/imageDecode.worker.ts @@ -1,5 +1,45 @@ const MAX_PIXELS = 32_000_000; +function validateTiffMetadata(bytes: ArrayBuffer) { + const view = new DataView(bytes); + if (bytes.byteLength < 8) throw new Error("Invalid TIFF metadata."); + const order = view.getUint16(0); + const littleEndian = order === 0x4949; + if (![0x4949, 0x4d4d].includes(order) || view.getUint16(2, littleEndian) !== 42) + throw new Error("Invalid TIFF metadata."); + const visited = new Set(); + let entries = 0; + let allocation = 0; + const inspect = (offset: number, depth: number) => { + if (depth > 8 || visited.has(offset) || offset < 8 || offset + 2 > bytes.byteLength) + throw new Error("Invalid TIFF metadata."); + visited.add(offset); + const count = view.getUint16(offset, littleEndian); + entries += count; + if (entries > 2048 || offset + 2 + count * 12 + 4 > bytes.byteLength) + throw new Error("This TIFF has too much or truncated metadata."); + for (let index = 0; index < count; index++) { + const entry = offset + 2 + index * 12; + const tag = view.getUint16(entry, littleEndian); + const type = view.getUint16(entry + 2, littleEndian); + const values = view.getUint32(entry + 4, littleEndian); + const width = [0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8][type]; + if (!width) throw new Error("Unsupported TIFF metadata type."); + const length = values * width; + const start = length > 4 ? view.getUint32(entry + 8, littleEndian) : entry + 8; + // The decoder retains a separate array or string for every tag, including aliases. + allocation += values * Math.max(16, width); + if (start + length > bytes.byteLength || allocation > 8 * 1024 * 1024) + throw new Error("This TIFF has too much or truncated metadata."); + if (tag === 0x8769 || tag === 0x8825) { + if (type !== 4 || values !== 1) throw new Error("Invalid TIFF metadata pointer."); + inspect(view.getUint32(entry + 8, littleEndian), depth + 1); + } + } + }; + inspect(view.getUint32(4, littleEndian), 0); +} + function dimensions(width: number, height: number) { if (!(width > 0 && height > 0) || width > 16384 || height > 16384 || width > MAX_PIXELS / height) throw new Error("Image previews are limited to 32 megapixels and 16,384 pixels per side."); @@ -101,8 +141,7 @@ async function validateTiffStrips(bytes: ArrayBuffer, image: import("tiff").Tiff } finally { await reader.cancel(); } - } else if (image.compression !== 1 && image.compression !== 32773) - throw new Error("This TIFF compression is not supported."); + } else if (image.compression !== 1) throw new Error("This TIFF compression is not supported."); // Bound repeated strips and padding as well as the visible image dimensions. expanded += limit; if (expanded > 256 * 1024 * 1024) throw new Error("This TIFF has too much image data."); @@ -117,10 +156,14 @@ async function decode(bytes: ArrayBuffer, heif: boolean) { return await heicTo({ blob: new Blob([bytes]), type: "image/png" }); } const { decode: decodeTiff } = await import("tiff"); + validateTiffMetadata(bytes); const metadata = decodeTiff(bytes, { ignoreImageData: true, pages: [0] })[0]; if (!metadata) throw new Error("This TIFF has no readable image."); dimensions(metadata.width, metadata.height); + if (metadata.orientation && metadata.orientation !== 1) + throw new Error("This TIFF orientation is not supported."); if ( + (metadata.tiled && metadata.components > 1) || ![1, 2, 3, 4].includes(metadata.components) || ![8, 16].includes(metadata.bitsPerSample) || ![0, 1, 2, 3].includes(metadata.type) @@ -142,7 +185,7 @@ async function decode(bytes: ArrayBuffer, heif: boolean) { rgba[pixel * 4 + channel] = image.data[index + channel]! / scale; } else { const gray = image.data[index]! / scale; - rgba.fill(image.type === 0 ? 255 - gray : gray, pixel * 4, pixel * 4 + 3); + rgba.fill(gray, pixel * 4, pixel * 4 + 3); } rgba[pixel * 4 + 3] = image.alpha ? image.data[index + image.components - 1]! / scale : 255; } From 53395e974567191c27878e7e2386736ceb4c6231 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 19:06:25 +0000 Subject: [PATCH 06/12] fix(web): preserve pax payloads and generated sqlite columns --- .../AttachmentFilePreview.browser.test.tsx | 127 ++++++++++++++++++ .../web/src/components/files/BinaryViewer.tsx | 2 +- .../components/files/binaryPreview.worker.ts | 30 ++++- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 691d6da096e5..61ae892bdaf6 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -24,6 +24,133 @@ import BinaryViewer from "./BinaryViewer"; import { decodeImagePreview } from "./decodeImagePreview"; describe("bounded binary previews", () => { + it("can leave an archive folder with a repeated separator", async () => { + const bytes = new Uint8Array(2048); + const write = (offset: number, value: string) => + bytes.set(new TextEncoder().encode(value), offset); + write(0, "a//hello.txt"); + write(124, "00000000014\0"); + bytes.fill(32, 148, 156); + write(156, "0"); + write( + 148, + bytes + .subarray(0, 512) + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0") + "\0 ", + ); + write(512, "nested hello"); + const src = URL.createObjectURL(new Blob([bytes])); + try { + await using screen = await render(); + const child = () => screen.container.querySelector("tbody button"); + await vi.waitFor(() => expect(child()?.textContent).toBe("a")); + await act(() => child()!.click()); + await act(() => child()!.click()); + await vi.waitFor(() => expect(child()?.textContent).toBe("hello.txt")); + const parent = () => + screen.container.querySelector('[aria-label="Parent folder"]')!; + await act(() => parent().click()); + await act(() => parent().click()); + expect(parent().disabled).toBe(true); + expect(child()?.textContent).toBe("a"); + } finally { + URL.revokeObjectURL(src); + } + }); + + it("uses a PAX size override for the following TAR payload and bounds it", async () => { + const archive = (size: string) => { + const header = (name: string, size: number, type: string) => { + const bytes = new Uint8Array(512); + const write = (offset: number, value: string) => + bytes.set(new TextEncoder().encode(value), offset); + write(0, name); + write(124, size.toString(8).padStart(11, "0") + "\0"); + bytes.fill(32, 148, 156); + write(156, type); + write( + 148, + bytes + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0") + "\0 ", + ); + return bytes; + }; + const payload = ` size=${size}\n`; + let record = `${payload.length + 1}${payload}`; + while (record.length !== Number.parseInt(record, 10)) record = `${record.length}${payload}`; + const bytes = new Uint8Array(3072); + bytes.set(header("PaxHeader", record.length, "x")); + bytes.set(new TextEncoder().encode(record), 512); + bytes.set(header("hello.txt", 0, "0"), 1024); + bytes.set(new TextEncoder().encode("hello"), 1536); + return bytes; + }; + for (const size of ["5", "999999999999999999999"]) { + const src = URL.createObjectURL(new Blob([archive(size)])); + try { + await using screen = await render(); + if (size === "5") { + await vi.waitFor(() => + expect(screen.container.querySelector("tbody button")?.textContent).toBe("hello.txt"), + ); + await act(() => + screen.container.querySelector("tbody button")!.click(), + ); + await vi.waitFor(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("hello"), + ); + } else { + await vi.waitFor(() => + expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain( + "too large or damaged", + ), + ); + } + } finally { + URL.revokeObjectURL(src); + } + } + }); + + it("shows stored and virtual generated SQLite columns", async () => { + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + const database = new SQL.Database(); + database.run( + "CREATE TABLE orders (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO orders(price, quantity) VALUES (7, 3)", + ); + const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())])); + database.close(); + try { + await using screen = await render( + , + ); + await vi.waitFor(() => + expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([ + "price", + "quantity", + "total", + "doubled", + ]), + ); + expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ + "7", + "3", + "21", + "14", + ]); + } finally { + URL.revokeObjectURL(src); + } + }); + it("reauthorizes a failed image when retry is clicked", async () => { const image = 'data:image/svg+xml,'; diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx index c324247f2fe9..277c4962a9f3 100644 --- a/apps/web/src/components/files/BinaryViewer.tsx +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -187,7 +187,7 @@ export default function BinaryViewer(props: { setError(null); setEntry(null); if (selectedPath) setSelectedPath(null); - else setFolder(folder.replace(/[^/]+\/$/, "")); + else setFolder(folder.replace(/(?:[^/]+)?\/$/, "")); }} > diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index 5635db119fc3..da6e3da5b247 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -52,6 +52,7 @@ function tarEntries(bytes: Uint8Array) { return Number.parseInt(value || "0", 8); }; let longPath: string | null = null; + let paxSize: number | null = null; let offset = 0; while (offset + 512 <= bytes.length) { if (bytes.subarray(offset, offset + 512).every((byte) => byte === 0)) break; @@ -61,14 +62,18 @@ function tarEntries(bytes: Uint8Array) { for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : bytes[offset + index]!; if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged."); - const size = number(offset + 124, 12); const type = text(offset + 156, 1); + const metadata = type === "L" || type === "x" || type === "g"; + const size = !metadata && paxSize !== null ? paxSize : number(offset + 124, 12); const prefix = text(offset + 257, 6) === "ustar" && text(offset + 263, 2) === "00" ? text(offset + 345, 155) : ""; const path = longPath ?? [prefix, text(offset, 100)].filter(Boolean).join("/"); - longPath = null; + if (!metadata) { + longPath = null; + paxSize = null; + } if (path.length > 1024 || size > BINARY_PREVIEW_MAX_BYTES || offset + 512 + size > bytes.length) throw new Error("An archive entry is too large or damaged."); const content = bytes.subarray(offset + 512, offset + 512 + size); @@ -76,8 +81,20 @@ function tarEntries(bytes: Uint8Array) { else if (type === "x" || type === "g") { if (size > 65536) throw new Error("Archive metadata is too large."); for (const line of new TextDecoder().decode(content).split("\n")) { - const match = /^\d+ path=(.*)$/.exec(line); - if (match && type === "x") longPath = match[1]!; + const match = /^\d+ (path|size)=(.*)$/.exec(line); + if (!match || type !== "x") continue; + if (match[1] === "path") longPath = match[2]!; + else { + const value = match[2]!; + const size = Number(value); + if ( + !/^\d+$/.test(value) || + !Number.isSafeInteger(size) || + size > BINARY_PREVIEW_MAX_BYTES + ) + throw new Error("An archive entry is too large or damaged."); + paxSize = size; + } } } else if (type === "" || type === "0" || type === "5") { const modified = number(offset + 136, 12) * 1000; @@ -115,8 +132,9 @@ async function handle(input: BinaryWorkerRequest) { throw new Error("This table is unavailable."); const columns = database - .exec(`PRAGMA table_info(${quote(input.table)})`)[0] - ?.values.map((row) => String(row[1])) + .exec(`PRAGMA table_xinfo(${quote(input.table)})`)[0] + ?.values.filter((row) => row[6] !== 1) + .map((row) => String(row[1])) .slice(0, 50) ?? []; const fields = columns.map((name) => { const field = quote(name); From c8957ed1bdf8642b4b75e91e254439422afc40ec Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 19:35:31 +0000 Subject: [PATCH 07/12] fix(web): preserve pax attributes and sqlite table names --- .../AttachmentFilePreview.browser.test.tsx | 83 ++++++++++++++++++- .../components/files/binaryPreview.worker.ts | 67 ++++++++++----- apps/web/vite.config.ts | 2 +- 3 files changed, 129 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 5c3d9385e4d7..3fa86397df38 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -22,8 +22,89 @@ import JSZip from "jszip"; import { boundedZip } from "./boundedZip"; import BinaryViewer from "./BinaryViewer"; import { decodeImagePreview } from "./decodeImagePreview"; +import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; describe("bounded binary previews", () => { + it("preserves length-delimited local and global PAX attributes", async () => { + const encode = (value: string) => new TextEncoder().encode(value); + const block = (name: string, content: Uint8Array, type = "0", size = content.length) => { + const bytes = new Uint8Array(512 + Math.ceil(content.length / 512) * 512); + const write = (offset: number, value: string) => bytes.set(encode(value), offset); + write(0, name); + write(124, size.toString(8).padStart(11, "0") + "\0"); + bytes.fill(32, 148, 156); + write(156, type); + write( + 148, + bytes + .subarray(0, 512) + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0") + "\0 ", + ); + bytes.set(content, 512); + return bytes; + }; + const attributes = (values: Record) => + encode( + Object.entries(values) + .map(([key, value]) => { + const payload = ` ${key}=${value}\n`; + let record = `${encode(payload).length + 1}${payload}`; + while (encode(record).length !== Number.parseInt(record, 10)) + record = `${encode(record).length}${payload}`; + return record; + }) + .join(""), + ); + const chunks = [ + block("Global", attributes({ size: "5", mtime: "-86400" }), "g"), + block("Local", attributes({ path: "café\n9 size=1\nname.txt" }), "x"), + block("first.txt", encode("hello"), "0", 0), + block("world.txt", encode("world"), "0", 0), + block("Local", attributes({ size: "3", mtime: "-172800" }), "x"), + block("short.txt", encode("bye"), "0", 0), + block("again.txt", encode("again"), "0", 0), + new Uint8Array(1024), + ]; + const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + const worker = new Worker(new URL("./binaryPreview.worker.ts", import.meta.url), { + type: "module", + }); + const request = (input: object) => + new Promise((resolve, reject) => { + worker.addEventListener("message", (event) => resolve(event.data), { once: true }); + worker.addEventListener("error", reject, { once: true }); + worker.postMessage(input, []); + }); + try { + const result = await request({ + type: "open", + kind: "archive", + name: "pax.tar", + bytes: bytes.buffer, + }); + expect(result.type).toBe("archive"); + if (result.type !== "archive") throw new Error("Expected archive"); + expect(result.entries.map((entry) => [entry.path, entry.size, entry.modified])).toEqual([ + ["café\n9 size=1\nname.txt", 5, "1969-12-31T00:00:00.000Z"], + ["world.txt", 5, "1969-12-31T00:00:00.000Z"], + ["short.txt", 3, "1969-12-30T00:00:00.000Z"], + ["again.txt", 5, "1969-12-31T00:00:00.000Z"], + ]); + const entry = await request({ type: "entry", path: "café\n9 size=1\nname.txt" }); + if (entry.type !== "entry") throw new Error("Expected entry"); + expect(new TextDecoder().decode(entry.bytes)).toBe("hello"); + } finally { + worker.terminate(); + } + }); + it("can leave an archive folder with a repeated separator", async () => { const bytes = new Uint8Array(2048); const write = (offset: number, value: string) => @@ -124,7 +205,7 @@ describe("bounded binary previews", () => { const SQL = await initSqlJs({ locateFile: () => wasmUrl }); const database = new SQL.Database(); database.run( - "CREATE TABLE orders (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO orders(price, quantity) VALUES (7, 3)", + "CREATE TABLE sqliteData (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO sqliteData(price, quantity) VALUES (7, 3)", ); const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())])); database.close(); diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index da6e3da5b247..d2ec9e88ea80 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -52,7 +52,8 @@ function tarEntries(bytes: Uint8Array) { return Number.parseInt(value || "0", 8); }; let longPath: string | null = null; - let paxSize: number | null = null; + const globalPax: Record = {}; + let localPax: Record = {}; let offset = 0; while (offset + 512 <= bytes.length) { if (bytes.subarray(offset, offset + 512).every((byte) => byte === 0)) break; @@ -64,40 +65,64 @@ function tarEntries(bytes: Uint8Array) { if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged."); const type = text(offset + 156, 1); const metadata = type === "L" || type === "x" || type === "g"; - const size = !metadata && paxSize !== null ? paxSize : number(offset + 124, 12); + const attributes: Record = metadata ? {} : { ...globalPax, ...localPax }; + const size = attributes.size ? Number(attributes.size) : number(offset + 124, 12); + if (attributes.size && !/^\d+$/.test(attributes.size)) + throw new Error("An archive entry is too large or damaged."); const prefix = text(offset + 257, 6) === "ustar" && text(offset + 263, 2) === "00" ? text(offset + 345, 155) : ""; - const path = longPath ?? [prefix, text(offset, 100)].filter(Boolean).join("/"); + const path = + attributes.path || longPath || [prefix, text(offset, 100)].filter(Boolean).join("/"); if (!metadata) { longPath = null; - paxSize = null; + localPax = {}; } - if (path.length > 1024 || size > BINARY_PREVIEW_MAX_BYTES || offset + 512 + size > bytes.length) + if ( + path.length > 1024 || + !Number.isSafeInteger(size) || + size < 0 || + size > BINARY_PREVIEW_MAX_BYTES || + offset + 512 + size > bytes.length + ) throw new Error("An archive entry is too large or damaged."); const content = bytes.subarray(offset + 512, offset + 512 + size); if (type === "L") longPath = new TextDecoder().decode(content).replace(/\0.*$/s, ""); else if (type === "x" || type === "g") { if (size > 65536) throw new Error("Archive metadata is too large."); - for (const line of new TextDecoder().decode(content).split("\n")) { - const match = /^\d+ (path|size)=(.*)$/.exec(line); - if (!match || type !== "x") continue; - if (match[1] === "path") longPath = match[2]!; - else { - const value = match[2]!; - const size = Number(value); - if ( - !/^\d+$/.test(value) || - !Number.isSafeInteger(size) || - size > BINARY_PREVIEW_MAX_BYTES - ) - throw new Error("An archive entry is too large or damaged."); - paxSize = size; + for (let cursor = 0; cursor < content.length;) { + const separator = content.indexOf(32, cursor); + if (separator < cursor || separator - cursor > 10) + throw new Error("The TAR metadata is damaged."); + const lengthText = new TextDecoder().decode(content.subarray(cursor, separator)); + const length = Number(lengthText); + const end = cursor + length; + if ( + !/^[1-9]\d*$/.test(lengthText) || + !Number.isSafeInteger(length) || + end > content.length || + end <= separator + 1 || + content[end - 1] !== 10 + ) + throw new Error("The TAR metadata is damaged."); + const record = new TextDecoder().decode(content.subarray(separator + 1, end - 1)); + const equals = record.indexOf("="); + if (equals < 1) throw new Error("The TAR metadata is damaged."); + const key = record.slice(0, equals); + const value = record.slice(equals + 1); + if (["path", "size", "mtime"].includes(key)) { + if (type === "x") localPax[key] = value; + else if (value) globalPax[key] = value; + else delete globalPax[key]; } + cursor = end; } } else if (type === "" || type === "0" || type === "5") { - const modified = number(offset + 136, 12) * 1000; + if (attributes.mtime && !/^[+-]?\d+(?:\.\d+)?$/.test(attributes.mtime)) + throw new Error("The TAR metadata is damaged."); + const modified = + (attributes.mtime ? Number(attributes.mtime) : number(offset + 136, 12)) * 1000; const date = new Date(modified); entries.push({ path, @@ -167,7 +192,7 @@ async function handle(input: BinaryWorkerRequest) { tables = database .exec( - "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name LIMIT 101", + "SELECT name FROM sqlite_schema WHERE type='table' AND substr(name, 1, 7) != 'sqlite_' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name LIMIT 101", )[0] ?.values.map((row) => String(row[0])) ?? []; if (tables.length > 100) throw new Error("Database previews are limited to 100 tables."); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 61ef215d9913..c0fcc745b062 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -95,7 +95,7 @@ const browserTestApiPort = 61_000 + ((Hash.string(import.meta.dirname) >>> 0) % const browserTestProject = { extends: true, // Pre-bundled so the first test importing userEvent does not trigger a mid-run reload. - optimizeDeps: { include: ["vite-plus/test/browser"] }, + optimizeDeps: { include: ["vite-plus/test/browser", "heic-to/next", "tiff"] }, test: { name: "browser", include: ["src/**/*.browser.test.{ts,tsx}"], From 337b5c14992dbaf39169b8fda49b4ac9e86a73c4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 20:13:02 +0000 Subject: [PATCH 08/12] test(web): allow cold preview workers to finish in CI --- .../AttachmentFilePreview.browser.test.tsx | 65 ++++++++++++------- apps/web/vite.config.ts | 4 +- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 93e48d7d53d9..8849c5e8d153 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -24,6 +24,8 @@ import BinaryViewer from "./BinaryViewer"; import { decodeImagePreview } from "./decodeImagePreview"; import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; +// Real worker imports and WASM initialization can exceed waitFor's default second +// when the full browser project runs concurrently on CI. describe("bounded binary previews", () => { it("preserves length-delimited local and global PAX attributes", async () => { const encode = (value: string) => new TextEncoder().encode(value); @@ -126,7 +128,7 @@ describe("bounded binary previews", () => { try { await using screen = await render(); const child = () => screen.container.querySelector("tbody button"); - await vi.waitFor(() => expect(child()?.textContent).toBe("a")); + await vi.waitFor(() => expect(child()?.textContent).toBe("a"), 10_000); await act(() => child()!.click()); await act(() => child()!.click()); await vi.waitFor(() => expect(child()?.textContent).toBe("hello.txt")); @@ -175,20 +177,25 @@ describe("bounded binary previews", () => { try { await using screen = await render(); if (size === "5") { - await vi.waitFor(() => - expect(screen.container.querySelector("tbody button")?.textContent).toBe("hello.txt"), + await vi.waitFor( + () => + expect(screen.container.querySelector("tbody button")?.textContent).toBe("hello.txt"), + 10_000, ); await act(() => screen.container.querySelector("tbody button")!.click(), ); - await vi.waitFor(() => - expect(screen.container.querySelector("pre")?.textContent).toBe("hello"), + await vi.waitFor( + () => expect(screen.container.querySelector("pre")?.textContent).toBe("hello"), + 10_000, ); } else { - await vi.waitFor(() => - expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain( - "too large or damaged", - ), + await vi.waitFor( + () => + expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain( + "too large or damaged", + ), + 10_000, ); } } finally { @@ -213,13 +220,15 @@ describe("bounded binary previews", () => { await using screen = await render( , ); - await vi.waitFor(() => - expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([ - "price", - "quantity", - "total", - "doubled", - ]), + await vi.waitFor( + () => + expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([ + "price", + "quantity", + "total", + "doubled", + ]), + 10_000, ); expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ "7", @@ -299,13 +308,14 @@ describe("bounded binary previews", () => { const src = URL.createObjectURL(new Blob([bytes])); try { await using screen = await render(); - await vi.waitFor(() => - expect(screen.container.querySelectorAll("tbody button")).toHaveLength(2), + await vi.waitFor( + () => expect(screen.container.querySelectorAll("tbody button")).toHaveLength(2), + 10_000, ); const buttons = [...screen.container.querySelectorAll("tbody button")]; expect(buttons.map((button) => button.textContent)).toEqual(["foo.txt", "foo.txt"]); await act(() => buttons[1]!.click()); - await vi.waitFor(() => expect(screen.container.textContent).toContain("root file")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("root file"), 10_000); } finally { URL.revokeObjectURL(src); } @@ -467,8 +477,9 @@ describe("large text preview recovery", () => { await using screen = await render( , ); - await vi.waitFor(() => - expect(screen.container.querySelector('[role="alert"]')).not.toBeNull(), + await vi.waitFor( + () => expect(screen.container.querySelector('[role="alert"]')).not.toBeNull(), + 10_000, ); } finally { URL.revokeObjectURL(src); @@ -497,7 +508,7 @@ describe("large text preview recovery", () => {
, ); - await vi.waitFor(() => expect(screen.container.textContent).toContain("current-40")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("current-40"), 10_000); expect(screen.container.textContent).not.toContain("old-"); } finally { URL.revokeObjectURL(src); @@ -590,11 +601,15 @@ describe("large text preview recovery", () => { await using screen = await render( , ); - await vi.waitFor(() => - expect(screen.container.textContent).toContain("This file contains binary data."), + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file contains binary data."), + 10_000, ); await screen.rerender(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("This file is empty.")); + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file is empty."), + 10_000, + ); expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index c0fcc745b062..0a1d09b8484c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -94,8 +94,8 @@ const browserTestApiPort = 61_000 + ((Hash.string(import.meta.dirname) >>> 0) % const browserTestProject = { extends: true, - // Pre-bundled so the first test importing userEvent does not trigger a mid-run reload. - optimizeDeps: { include: ["vite-plus/test/browser", "heic-to/next", "tiff"] }, + // Pre-bundle lazy browser dependencies so their first use cannot trigger a mid-run reload. + optimizeDeps: { include: ["vite-plus/test/browser", "heic-to/next", "sql.js", "tiff"] }, test: { name: "browser", include: ["src/**/*.browser.test.{ts,tsx}"], From 04bce05e6676a3cdb9dc48e01677cff2abf1dce7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 20:36:06 +0000 Subject: [PATCH 09/12] fix(web): preserve invalid office document errors --- apps/web/src/components/files/officePreview.worker.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/files/officePreview.worker.ts b/apps/web/src/components/files/officePreview.worker.ts index abf7a3590474..37abcd8487dc 100644 --- a/apps/web/src/components/files/officePreview.worker.ts +++ b/apps/web/src/components/files/officePreview.worker.ts @@ -24,6 +24,10 @@ async function boundedArchive(bytes: ArrayBuffer, spreadsheet: boolean) { totalLimit: spreadsheet ? 128 * 1024 * 1024 : 50 * 1024 * 1024, xmlLimit: spreadsheet ? 64 * 1024 * 1024 : 8 * 1024 * 1024, ratioFloor: 8 * 1024 * 1024, + }).catch((error: unknown) => { + if (error instanceof Error && error.message === "This is not a readable ZIP archive.") + throw new Error("This file is damaged, encrypted, or not a valid Office document."); + throw error; }); const archive = new JSZip(); for (const [name, file] of files) { From 54bf5e814aeb5ea40e9251f5f9feef9062911978 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 22:08:24 +0000 Subject: [PATCH 10/12] fix(web): validate zip64 targets and retry binary previews --- .../AttachmentFilePreview.browser.test.tsx | 76 ++++++++++++++++++- .../files/AttachmentFilePreview.tsx | 8 +- .../components/files/BinaryFilePreview.tsx | 1 + .../web/src/components/files/BinaryViewer.tsx | 49 ++++++++---- apps/web/src/components/files/boundedZip.ts | 2 +- 5 files changed, 119 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 0a2a2b128c70..fde0cc52f20e 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2,6 +2,7 @@ import { act, render, stubGlobal, type Screen } from "../../../test/render"; import "~/index.css"; import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; const { refresh } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>() })); @@ -214,7 +215,7 @@ describe("bounded binary previews", () => { const SQL = await initSqlJs({ locateFile: () => wasmUrl }); const database = new SQL.Database(); database.run( - "CREATE TABLE sqliteData (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO sqliteData(price, quantity) VALUES (7, 3)", + "CREATE TABLE sqliteData (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO sqliteData(price, quantity) VALUES (7, 3); CREATE TABLE zzInventory (stock INTEGER); INSERT INTO zzInventory VALUES(42)", ); const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())])); database.close(); @@ -238,6 +239,15 @@ describe("bounded binary previews", () => { "21", "14", ]); + await act(() => + screen.container.querySelector('[aria-label="Table"]')!.click(), + ); + await page.getByRole("option", { name: "zzInventory (1 rows)" }).click(); + await vi.waitFor(() => + expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ + "42", + ]), + ); } finally { URL.revokeObjectURL(src); } @@ -295,6 +305,15 @@ describe("bounded binary previews", () => { new TextDecoder().decode((await boundedZip(extended.buffer)).get("readme.txt")?.bytes), ).toBe("hello"); + const redirected = new Uint8Array(extended.length + 56); + redirected.set(extended.subarray(0, end + 56)); + redirected.set(extended.subarray(end + 56), end + 112); + const redirect = new DataView(redirected.buffer); + redirect.setUint32(end + 56, 0x06064b50, true); + redirect.setBigUint64(end + 60, 44n, true); + redirect.setBigUint64(end + 104, BigInt(end), true); + await expect(boundedZip(redirected.buffer)).rejects.toThrow("Unsupported ZIP64 directory"); + const many = new JSZip(); for (let i = 0; i < 2049; i++) many.file(`file-${i}`, ""); const crowded = await many.generateAsync({ type: "arraybuffer" }); @@ -932,3 +951,58 @@ it("copies every indexed line rather than the mounted preview rows", async () => await vi.waitFor(() => expect(screen.container.textContent).toContain("line-0")); await vi.waitFor(async () => expect(await copyContents.current!()).toBe(text)); }); + +it.each(["archive", "sqlite"] as const)( + "reauthorizes and retries a failed %s attachment", + async (kind) => { + const bad = URL.createObjectURL(new Blob(["invalid"])); + let bytes: Uint8Array; + if (kind === "archive") + bytes = new Uint8Array( + await new JSZip().file("recovered.txt", "ok").generateAsync({ type: "uint8array" }), + ); + else { + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + const database = new SQL.Database(); + database.run( + "CREATE TABLE recovered (value TEXT); INSERT INTO recovered VALUES('retry succeeded')", + ); + bytes = new Uint8Array(database.export()); + database.close(); + } + const good = URL.createObjectURL(new Blob([bytes])); + refresh.mockReset().mockResolvedValueOnce(bad).mockResolvedValue(good); + try { + await using screen = await render( + , + ); + await vi.waitFor( + () => expect(screen.container.querySelector('[role="alert"] button')).not.toBeNull(), + 10_000, + ); + await act(() => + screen.container.querySelector('[role="alert"] button')!.click(), + ); + await vi.waitFor( + () => + expect(screen.container.textContent).toContain( + kind === "archive" ? "recovered.txt" : "retry succeeded", + ), + 10_000, + ); + expect(refresh).toHaveBeenCalledTimes(2); + } finally { + URL.revokeObjectURL(bad); + URL.revokeObjectURL(good); + } + }, +); diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 39454825b3fd..5f59053210f0 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -275,7 +275,13 @@ export function AttachmentFilePreview(props: { ) ) : kind === "archive" || kind === "sqlite" ? ( - + setRevision((value) => value + 1)} + /> ) : kind === "font" ? ( ) : officeKind ? ( diff --git a/apps/web/src/components/files/BinaryFilePreview.tsx b/apps/web/src/components/files/BinaryFilePreview.tsx index e20f259a8755..bc3295ad6168 100644 --- a/apps/web/src/components/files/BinaryFilePreview.tsx +++ b/apps/web/src/components/files/BinaryFilePreview.tsx @@ -8,6 +8,7 @@ export function BinaryFilePreview(props: { readonly src: string; readonly name: string; readonly kind: "archive" | "sqlite"; + readonly onRetry?: () => void; }) { return ( }> diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx index 277c4962a9f3..23c28505745a 100644 --- a/apps/web/src/components/files/BinaryViewer.tsx +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -8,6 +8,14 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments" import { ChevronLeftIcon, ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react"; import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { + Select, + SelectTrigger, + SelectValue, + SelectPopup, + SelectItem, +} from "~/components/ui/select"; + import { FileSurfaceAction, FileSurfaceFailure, @@ -72,6 +80,7 @@ export default function BinaryViewer(props: { readonly src: string; readonly name: string; readonly kind: "archive" | "sqlite"; + readonly onRetry?: () => void; }) { const [archive, setArchive] = useState(null); const [database, setDatabase] = useState(null); @@ -203,19 +212,28 @@ export default function BinaryViewer(props: { {database && table ? ( <>
- +
+ +
) : null} {error ? ( - + ) : busy ? ( ) : selectedPath ? ( diff --git a/apps/web/src/components/files/boundedZip.ts b/apps/web/src/components/files/boundedZip.ts index 38f8b78c6bf8..bd976d99ed11 100644 --- a/apps/web/src/components/files/boundedZip.ts +++ b/apps/web/src/components/files/boundedZip.ts @@ -50,7 +50,7 @@ export async function boundedZip( view.getUint32(record + 20, true) !== 0 || view.getUint32(locator + 4, true) !== 0 || view.getUint32(locator + 16, true) !== 1 || - view.getBigUint64(locator + 8, true) > BigInt(record) + view.getBigUint64(locator + 8, true) !== BigInt(record) ) throw new Error("Unsupported ZIP64 directory."); const records = view.getBigUint64(record + 32, true); From 132ee240d4ab65e331e87669d5035d6276a2203e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 21 Oct 2026 03:18:29 +0000 Subject: [PATCH 11/12] fix(web): preserve sqlite table picker keyboard focus --- .../AttachmentFilePreview.browser.test.tsx | 100 ++++++++++-------- .../web/src/components/files/BinaryViewer.tsx | 5 +- 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index a3867728b14b..ffed7165dbcf 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -1,7 +1,7 @@ import { act, render, stubGlobal, type Screen } from "../../../test/render"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; const { refresh } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>() })); @@ -207,51 +207,65 @@ describe("bounded binary previews", () => { } }); - it("shows stored and virtual generated SQLite columns", async () => { - const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ - import("sql.js"), - import("sql.js/dist/sql-wasm.wasm?url"), - ]); - const SQL = await initSqlJs({ locateFile: () => wasmUrl }); - const database = new SQL.Database(); - database.run( - "CREATE TABLE sqliteData (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO sqliteData(price, quantity) VALUES (7, 3); CREATE TABLE zzInventory (stock INTEGER); INSERT INTO zzInventory VALUES(42)", - ); - const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())])); - database.close(); - try { - await using screen = await render( - , - ); - await vi.waitFor( - () => - expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([ - "price", - "quantity", - "total", - "doubled", - ]), - 10_000, - ); - expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ - "7", - "3", - "21", - "14", + it.each([320, 1280])( + "shows generated SQLite columns and retains picker focus at %i px", + async (width) => { + await page.viewport(width, 800); + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), ]); - await act(() => - screen.container.querySelector('[aria-label="Table"]')!.click(), + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + const database = new SQL.Database(); + database.run( + "CREATE TABLE sqliteData (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO sqliteData(price, quantity) VALUES (7, 3); CREATE TABLE zzInventory (stock INTEGER); INSERT INTO zzInventory VALUES(42)", ); - await page.getByRole("option", { name: "zzInventory (1 rows)" }).click(); - await vi.waitFor(() => + const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())])); + database.close(); + try { + await using screen = await render( + , + ); + await vi.waitFor( + () => + expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([ + "price", + "quantity", + "total", + "doubled", + ]), + 10_000, + ); expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ - "42", - ]), - ); - } finally { - URL.revokeObjectURL(src); - } - }); + "7", + "3", + "21", + "14", + ]); + const trigger = screen.container.querySelector('[aria-label="Table"]')!; + await act(() => trigger.focus()); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByRole("option", { name: "zzInventory (1 rows)" })) + .toBeVisible(); + await userEvent.keyboard("{ArrowDown}{Enter}"); + await vi.waitFor(() => + expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ + "42", + ]), + ); + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByRole("option", { name: "zzInventory (1 rows)" })) + .toBeVisible(); + await userEvent.keyboard("{Escape}"); + } finally { + URL.revokeObjectURL(src); + await page.viewport(1280, 800); + } + }, + ); it("reauthorizes a failed image when retry is clicked", async () => { const image = diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx index 23c28505745a..85fc5aab7e77 100644 --- a/apps/web/src/components/files/BinaryViewer.tsx +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -215,12 +215,11 @@ export default function BinaryViewer(props: {