Revert "Studio: inline DOCX preview via docx-preview + DOMPurify"

This reverts commit ba78141ac5.
This commit is contained in:
Roland Tannous 2026-05-28 15:08:19 +04:00
commit d55e5d1474
6 changed files with 12 additions and 139 deletions

View file

@ -57,8 +57,6 @@
"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",

View file

@ -4,9 +4,7 @@
* 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" 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 === "docx" text-view fallback shown, NO inline rendering.
* - mediaKind === "unknown" unavailable/download state, NOT inline.
* - mediaKind === "text" text/snippet view shown.
* - Panel without a target renders nothing or unavailable state.

View file

@ -7,9 +7,7 @@
* - 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 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).
* - For unsafe mediaKind (html/docx/unknown) blob fetch is skipped; previewBlobUrl = null.
* - isInlineBlobAllowed pure predicate matches contracts §5.4 allowlist.
* - __previewStoreInternals() verifies module-scoped cleanup.
*/
@ -227,28 +225,18 @@ describe("preview-store open/close lifecycle (contracts §5)", () => {
expect(status).toBe("ready");
});
it("docx mediaKind fetches bytes for sanitized render but never creates an object URL (Risk #3)", async () => {
it("docx mediaKind skips blob fetch and sets previewBlobUrl = null (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" });
// 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(mockFetchPreviewFileBlob).not.toHaveBeenCalled();
expect(mockFetchPreviewFileUrl).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
const { previewBlob, previewBlobUrl, status } = usePreviewStore.getState();
expect(previewBlob).not.toBeNull();
expect(previewBlobUrl).toBeNull();
expect(status).toBe("ready");
expect(usePreviewStore.getState().previewBlob).toBeNull();
expect(usePreviewStore.getState().previewBlobUrl).toBeNull();
});
it("unknown mediaKind skips blob fetch and sets previewBlobUrl = null", async () => {

View file

@ -1,63 +0,0 @@
// 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<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(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 <style> so docx-preview's scoped layout CSS survives the
// sanitize pass; everything else uses DOMPurify defaults (drops
// <script>, event handlers, javascript: URLs, etc.).
const clean = DOMPurify.sanitize(offscreen.innerHTML, {
ADD_TAGS: ["style"],
});
ref.current.innerHTML = clean;
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
};
}, [blob]);
if (error) {
return (
<div className="p-4 text-xs text-destructive">
Could not render this document. Use Download to open the original.
</div>
);
}
return (
<div className="h-full overflow-auto bg-muted/30 p-4">
<div ref={ref} className="docx-preview mx-auto" />
</div>
);
};

View file

@ -22,7 +22,6 @@ import {
isInlineBlobAllowed,
usePreviewStore,
} from "../stores/preview-store";
import { PreviewDocxView } from "./preview-docx-view";
import { PreviewPdfView } from "./preview-pdf-view";
import { PreviewTextView } from "./preview-text-view";
import { PreviewUnavailable } from "./preview-unavailable";
@ -112,16 +111,9 @@ function renderPreviewBody({
return <PreviewPdfView target={target} file={pdfFile} />;
}
// DOCX renders inline through a DOMPurify-sanitized docx-preview
// pass (Risk #3 stays satisfied — the markup is sanitized and no raw
// object URL is created).
if (target.mediaKind === "docx" && previewBlob) {
return <PreviewDocxView target={target} blob={previewBlob} />;
}
// text / html / unknown — routed through text-view. text gets the
// snippet rendered inline; html / unknown skip inline-render entirely
// (contracts §5.4 + Risk #3).
// text / image / docx / html / unknown — all routed through
// text-view. text gets the snippet rendered inline; docx/html
// /unknown skip inline-render entirely (contracts §5.4 + Risk #3).
return <PreviewTextView target={target} />;
}

View file

@ -147,49 +147,9 @@ export const usePreviewStore = create<PreviewState>((set) => ({
return;
}
// DOCX: fetch the raw bytes so the panel can render a DOMPurify-
// sanitized inline preview (docx-preview). We deliberately keep
// previewBlobUrl = null — the bytes flow through `previewBlob` to the
// renderer only; no object URL is created, so the "open original
// inline" path stays disabled (Risk #3) and Download remains the only
// way to get the raw file.
if (target.mediaKind === "docx") {
let docxBlob: Blob;
try {
docxBlob = await fetchPreviewFileBlob(req.documentId, controller.signal);
} catch (err) {
if (controller.signal.aborted || myKey !== activeOpenKey) return;
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "error",
error: err instanceof Error ? err.message : String(err),
openKey: myKey,
});
return;
}
if (myKey !== activeOpenKey) return;
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: null,
previewBlob: docxBlob,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "ready",
error: null,
openKey: myKey,
});
return;
}
// For mediaKinds outside the allowlist (html / unknown), skip the
// blob fetch entirely — the panel mounts the extracted-text fallback
// (contracts §5.4 + Risk #3).
// For mediaKinds outside the allowlist (docx / html / unknown),
// skip the blob fetch entirely — the panel mounts the
// extracted-text fallback (contracts §5.4 + Risk #3).
if (!isInlineBlobAllowed(target.mediaKind)) {
if (activeAbortController === controller) activeAbortController = null;
set({