Revert "Studio: inline DOCX preview via docx-preview + DOMPurify"
This reverts commit ba78141ac5.
This commit is contained in:
parent
32e57fa1c5
commit
d55e5d1474
6 changed files with 12 additions and 139 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue