From 0f936e8528cda851fefbdf62ea90a6bf601f0b60 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 4 May 2026 13:25:57 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20High-fidelity=20DOCX=20prev?= =?UTF-8?q?iew=20via=20docx-preview=20in=20iframe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the default DOCX render path from server-side mammoth → flat HTML to client-side `docx-preview` loaded inside the Sandpack iframe. Mammoth becomes the fallback for files above the cap. Why --- The Sandpack iframe is a real browser DOM. Server-side rendering ceiling for DOCX→HTML is well below the source's visual fidelity — mammoth strips cell shading, run colors, banners, and column layouts because Word's layout model doesn't fit HTML's flow model. Pushing the render into the iframe lifts that ceiling without paying the server-side cost of jsdom or LibreOffice. What ---- - New `wordDocToHtmlViaCdn(buffer)` builds a self-contained HTML doc that embeds the binary as base64 and lets `docx-preview@0.3.7` render it on load. CSS preserves dark/light mode handoff via `prefers-color-scheme`. Bootstrap script falls back to a "preview unavailable, please download" message if the CDN is unreachable or the parse throws. - `docx-preview` and its `jszip` peer dep are pinned to specific versions on jsdelivr with SRI sha384 integrity hashes and `crossorigin="anonymous"`. Refresh: re-fetch the file, run `openssl dgst -sha384 -binary FILE | openssl base64 -A`. - CSP locked down on the iframe: `default-src 'none'`, scripts only from jsdelivr (no eval), `connect-src 'none'` so a parser bug in docx-preview can't be turned into exfiltration of the embedded document, `base-uri 'none'`, `form-action 'none'`. Defense in depth on top of the Sandpack cross-origin sandbox. - `wordDocToHtml` dispatches by size: ≤ 350 KB binary → CDN path (high fidelity), larger → mammoth fallback (preserves the size cap on `attachment.text`). 350 KB chosen so worst-case base64-inflated output (~478 KB) plus wrapper overhead (~5 KB) fits under MAX_TEXT_CACHE_BYTES (512 KB) with 40 KB headroom. - Internal renderers exported as `_internal` for tests. Public API unchanged — callers still go through `wordDocToHtml`. PPTX intentionally NOT switched ------------------------------- Surveyed the available client-side PPTX libraries: - `pptx-preview@1.0.7` ships an ESM-only main entry plus a 1.36 MB UMD that references `require("stream"/"events"/"buffer"/"util")` — bundled for Node, not browser-clean. Could work but the runtime references to undefined Node globals are a fragility risk worth more validation than this PR can absorb. - `pptxjs` is jQuery-era, requires four separate UMD scripts in a specific order, less actively maintained. - The honest answer for PPTX is the LibreOffice sidecar (DOCX/XLSX/ PPTX → PDF → PDF.js), which is the architecture every major product (Google Drive, Claude.ai, ChatGPT) effectively uses and the only path to ~5/5 fidelity for arbitrary user decks. PPTX stays on the existing slide-list extraction for now. Open a follow-up issue for the LibreOffice/Gotenberg sidecar. Tests ----- - 6 new in CDN-rendered describe block: wrapper structure, base64 round-trip, SRI integrity + crossorigin, CSP locks (connect-src/eval/base-uri/form-action), fallback message wiring, size-threshold lock. - Adjusted 2 existing tests that asserted on mammoth-path artifacts (literal document text in `
`) — those assertions move to the mammoth-fallback test that calls `_internal.wordDocToHtmlViaMammoth` directly. Dispatcher tests now assert CDN-path signatures instead. packages/api files: 434/434 ✅, full unit suite 4473/4473 ✅. --- packages/api/src/files/documents/html.spec.ts | 131 +++++++++-- packages/api/src/files/documents/html.ts | 212 ++++++++++++++++-- 2 files changed, 306 insertions(+), 37 deletions(-) diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index 144fbc6af8..6b206e4db8 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import JSZip from 'jszip'; import { megabyte } from 'librechat-data-provider'; import { + _internal, bufferToOfficeHtml, csvToHtml, excelSheetToHtml, @@ -18,40 +19,118 @@ const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixtures describe('Office HTML producers', () => { describe('wordDocToHtml', () => { - test('renders a docx with its paragraph text in a sanitized HTML document', async () => { + /* The dispatcher chooses CDN vs mammoth by buffer size. The fixture + * sample.docx is small (~6 KB) so it goes down the CDN path. Tests + * that need the mammoth output specifically call + * `_internal.wordDocToHtmlViaMammoth` directly. */ + + test('routes a small docx (≤ cap) through the CDN-rendered path', async () => { const html = await wordDocToHtml(readFixture('sample.docx')); expect(html).toMatch(/^/); + // CDN wrapper signatures. + expect(html).toContain('id="lc-doc-data"'); + expect(html).toContain('docx.renderAsync'); + expect(html).toContain('cdn.jsdelivr.net/npm/docx-preview@'); + // Mammoth-path artifact must NOT appear. + expect(html).not.toContain('
'); + }); + + test('routes a docx above the size cap through the mammoth fallback', async () => { + /* Synthesize an oversized buffer by reading the fixture and + * verifying the dispatcher picks the mammoth path. We can't + * cheaply forge a multi-hundred-KB DOCX that mammoth can still + * parse, so this asserts the size predicate by calling + * `wordDocToHtmlViaMammoth` directly — the dispatcher's `if` + * branch is itself trivial to inspect. */ + const html = await _internal.wordDocToHtmlViaMammoth(readFixture('sample.docx')); expect(html).toContain('
'); expect(html).toContain('This is a sample DOCX file.'); }); - test('strips ` substring inside the binary. + * + * The CSP locks the page down to the pinned CDN host: scripts only + * from jsdelivr (with SRI), no outbound `fetch`/`XHR`, no eval, images + * only `self`/`data:`/`blob:` (docx-preview uses `URL.createObjectURL` + * for inline images), styles inline (`docx-preview` injects per-doc + * styles into `` at render time). + */ +function buildDocxCdnDocument(base64: string): string { + const csp = [ + "default-src 'none'", + "script-src https://cdn.jsdelivr.net 'unsafe-inline'", + "style-src 'unsafe-inline'", + "img-src 'self' data: blob:", + 'font-src data:', + "connect-src 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join('; '); + return ` + + + + + +Preview + + + + + +
Loading preview…
+ + + + +`; +} + +async function wordDocToHtmlViaCdn(buffer: Buffer): Promise { + return buildDocxCdnDocument(buffer.toString('base64')); +} + +async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise { const { convertToHtml } = await import('mammoth'); const result = await convertToHtml({ buffer }, { styleMap: DOCX_STYLE_MAP }); const sanitized = await sanitizeOfficeHtml(result.value); return wrapAsDocument(`
${sanitized}
`, DOCX_EXTRA_CSS); } +/** + * Convert a `.docx` buffer to a sandboxed HTML document. Two render + * paths, chosen by file size: + * + * 1. **CDN-rendered (default for files ≤ 350 KB binary)**: embeds the + * binary as base64 and lets `docx-preview` render it inside the + * Sandpack iframe. High visual fidelity — preserves cell shading, + * run-level colors/fonts, headers/footers, columns, and images. + * 2. **Mammoth (fallback for larger files)**: server-side semantic + * HTML conversion. Lower fidelity (flat paragraphs, no shading) + * but produces compact output that fits the + * `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text` even + * for large documents. + * + * Both paths pre-flight through `assertSafeZipSize` so a zip-bomb DOCX + * is rejected before either renderer touches it — mammoth's internal + * extraction has no decompressed-size cap and would happily inflate a + * sub-1MB compressed bomb to 200+ MB of XML. See SEC review on PR + * #12934 for the original DoS finding. + */ +export async function wordDocToHtml(buffer: Buffer): Promise { + await assertSafeZipSize(buffer, { name: 'docx' }); + if (buffer.length <= MAX_DOCX_CDN_BINARY_BYTES) { + return wordDocToHtmlViaCdn(buffer); + } + return wordDocToHtmlViaMammoth(buffer); +} + +/** + * Test-only re-exports. Each path has distinct visual-fidelity and + * size characteristics that warrant direct testing rather than + * round-tripping through the size-based dispatcher with synthetically + * padded fixtures. Not part of the public API — callers in production + * code should always go through `wordDocToHtml`. + */ +export const _internal = { + wordDocToHtmlViaCdn, + wordDocToHtmlViaMammoth, + MAX_DOCX_CDN_BINARY_BYTES, + DOCX_PREVIEW_CDN, +}; + /* ============================================================================= * XLSX / XLS / ODS → HTML (multi-sheet with pure-CSS tab strip) * ============================================================================= */