mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🩹 fix: PPTX black screen — allow blob: workers + harden bootstrap
Manual e2e of the PPTX CDN renderer surfaced a black screen with
"Could not establish connection. Receiving end does not exist."
unhandled-rejection — characteristic of a Web Worker that couldn't
start.
Root cause: pptx-preview's bundled echarts dep spins up Web Workers
via blob: URLs for chart rendering. Our CSP had `default-src 'none'`
and no `worker-src`, so workers fell back to default → blocked. The
async failure deep inside echarts didn't surface through the outer
`previewer.preview()` promise, so my bootstrap's `.catch` never fired,
the loading state was removed, and the iframe sat with the body
background showing through (dark navy in dark mode = "black screen").
Three changes:
- Add `worker-src blob:` to the PPTX CSP. Allows blob:-only worker
creation without permitting arbitrary worker URLs.
- Bootstrap: window-level `unhandledrejection` and `error` listeners
so rejections from inside bundled-dep async pipelines surface as
the user-facing "Preview unavailable" fallback instead of going
silent.
- Bootstrap: 8-second timeout that checks `container.children.length`
— if the renderer hasn't appended anything visible by then, assume
silent failure and show the fallback.
Also wipe `container.innerHTML` when showing the fallback so a partial
render doesn't compete with the message.
DOCX wrapper unchanged: docx-preview doesn't use workers, so the
worker-src directive doesn't apply, and the existing fallback path
already covers its failure modes.
Tests
-----
- Existing PPTX CSP test now also asserts `worker-src blob:` is present.
- Existing fallback-message test extended to cover the new
unhandledrejection/error/timeout listeners.
packages/api/src/files: 467/467 ✅.
This commit is contained in:
parent
cbdcdd4b96
commit
71d8f106c9
2 changed files with 62 additions and 3 deletions
|
|
@ -373,14 +373,29 @@ describe('Office HTML producers', () => {
|
|||
expect(csp).toMatch(/form-action 'none'/);
|
||||
expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/);
|
||||
expect(csp).not.toMatch(/unsafe-eval/);
|
||||
/* PPTX must allow blob:-only Web Workers — pptx-preview's
|
||||
* bundled echarts dep spins up workers via blob: URLs for
|
||||
* chart rendering. Without this, the renderer's async
|
||||
* pipeline throws unhandled rejections and the iframe shows
|
||||
* a black screen. */
|
||||
expect(csp).toMatch(/worker-src blob:/);
|
||||
});
|
||||
|
||||
test('exposes a fallback message that surfaces if the renderer fails to load', async () => {
|
||||
test('exposes a fallback message that surfaces if the renderer fails to load or times out', async () => {
|
||||
const pptx = await buildPptx([{ title: 'X' }]);
|
||||
const html = await _internal.pptxToHtmlViaCdn(pptx);
|
||||
// Visible loading state + fallback that swaps in on error.
|
||||
expect(html).toContain('Loading preview…');
|
||||
expect(html).toContain('Preview unavailable');
|
||||
// The renderer-not-loaded check.
|
||||
expect(html).toContain("typeof pptxPreview === 'undefined'");
|
||||
// The unhandledrejection + error listeners — pptx-preview's
|
||||
// bundled deps raise async rejections that don't surface
|
||||
// through the outer Promise.
|
||||
expect(html).toContain("addEventListener('unhandledrejection'");
|
||||
expect(html).toContain("addEventListener('error'");
|
||||
// The 8-second timeout safety net for silent renderer failures.
|
||||
expect(html).toContain('renderer-timeout');
|
||||
});
|
||||
|
||||
test('size-fallback threshold is the documented 350 KB', () => {
|
||||
|
|
|
|||
|
|
@ -972,10 +972,20 @@ const MAX_PPTX_CDN_BINARY_BYTES = 350 * 1024;
|
|||
* once the renderer paints them.
|
||||
*/
|
||||
function buildPptxCdnDocument(base64: string): string {
|
||||
/* PPTX-specific CSP relaxations vs DOCX:
|
||||
* - `worker-src blob:` — pptx-preview's bundled echarts dep spins up
|
||||
* Web Workers via blob: URLs for chart rendering. Without this,
|
||||
* workers default to `default-src 'none'`, get blocked, and
|
||||
* echarts throws an unhandled-rejection deep inside its async
|
||||
* pipeline that we can't catch from the bootstrap script. The
|
||||
* visible symptom is a black iframe with the renderer half-
|
||||
* started. Allowing blob:-only workers is the minimum surface to
|
||||
* unblock rendering without permitting arbitrary worker URLs. */
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
"script-src https://cdn.jsdelivr.net 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
'worker-src blob:',
|
||||
"img-src 'self' data: blob:",
|
||||
'font-src data:',
|
||||
"connect-src 'none'",
|
||||
|
|
@ -1006,15 +1016,39 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
|
|||
<script id="lc-doc-data" type="application/octet-stream;base64">${base64}</script>
|
||||
<script>
|
||||
(function () {
|
||||
var settled = false;
|
||||
function showFallback(reason) {
|
||||
if (settled) { return; }
|
||||
settled = true;
|
||||
var loading = document.querySelector('.lc-pptx-loading');
|
||||
if (loading) { loading.remove(); }
|
||||
var container = document.getElementById('lc-render');
|
||||
if (container) {
|
||||
// Wipe any partial render the library left behind so the
|
||||
// fallback message isn't competing with a half-rendered slide.
|
||||
container.innerHTML = '';
|
||||
}
|
||||
var fallback = document.getElementById('lc-fallback');
|
||||
if (fallback) {
|
||||
fallback.hidden = false;
|
||||
if (reason) { fallback.title = String(reason).slice(0, 200); }
|
||||
}
|
||||
}
|
||||
function markSuccess() { settled = true; }
|
||||
|
||||
/* pptx-preview bundled deps (echarts, etc.) raise rejections deep
|
||||
* inside their async pipelines that the outer Promise from
|
||||
* previewer.preview does not always surface. Listen at the window
|
||||
* level so we do not go silent on any of them. */
|
||||
window.addEventListener('unhandledrejection', function (e) {
|
||||
if (settled) { return; }
|
||||
showFallback((e.reason && e.reason.message) || 'unhandled-rejection');
|
||||
});
|
||||
window.addEventListener('error', function (e) {
|
||||
if (settled) { return; }
|
||||
showFallback((e.error && e.error.message) || e.message || 'script-error');
|
||||
});
|
||||
|
||||
if (typeof pptxPreview === 'undefined' || typeof pptxPreview.init !== 'function') {
|
||||
showFallback('renderer-not-loaded');
|
||||
return;
|
||||
|
|
@ -1027,9 +1061,19 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
|
|||
if (loading) { loading.remove(); }
|
||||
var previewer = pptxPreview.init(container, { width: 960, height: 540 });
|
||||
var result = previewer.preview(bytes.buffer);
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch(function (err) { showFallback(err && err.message); });
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(markSuccess).catch(function (err) { showFallback(err && err.message); });
|
||||
}
|
||||
/* Final safety net: if 8 seconds in the renderer hasn't appended
|
||||
* anything visible to the container, assume it failed silently. */
|
||||
setTimeout(function () {
|
||||
if (settled) { return; }
|
||||
if (container && container.children.length > 0) {
|
||||
markSuccess();
|
||||
return;
|
||||
}
|
||||
showFallback('renderer-timeout');
|
||||
}, 8000);
|
||||
} catch (err) {
|
||||
showFallback(err && err.message);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue