diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 58bc2d8f09..b07be9a589 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -57,6 +57,8 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dexie": "^4.3.0", + "docx-preview": "^0.3.6", + "dompurify": "^3.2.7", "event-source-polyfill": "1.0.31", "fflate": "0.8.3", "js-yaml": "^4.1.1", diff --git a/studio/frontend/src/__tests__/preview-panel.test.tsx b/studio/frontend/src/__tests__/preview-panel.test.tsx index 18d34454ae..ab9dd9e7fc 100644 --- a/studio/frontend/src/__tests__/preview-panel.test.tsx +++ b/studio/frontend/src/__tests__/preview-panel.test.tsx @@ -4,7 +4,9 @@ * Acceptance criteria (contracts §5.4, PLAN.md T5, decisions Q7): * - mediaKind === "pdf" → react-pdf view is mounted (or loading indicator shown). * - mediaKind === "html" → text-view fallback shown, NO object/embed/iframe with blob URL. - * - mediaKind === "docx" → text-view fallback shown, NO inline rendering. + * - mediaKind === "docx" → DOMPurify-sanitized docx-preview render when a + * blob is present; falls back to text-view when it isn't. Never uses + * object/embed/iframe and never creates a raw object URL. * - mediaKind === "unknown" → unavailable/download state, NOT inline. * - mediaKind === "text" → text/snippet view shown. * - Panel without a target renders nothing or unavailable state. diff --git a/studio/frontend/src/__tests__/preview-store.test.ts b/studio/frontend/src/__tests__/preview-store.test.ts index fa68ba8bf9..6d8ee50b58 100644 --- a/studio/frontend/src/__tests__/preview-store.test.ts +++ b/studio/frontend/src/__tests__/preview-store.test.ts @@ -7,7 +7,9 @@ * - Opening doc B while doc A is loaded revokes doc A's URL. * - PDFs use a signed range URL instead of a full blob download. * - Inline object URLs are created ONLY for safe non-PDF mediaKind (text/image). - * - For unsafe mediaKind (html/docx/unknown) blob fetch is skipped; previewBlobUrl = null. + * - For html/unknown blob fetch is skipped; previewBlobUrl = null. + * - docx fetches bytes (for the sanitized inline render) but still never + * creates an object URL (previewBlobUrl = null). * - isInlineBlobAllowed pure predicate matches contracts §5.4 allowlist. * - __previewStoreInternals() verifies module-scoped cleanup. */ @@ -225,18 +227,28 @@ describe("preview-store open/close lifecycle (contracts §5)", () => { expect(status).toBe("ready"); }); - it("docx mediaKind skips blob fetch and sets previewBlobUrl = null (Risk #3)", async () => { + it("docx mediaKind fetches bytes for sanitized render but never creates an object URL (Risk #3)", async () => { mockFetchPreviewTarget.mockResolvedValue( makeTarget({ mediaKind: "docx", filename: "report.docx" }), ); + mockFetchPreviewFileBlob.mockResolvedValue( + new Blob(["PK"], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }), + ); await usePreviewStore.getState().open({ documentId: "doc-docx" }); - expect(mockFetchPreviewFileBlob).not.toHaveBeenCalled(); + // Bytes ARE fetched (the DOMPurify-sanitized docx-preview renderer + // needs them) but no object URL is created — the raw file is never + // exposed as an inline blob: URL. + expect(mockFetchPreviewFileBlob).toHaveBeenCalled(); expect(mockFetchPreviewFileUrl).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled(); - expect(usePreviewStore.getState().previewBlob).toBeNull(); - expect(usePreviewStore.getState().previewBlobUrl).toBeNull(); + const { previewBlob, previewBlobUrl, status } = usePreviewStore.getState(); + expect(previewBlob).not.toBeNull(); + expect(previewBlobUrl).toBeNull(); + expect(status).toBe("ready"); }); it("unknown mediaKind skips blob fetch and sets previewBlobUrl = null", async () => { diff --git a/studio/frontend/src/features/rag/components/preview-docx-view.tsx b/studio/frontend/src/features/rag/components/preview-docx-view.tsx new file mode 100644 index 0000000000..e039c90cf5 --- /dev/null +++ b/studio/frontend/src/features/rag/components/preview-docx-view.tsx @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import DOMPurify from "dompurify"; +import { renderAsync } from "docx-preview"; +import { type FC, useEffect, useRef, useState } from "react"; +import type { PreviewTarget } from "../api/rag-api"; + +/** Inline DOCX preview. docx-preview renders the .docx bytes to HTML; the + * source document is user-supplied, so we sanitize that HTML with + * DOMPurify before injecting it (Risk #3 — a malicious .docx must not + * execute script in the app origin). The raw bytes are never exposed as + * an object URL — only the sanitized render and the Download action. */ +export const PreviewDocxView: FC<{ target: PreviewTarget; blob: Blob }> = ({ + blob, +}) => { + const ref = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setError(null); + // Render off-screen first so we can sanitize before the markup ever + // touches the live DOM. + const offscreen = document.createElement("div"); + void renderAsync(blob, offscreen, undefined, { + inWrapper: true, + ignoreLastRenderedPageBreak: true, + }) + .then(() => { + if (cancelled || !ref.current) return; + // Keep