From 0ef421c8a6f2a75ecb9c421a3aa9dced0e6c0ffb Mon Sep 17 00:00:00 2001 From: piotrnowakowski <61783713+piotrnowakowski@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:05:49 +0200 Subject: [PATCH 1/4] feat(files): preview Word documents and Excel workbooks --- .../features/files/AttachmentFileScreen.tsx | 3 +- apps/server/src/assets/AssetAccess.test.ts | 40 +++++ apps/server/src/assets/AssetAccess.ts | 42 +++-- apps/web/package.json | 2 + .../files/AttachmentFilePreview.tsx | 10 ++ apps/web/src/components/files/DocxPreview.tsx | 42 +++++ .../src/components/files/FilePreviewPanel.tsx | 155 +++++++++++++++++- .../components/files/OfficeFilePreview.tsx | 74 +++++++++ .../components/files/SpreadsheetPreview.tsx | 135 +++++++++++++++ .../src/components/files/docxDocument.test.ts | 32 ++++ apps/web/src/components/files/docxDocument.ts | 24 +++ .../components/files/officeArchive.test.ts | 41 +++++ .../web/src/components/files/officeArchive.ts | 58 +++++++ .../files/spreadsheetDocument.test.ts | 107 ++++++++++++ .../components/files/spreadsheetDocument.ts | 54 ++++++ .../files/spreadsheetPreview.worker.ts | 29 ++++ .../files/spreadsheetPreviewModel.ts | 14 ++ docs/user/composer.md | 12 ++ .../client-runtime/src/filePreview.test.ts | 44 ++++- packages/client-runtime/src/filePreview.ts | 32 +++- packages/shared/src/filePreview.test.ts | 18 ++ packages/shared/src/filePreview.ts | 27 ++- pnpm-lock.yaml | 78 ++++++--- 23 files changed, 1021 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/components/files/DocxPreview.tsx create mode 100644 apps/web/src/components/files/OfficeFilePreview.tsx create mode 100644 apps/web/src/components/files/SpreadsheetPreview.tsx create mode 100644 apps/web/src/components/files/docxDocument.test.ts create mode 100644 apps/web/src/components/files/docxDocument.ts create mode 100644 apps/web/src/components/files/officeArchive.test.ts create mode 100644 apps/web/src/components/files/officeArchive.ts create mode 100644 apps/web/src/components/files/spreadsheetDocument.test.ts create mode 100644 apps/web/src/components/files/spreadsheetDocument.ts create mode 100644 apps/web/src/components/files/spreadsheetPreview.worker.ts create mode 100644 apps/web/src/components/files/spreadsheetPreviewModel.ts diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index e877b165eb9f..366e088da514 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -49,7 +49,8 @@ type AttachmentFileScreenProps = StaticScreenProps; function nativeViewerKind(kind: ReturnType["kind"]) { if (kind === "image") return "image" as const; if (kind === "pdf") return "pdf" as const; - if (kind === "video" || kind === "unsupported") return "document" as const; + if (kind === "video" || kind === "unsupported" || kind === "docx" || kind === "xlsx") + return "document" as const; return null; } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index e32f41372528..150f0ac8c74d 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -919,6 +919,46 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect( + "serves Office workspace and host files through exact signed URLs without exposing siblings", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-office-preview-" }); + const bytes = Uint8Array.from([0x50, 0x4b, 0, 255, 1]); + for (const [extension, mimeType] of [ + ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ]) { + const name = `report#final.${extension}`; + const filePath = path.join(root, name); + yield* fs.writeFile(filePath, bytes); + yield* fs.writeFileString(path.join(root, `sibling.${extension}`), "private sibling"); + for (const resource of [ + { + _tag: "workspace-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + { _tag: "media-file" as const, threadId: ThreadId.make("thread-1"), path: filePath }, + { _tag: "draft-workspace-file" as const, cwd: root, path: filePath }, + ]) { + const issued = yield* issueAssetUrl({ resource, workspaceRoot: root }); + const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const resolved = yield* resolveAsset(token, suffix.slice(separator + 1)); + expect(resolved).toMatchObject({ kind: "file", mimeType }); + expect(Array.from(yield* fs.readFile(filePath))).toEqual(Array.from(bytes)); + expect(yield* resolveAsset(token, `sibling.${extension}`)).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + } + }).pipe(Effect.provide(layerTest)), + ); + it.effect("previews workspace files with literal fragment characters in their paths", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 6b736c68f74e..39631b736a13 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -20,6 +20,8 @@ import { import { audioMimeTypeFromExtension, hostPreviewMimeTypeFromExtension, + officeFilePreviewKind, + OFFICE_PREVIEW_MIME_TYPES, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, @@ -479,21 +481,23 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA ? yield* readImageDimensionsFromHeader(canonicalFile) : null; return { - claims: isWorkspaceImagePreviewPath(resolved.relativePath) - ? { - version: 1 as const, - kind: "workspace-file-exact" as const, - workspaceRoot: canonicalWorkspaceRoot, - relativePath: resolved.relativePath, - expiresAt: input.expiresAt, - } - : { - version: 1 as const, - kind: "workspace-file" as const, - workspaceRoot: canonicalWorkspaceRoot, - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt: input.expiresAt, - }, + claims: + isWorkspaceImagePreviewPath(resolved.relativePath) || + officeFilePreviewKind(resolved.relativePath) !== null + ? { + version: 1 as const, + kind: "workspace-file-exact" as const, + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt: input.expiresAt, + } + : { + version: 1 as const, + kind: "workspace-file" as const, + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt: input.expiresAt, + }, fileName: path.basename(resolved.relativePath), imageDimensions, }; @@ -967,8 +971,14 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); + const officeKind = + exactWorkspaceFile === null ? null : officeFilePreviewKind(exactWorkspaceFile); return exactWorkspaceFile - ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: exactWorkspaceFile, + ...(officeKind === null ? {} : { mimeType: OFFICE_PREVIEW_MIME_TYPES[officeKind] }), + } satisfies ResolvedAsset) : null; } const segments = decodedPath.split(/[\\/]/); diff --git a/apps/web/package.json b/apps/web/package.json index b4ef7684a006..de06177fdd9e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,6 +52,7 @@ "@tiptap/starter-kit": "^3.31.3", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", + "docx-preview": "0.4.1", "dompurify": "^3.4.16", "effect": "catalog:", "hast-util-to-html": "^9.0.5", @@ -70,6 +71,7 @@ "remark-gfm": "catalog:", "tailwind-merge": "^3.4.0", "unbash": "^5.0.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 68b186d3a04e..7c55b824e374 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -19,6 +19,7 @@ import { cn } from "~/lib/utils"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; +import { OfficeFilePreview } from "./OfficeFilePreview"; import { FILE_SURFACE_SUBHEADER_CLASS, FileSurfaceAction, @@ -244,6 +245,15 @@ export function AttachmentFilePreview(props: { ) : ( ) + ) : kind === "docx" || kind === "xlsx" ? ( + setRevision((value) => value + 1)} + /> ) : kind === "pdf" || kind === "html" ? ( void; +}) { + const [result, setResult] = useState<{ html: string | null; error: string | null }>({ + html: null, + error: null, + }); + useEffect(() => { + let cancelled = false; + void renderDocxDocument(props.bytes) + .then((html) => { + if (!cancelled) setResult({ html, error: null }); + }) + .catch((cause: unknown) => { + if (!cancelled) + setResult({ + html: null, + error: cause instanceof Error ? cause.message : "Could not render this Word document.", + }); + }); + return () => { + cancelled = true; + }; + }, [props.bytes]); + if (result.error !== null) + return ; + if (result.html === null) return ; + return ( +