mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 10:36:09 +00:00
🧪 feat: LibreOffice → PDF preview path (POC, opt-in via env)
Per the plan-mode discussion: prove out a LibreOffice subprocess pipeline as an alternative to the docx-preview / pptx-preview CDN renderers. LibreOffice handles every office format Microsoft and LibreOffice itself can open (DOCX, PPTX, XLSX, ODT, ODP, ODS, RTF, many more), produces a PDF, and the host browser's built-in PDF viewer renders it inside the Sandpack iframe via a `data:` URI. No client-side JS dependency, no CDN dependency, true high fidelity for any feature LibreOffice supports. Off by default. Operators opt in by setting both: - `OFFICE_PREVIEW_LIBREOFFICE=true` - LibreOffice (`soffice` or `libreoffice`) on the server's `$PATH` When either is missing, the dispatcher falls through to the existing CDN/mammoth/slide-list pipeline so a misconfiguration doesn't break previews. Hardening (`packages/api/src/files/documents/libreoffice.ts`): - Fresh subprocess per call with isolated temp dir, stripped env (PATH/HOME/TMPDIR only), and `-env:UserInstallation` so concurrent conversions can't collide on shared `~/.config/libreoffice` locks - 30-second wall-time cap; SIGKILL on timeout - 50 MB PDF output cap to bound disk pressure - 512 KB output cap on the wrapped HTML so the SSE/cache contract stays intact (base64 inflates ~33%, effective PDF cap ~380 KB) - Macros disabled by default flags (`--norestore --invisible --nodefault --nofirststartwizard --nolockcheck`) - Tag-distinct `LibreOfficeUnavailableError` / `LibreOfficeConversionError` so callers can swallow appropriately Iframe wrapper (`buildPdfEmbedDocument`): - Native browser PDF viewer via `<iframe src="data:application/pdf; base64,...">` — works in Chrome, Edge, Safari, Firefox - CSP locks the iframe to `default-src 'none'; frame-src data:; connect-src 'none'; script-src 'unsafe-inline'` — no outbound network, no eval, no external scripts - `#view=FitH` for first-paint sizing - 4-second heuristic timer that swaps to a "Preview unavailable" fallback when the browser's PDF viewer is disabled (kiosk mode, Brave Shields, etc.) Wired into `wordDocToHtml` and `pptxToHtml` as the first branch — returns null when disabled / unavailable / oversized so the existing pipeline takes over. XLSX intentionally NOT routed through this path: SheetJS's HTML output is already excellent for spreadsheets (sortable, sticky headers) and PDF rendering of sheets is awkward. Tests (`libreoffice.spec.ts`, 30 cases — 25 always run, 5 conditional on the binary): env-gating parser semantics matching `OFFICE_PREVIEW_DISABLE_CDN`, fallthrough contract (never throws, returns null on any failure), CSP lock-down, fallback structure, binary probe caching + missing-binary path, error tagging, and integration tests that engage when `soffice`/`libreoffice` is on PATH (DOCX→PDF, PPTX→PDF, output-cap fallthrough). Integration tests skip cleanly on bare CI.
This commit is contained in:
parent
0c0b0ce887
commit
2dc32c7a42
3 changed files with 691 additions and 0 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import yauzl from 'yauzl';
|
||||
import { excelMimeTypes, megabyte } from 'librechat-data-provider';
|
||||
import { tryLibreOfficePreview } from './libreoffice';
|
||||
import { assertSafeZipSize } from './zipSafety';
|
||||
|
||||
/**
|
||||
|
|
@ -582,6 +583,16 @@ function isOfficePreviewCdnDisabled(): boolean {
|
|||
*/
|
||||
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
|
||||
await assertSafeZipSize(buffer, { name: 'docx' });
|
||||
/* Opt-in LibreOffice path: highest fidelity for any DOCX feature
|
||||
* mammoth/docx-preview can't reproduce (complex tables, drawing
|
||||
* objects, charts, embedded objects). Returns null when disabled,
|
||||
* binary missing, conversion failed, or output exceeds the cache
|
||||
* cap — falls through to the existing pipeline in any of those
|
||||
* cases so a misconfiguration doesn't break previews. */
|
||||
const lo = await tryLibreOfficePreview(buffer, 'docx', OFFICE_HTML_OUTPUT_CAP);
|
||||
if (lo) {
|
||||
return lo;
|
||||
}
|
||||
if (isOfficePreviewCdnDisabled() || buffer.length > MAX_DOCX_CDN_BINARY_BYTES) {
|
||||
return wordDocToHtmlViaMammoth(buffer);
|
||||
}
|
||||
|
|
@ -1167,6 +1178,14 @@ async function pptxToHtmlViaCdn(buffer: Buffer): Promise<string> {
|
|||
*/
|
||||
export async function pptxToHtml(buffer: Buffer): Promise<string> {
|
||||
await assertSafeZipSize(buffer, { name: 'pptx' });
|
||||
/* Opt-in LibreOffice path: PDF rendering of slides preserves layout,
|
||||
* fonts, charts, and embedded objects far better than the slide-list
|
||||
* fallback or even pptx-preview's canvas approach. Falls through on
|
||||
* any error per the contract in `tryLibreOfficePreview`. */
|
||||
const lo = await tryLibreOfficePreview(buffer, 'pptx', OFFICE_HTML_OUTPUT_CAP);
|
||||
if (lo) {
|
||||
return lo;
|
||||
}
|
||||
if (isOfficePreviewCdnDisabled()) {
|
||||
return pptxToSlideListHtmlInternal(buffer);
|
||||
}
|
||||
|
|
|
|||
298
packages/api/src/files/documents/libreoffice.spec.ts
Normal file
298
packages/api/src/files/documents/libreoffice.spec.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
_resetLibreOfficeProbeCache,
|
||||
buildPdfEmbedDocument,
|
||||
convertOfficeToPdf,
|
||||
isLibreOfficeEnabled,
|
||||
LibreOfficeConversionError,
|
||||
LibreOfficeUnavailableError,
|
||||
LIBREOFFICE_TIMEOUT_MS,
|
||||
MAX_LIBREOFFICE_PDF_BYTES,
|
||||
probeLibreOfficeBinary,
|
||||
tryLibreOfficePreview,
|
||||
} from './libreoffice';
|
||||
|
||||
/* Detect whether the host has a LibreOffice binary so the integration
|
||||
* tests below can conditionally engage. Most CI runners don't have
|
||||
* LibreOffice installed (it's a 500 MB dependency); we still exercise
|
||||
* the env-gating, the wrapper builder, and the failure-fallthrough
|
||||
* contract in those environments. */
|
||||
function hasLibreOfficeOnPath(): boolean {
|
||||
for (const candidate of ['soffice', 'libreoffice']) {
|
||||
const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
|
||||
if (result.status === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const LIBREOFFICE_INSTALLED = hasLibreOfficeOnPath();
|
||||
const itIfLibreOffice = LIBREOFFICE_INSTALLED ? it : it.skip;
|
||||
|
||||
/* Fixtures sit next to the source files (matches `html.spec.ts` resolution). */
|
||||
const FIXTURES_DIR = __dirname;
|
||||
function readFixture(name: string): Buffer {
|
||||
return fs.readFileSync(path.join(FIXTURES_DIR, name));
|
||||
}
|
||||
|
||||
describe('libreoffice (env gating + wrapper)', () => {
|
||||
const ORIGINAL_FLAG = process.env.OFFICE_PREVIEW_LIBREOFFICE;
|
||||
|
||||
afterEach(() => {
|
||||
_resetLibreOfficeProbeCache();
|
||||
if (ORIGINAL_FLAG === undefined) {
|
||||
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
|
||||
} else {
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = ORIGINAL_FLAG;
|
||||
}
|
||||
});
|
||||
|
||||
describe('isLibreOfficeEnabled', () => {
|
||||
it.each([['true'], ['1'], ['yes'], ['TRUE'], ['Yes'], [' true ']])(
|
||||
'returns true for %j (matches OFFICE_PREVIEW_DISABLE_CDN parser semantics)',
|
||||
(value) => {
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = value;
|
||||
expect(isLibreOfficeEnabled()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([['false'], ['0'], ['no'], [''], ['anything-else']])(
|
||||
'returns false for %j',
|
||||
(value) => {
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = value;
|
||||
expect(isLibreOfficeEnabled()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns false when the env var is unset', () => {
|
||||
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
|
||||
expect(isLibreOfficeEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPdfEmbedDocument', () => {
|
||||
/* The wrapper structure is the security-relevant surface. These
|
||||
* tests lock down the CSP, the iframe shape, and the fallback
|
||||
* contract — independent of whether LibreOffice is actually
|
||||
* installed. Same posture as the docx-preview / pptx-preview CDN
|
||||
* wrappers. */
|
||||
const FAKE_PDF_B64 = 'JVBERi0xLjQK'; // "%PDF-1.4\n" base64
|
||||
|
||||
it('emits a complete sandboxed HTML document', () => {
|
||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||
expect(html).toMatch(/^<!DOCTYPE html>/);
|
||||
expect(html).toContain('<title>Preview</title>');
|
||||
expect(html).toContain('id="lc-pdf"');
|
||||
expect(html).toContain(`data:application/pdf;base64,${FAKE_PDF_B64}`);
|
||||
});
|
||||
|
||||
it('CSP locks the iframe down: no script CDN, no outbound connect, no eval', () => {
|
||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||
const cspMatch = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/);
|
||||
expect(cspMatch).not.toBeNull();
|
||||
const csp = cspMatch![1];
|
||||
expect(csp).toMatch(/default-src 'none'/);
|
||||
/* The whole point: data: URIs in nested iframes (browser PDF viewer). */
|
||||
expect(csp).toMatch(/frame-src data:/);
|
||||
/* No outbound HTTP from the rendered iframe — a malicious PDF
|
||||
* can't beacon home from inside the viewer. */
|
||||
expect(csp).toMatch(/connect-src 'none'/);
|
||||
/* No external scripts (unlike docx-preview / pptx-preview the
|
||||
* native browser PDF viewer doesn't need a CDN library). */
|
||||
expect(csp).not.toMatch(/cdn\.jsdelivr\.net/);
|
||||
expect(csp).not.toMatch(/unsafe-eval/);
|
||||
expect(csp).toMatch(/base-uri 'none'/);
|
||||
expect(csp).toMatch(/form-action 'none'/);
|
||||
});
|
||||
|
||||
it('exposes a fallback message that surfaces when the browser PDF viewer is disabled', () => {
|
||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||
expect(html).toContain('id="lc-fallback"');
|
||||
expect(html).toContain('PDF preview unavailable in this browser');
|
||||
/* The 4-second heuristic timer that swaps to the fallback. */
|
||||
expect(html).toContain('4000');
|
||||
});
|
||||
|
||||
it('uses #view=FitH so the PDF fills the panel width on first paint', () => {
|
||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||
expect(html).toContain('#view=FitH');
|
||||
});
|
||||
|
||||
it('embeds large base64 payloads without breaking out of the data URI', () => {
|
||||
/* A `</script>` substring in the base64 wouldn't terminate the
|
||||
* iframe `src=` attribute, but a stray `"` would. base64 alphabet
|
||||
* is `A-Za-z0-9+/=` — none of those are dangerous. Sanity-check
|
||||
* with a synthetically large payload. */
|
||||
const big = 'A'.repeat(100_000);
|
||||
const html = buildPdfEmbedDocument(big);
|
||||
const src = html.match(/src="data:application\/pdf;base64,([^"]+)/);
|
||||
expect(src).not.toBeNull();
|
||||
expect(src![1].length).toBe(big.length + '#view=FitH'.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryLibreOfficePreview (gating + fallthrough contract)', () => {
|
||||
it('returns null when the env flag is unset (default behavior)', async () => {
|
||||
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
|
||||
const buf = readFixture('sample.docx');
|
||||
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when env flag is "false"', async () => {
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'false';
|
||||
const buf = readFixture('sample.docx');
|
||||
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('never throws — falls through to null on any conversion failure', async () => {
|
||||
/* Even if the binary IS available, a malformed buffer should
|
||||
* cause `convertOfficeToPdf` to throw and `tryLibreOfficePreview`
|
||||
* to swallow it. The dispatcher pipeline takes over from there. */
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true';
|
||||
const garbage = Buffer.from('this-is-definitely-not-a-docx');
|
||||
let threw = false;
|
||||
try {
|
||||
const out = await tryLibreOfficePreview(garbage, 'docx', 512 * 1024);
|
||||
expect(out).toBeNull();
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLibreOfficeBinary', () => {
|
||||
it("caches the probe result so we don't re-spawn `--version` on every call", async () => {
|
||||
_resetLibreOfficeProbeCache();
|
||||
const first = await probeLibreOfficeBinary();
|
||||
const second = await probeLibreOfficeBinary();
|
||||
/* Reference equality — second call returns the cached object. */
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('returns availability=false with a reason when the binary is missing', async () => {
|
||||
_resetLibreOfficeProbeCache();
|
||||
/* Force the probe to miss by clobbering PATH so neither
|
||||
* `soffice` nor `libreoffice` resolves. */
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = '/nonexistent';
|
||||
try {
|
||||
const probe = await probeLibreOfficeBinary();
|
||||
expect(probe.available).toBe(false);
|
||||
expect(probe.binary).toBeNull();
|
||||
expect(probe.reason).toMatch(/found on .?PATH/i);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
_resetLibreOfficeProbeCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error tagging', () => {
|
||||
it('LibreOfficeUnavailableError preserves the .name tag for callers', () => {
|
||||
const err = new LibreOfficeUnavailableError('binary missing');
|
||||
expect(err.name).toBe('LibreOfficeUnavailableError');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('LibreOfficeConversionError carries optional stderr context', () => {
|
||||
const err = new LibreOfficeConversionError('exit 1', 'soffice: oops');
|
||||
expect(err.name).toBe('LibreOfficeConversionError');
|
||||
expect(err.stderr).toBe('soffice: oops');
|
||||
});
|
||||
|
||||
it('exports tunable subprocess limits so tests can read documented values', () => {
|
||||
expect(LIBREOFFICE_TIMEOUT_MS).toBe(30_000);
|
||||
expect(MAX_LIBREOFFICE_PDF_BYTES).toBe(50 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* eslint-disable jest/no-standalone-expect -- the `itIfLibreOffice` alias
|
||||
* collapses to `it.skip` when LibreOffice isn't installed; the lint rule
|
||||
* doesn't realize the `expect`s below are still scoped to a real test
|
||||
* block via the alias. Disabling locally is cleaner than restructuring
|
||||
* to satisfy a static check that's wrong about this pattern. */
|
||||
describe('libreoffice integration (skipped unless LibreOffice is on $PATH)', () => {
|
||||
/* These tests engage only when `soffice` (or `libreoffice`) is
|
||||
* actually installed. Local dev with the binary present runs them;
|
||||
* stock CI runners skip. The skip is per-test rather than describe-
|
||||
* level so a printout makes the gating visible. */
|
||||
const ORIGINAL_FLAG = process.env.OFFICE_PREVIEW_LIBREOFFICE;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetLibreOfficeProbeCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_FLAG === undefined) {
|
||||
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
|
||||
} else {
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = ORIGINAL_FLAG;
|
||||
}
|
||||
});
|
||||
|
||||
itIfLibreOffice('probeLibreOfficeBinary detects the installed binary', async () => {
|
||||
const probe = await probeLibreOfficeBinary();
|
||||
expect(probe.available).toBe(true);
|
||||
expect(probe.binary).toMatch(/^(soffice|libreoffice)$/);
|
||||
expect(probe.versionLine).toMatch(/LibreOffice/i);
|
||||
});
|
||||
|
||||
itIfLibreOffice(
|
||||
'convertOfficeToPdf converts a DOCX to PDF bytes',
|
||||
async () => {
|
||||
const buf = readFixture('sample.docx');
|
||||
const pdf = await convertOfficeToPdf(buf, 'docx');
|
||||
/* PDF magic bytes: 25 50 44 46 == "%PDF". */
|
||||
expect(pdf.subarray(0, 4).toString('ascii')).toBe('%PDF');
|
||||
expect(pdf.length).toBeGreaterThan(500);
|
||||
},
|
||||
35_000,
|
||||
);
|
||||
|
||||
itIfLibreOffice(
|
||||
'convertOfficeToPdf converts a PPTX to PDF bytes',
|
||||
async () => {
|
||||
const buf = readFixture('sample.pptx');
|
||||
const pdf = await convertOfficeToPdf(buf, 'pptx');
|
||||
expect(pdf.subarray(0, 4).toString('ascii')).toBe('%PDF');
|
||||
},
|
||||
35_000,
|
||||
);
|
||||
|
||||
itIfLibreOffice(
|
||||
'tryLibreOfficePreview produces a PDF embed doc when env=true and binary is available',
|
||||
async () => {
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true';
|
||||
_resetLibreOfficeProbeCache();
|
||||
const buf = readFixture('sample.docx');
|
||||
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!).toMatch(/^<!DOCTYPE html>/);
|
||||
expect(out!).toContain('data:application/pdf;base64,');
|
||||
expect(Buffer.byteLength(out!, 'utf-8')).toBeLessThanOrEqual(512 * 1024);
|
||||
},
|
||||
35_000,
|
||||
);
|
||||
|
||||
itIfLibreOffice(
|
||||
'returns null when the embedded PDF would exceed the output cap',
|
||||
async () => {
|
||||
/* Force the cap below the smallest possible PDF so the size
|
||||
* check trips. The conversion still runs (verifying the
|
||||
* subprocess works) but `tryLibreOfficePreview` declines to
|
||||
* emit oversized output and the dispatcher falls through. */
|
||||
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true';
|
||||
_resetLibreOfficeProbeCache();
|
||||
const buf = readFixture('sample.docx');
|
||||
const out = await tryLibreOfficePreview(buf, 'docx', 100);
|
||||
expect(out).toBeNull();
|
||||
},
|
||||
35_000,
|
||||
);
|
||||
});
|
||||
374
packages/api/src/files/documents/libreoffice.ts
Normal file
374
packages/api/src/files/documents/libreoffice.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* LibreOffice-backed office preview pipeline (POC).
|
||||
*
|
||||
* Convert a DOCX / PPTX (eventually XLSX, ODT, PDF) buffer to PDF via
|
||||
* `soffice --headless --convert-to pdf`, then embed the PDF as a base64
|
||||
* `data:application/pdf` URI inside a sandboxed iframe so the host browser's
|
||||
* native PDF viewer renders it. Result: high-fidelity rendering for any
|
||||
* format LibreOffice opens, no client-side renderer dependency, no CDN
|
||||
* dependency. The trade-off is the LibreOffice binary on the server (~500 MB
|
||||
* disk, ~2-3 s cold-start per conversion).
|
||||
*
|
||||
* Off by default. Operators opt in by setting `OFFICE_PREVIEW_LIBREOFFICE=true`
|
||||
* AND ensuring `soffice` (or `libreoffice`) is on `$PATH`. When either
|
||||
* condition is missing we fall through to the existing CDN/mammoth/slide-list
|
||||
* pipeline so a misconfiguration doesn't break previews.
|
||||
*
|
||||
* Hardening:
|
||||
* - Subprocess runs in an isolated temp directory (no shared profile, no
|
||||
* access to the operator's home) and a stripped env (`PATH`, `HOME`,
|
||||
* `TMPDIR` only). LibreOffice's `-env:UserInstallation` flag forces a
|
||||
* fresh user profile per call so concurrent conversions can't collide
|
||||
* on shared ~/.config/libreoffice locks.
|
||||
* - 30-second timeout — soffice has been known to hang on malformed input;
|
||||
* `SIGKILL` after the timer expires.
|
||||
* - 50 MB PDF output cap — a runaway document or a deliberate pathology
|
||||
* can't blow the server's disk.
|
||||
* - Macros stay disabled (LibreOffice's default `--macro-security high`
|
||||
* plus our `--norestore --invisible --nodefault` flags).
|
||||
*
|
||||
* What this module is NOT:
|
||||
* - A LibreOffice service / pool. Each call spawns a fresh subprocess.
|
||||
* If load grows we'd want to keep a long-lived `soffice` process and
|
||||
* drive it via UNO, but that's a v2 concern.
|
||||
* - A PDF viewer. We rely on the host browser's built-in PDF viewer
|
||||
* (Chrome / Edge / Safari / Firefox all ship one). When that's missing
|
||||
* or disabled, the iframe falls back to a "Preview unavailable" state
|
||||
* and the user can still download the file.
|
||||
*/
|
||||
|
||||
/** Truthy parser shared with `OFFICE_PREVIEW_DISABLE_CDN` semantics. */
|
||||
function envFlagEnabled(value: string | undefined): boolean {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
return /^(1|true|yes)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the LibreOffice path is enabled for this process. Read at call
|
||||
* time (not module load) so tests can flip the env without rebuilding.
|
||||
*/
|
||||
export function isLibreOfficeEnabled(): boolean {
|
||||
return envFlagEnabled(process.env.OFFICE_PREVIEW_LIBREOFFICE);
|
||||
}
|
||||
|
||||
interface BinaryProbe {
|
||||
available: boolean;
|
||||
binary: string | null;
|
||||
versionLine: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
let cachedProbe: BinaryProbe | null = null;
|
||||
|
||||
/**
|
||||
* Probe `soffice` then `libreoffice` once per process and cache the result.
|
||||
* If the operator installs LibreOffice after server start, they need to
|
||||
* restart to pick it up — simpler than invalidating the cache on filesystem
|
||||
* events for a feature that's already opt-in.
|
||||
*/
|
||||
export async function probeLibreOfficeBinary(): Promise<BinaryProbe> {
|
||||
if (cachedProbe) {
|
||||
return cachedProbe;
|
||||
}
|
||||
for (const candidate of ['soffice', 'libreoffice']) {
|
||||
try {
|
||||
const versionLine = await runVersion(candidate);
|
||||
cachedProbe = { available: true, binary: candidate, versionLine };
|
||||
return cachedProbe;
|
||||
} catch {
|
||||
/* try next candidate */
|
||||
}
|
||||
}
|
||||
cachedProbe = {
|
||||
available: false,
|
||||
binary: null,
|
||||
versionLine: null,
|
||||
reason: 'neither `soffice` nor `libreoffice` found on $PATH',
|
||||
};
|
||||
return cachedProbe;
|
||||
}
|
||||
|
||||
function runVersion(binary: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(binary, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { PATH: process.env.PATH ?? '', HOME: tmpdir() },
|
||||
});
|
||||
let stdout = '';
|
||||
proc.stdout?.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(new Error('--version timed out'));
|
||||
}, 5_000);
|
||||
proc.once('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
proc.once('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve(stdout.split('\n')[0]?.trim() ?? '');
|
||||
} else {
|
||||
reject(new Error(`exit ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Reset the cached probe — for tests; never called from production code. */
|
||||
export function _resetLibreOfficeProbeCache(): void {
|
||||
cachedProbe = null;
|
||||
}
|
||||
|
||||
/** Maximum subprocess wall-time. Tuned for cold-start on a small DOCX. */
|
||||
export const LIBREOFFICE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Maximum PDF output size; refuse anything larger so a runaway doc can't fill the disk. */
|
||||
export const MAX_LIBREOFFICE_PDF_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/** Tag-distinct error so callers can distinguish "binary missing" from "conversion failed". */
|
||||
export class LibreOfficeUnavailableError extends Error {
|
||||
override readonly name = 'LibreOfficeUnavailableError';
|
||||
}
|
||||
|
||||
/** Tag-distinct error so callers can swallow conversion-side failures and fall through. */
|
||||
export class LibreOfficeConversionError extends Error {
|
||||
override readonly name = 'LibreOfficeConversionError';
|
||||
constructor(
|
||||
message: string,
|
||||
readonly stderr?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a LibreOffice subprocess to convert `buffer` (in `extensionHint`
|
||||
* format — `docx`, `pptx`, `xlsx`, `odt`, `odp`, `ods`) to PDF. Returns
|
||||
* the PDF bytes.
|
||||
*
|
||||
* `extensionHint` is part of the temp filename so soffice infers the input
|
||||
* format correctly. We never trust the extension to gate format — the
|
||||
* caller (`html.ts` dispatcher) has already routed by MIME / magic bytes.
|
||||
*
|
||||
* Throws:
|
||||
* - `LibreOfficeUnavailableError` when the binary isn't on $PATH
|
||||
* - `LibreOfficeConversionError` for subprocess failures, timeout, or
|
||||
* oversized output
|
||||
*/
|
||||
export async function convertOfficeToPdf(buffer: Buffer, extensionHint: string): Promise<Buffer> {
|
||||
const probe = await probeLibreOfficeBinary();
|
||||
if (!probe.available || !probe.binary) {
|
||||
throw new LibreOfficeUnavailableError(probe.reason ?? 'LibreOffice binary unavailable');
|
||||
}
|
||||
const safeExt = extensionHint.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'bin';
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'lc-libreoffice-'));
|
||||
try {
|
||||
const inputPath = join(tempDir, `input.${safeExt}`);
|
||||
await writeFile(inputPath, buffer);
|
||||
await runConversion(probe.binary, inputPath, tempDir);
|
||||
const outputPath = join(tempDir, 'input.pdf');
|
||||
const pdf = await readFile(outputPath);
|
||||
if (pdf.length > MAX_LIBREOFFICE_PDF_BYTES) {
|
||||
throw new LibreOfficeConversionError(
|
||||
`PDF output ${pdf.length} bytes exceeds cap ${MAX_LIBREOFFICE_PDF_BYTES}`,
|
||||
);
|
||||
}
|
||||
return pdf;
|
||||
} finally {
|
||||
/* Best-effort cleanup. If unlink fails (rare — Linux tmpfs, no
|
||||
* unmounts), the OS will reclaim the dir on next reboot. */
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function runConversion(binary: string, inputPath: string, tempDir: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const userProfile = `file://${join(tempDir, 'userprof')}`;
|
||||
const proc = spawn(
|
||||
binary,
|
||||
[
|
||||
'--headless',
|
||||
'--norestore',
|
||||
'--invisible',
|
||||
'--nodefault',
|
||||
'--nofirststartwizard',
|
||||
'--nolockcheck',
|
||||
`-env:UserInstallation=${userProfile}`,
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
tempDir,
|
||||
inputPath,
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
/* Stripped env. PATH is required for soffice to find its own libs;
|
||||
* HOME/TMPDIR pinned to the isolated temp dir so the subprocess
|
||||
* can't read the operator's profile or scribble outside our tree. */
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '',
|
||||
HOME: tempDir,
|
||||
TMPDIR: tempDir,
|
||||
/* Suppress LibreOffice's "Recovery" dialog state on macOS. */
|
||||
DBUS_SESSION_BUS_ADDRESS: 'disabled:',
|
||||
},
|
||||
},
|
||||
);
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(
|
||||
new LibreOfficeConversionError(
|
||||
`LibreOffice timeout after ${LIBREOFFICE_TIMEOUT_MS}ms`,
|
||||
stderr,
|
||||
),
|
||||
);
|
||||
}, LIBREOFFICE_TIMEOUT_MS);
|
||||
proc.once('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(new LibreOfficeConversionError(err.message, stderr));
|
||||
});
|
||||
proc.once('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new LibreOfficeConversionError(`soffice exit ${code}`, stderr));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the iframe HTML that embeds the PDF for in-panel rendering. The
|
||||
* PDF is base64-encoded as a `data:` URI and pointed to by an inner
|
||||
* `<iframe>` so the host browser's PDF viewer (PDF.js in Firefox, Chrome's
|
||||
* built-in viewer, Safari's Preview-driven viewer) can render it directly.
|
||||
*
|
||||
* Why an inner iframe rather than `<embed>` or `<object>`:
|
||||
* - `<embed>` and `<object>` rendering is least consistent across modern
|
||||
* browsers (Chrome's pdfium plugin requires CSP `object-src data:`,
|
||||
* and some headless contexts disable it).
|
||||
* - `<iframe src="data:application/pdf;base64,...">` is the most
|
||||
* reliable cross-browser path. Chromium's PDF viewer treats it as a
|
||||
* top-level navigation and serves the built-in viewer.
|
||||
*
|
||||
* The inner iframe is fully sandboxed — no script, same-origin, etc. —
|
||||
* and uses `#view=FitH` to size to the panel's width on first paint.
|
||||
*/
|
||||
export function buildPdfEmbedDocument(pdfBase64: string): string {
|
||||
/* CSP scoping:
|
||||
* - `default-src 'none'`: lock everything down.
|
||||
* - `frame-src data:`: allow the inner `<iframe src="data:application/pdf;...">`.
|
||||
* - `object-src 'self' data:`: belt-and-suspenders for browsers that
|
||||
* route PDFs through `<object>` via the iframe sandbox quirk.
|
||||
* - `script-src 'unsafe-inline'`: only our tiny load-detector script.
|
||||
* - `style-src 'unsafe-inline'`: page chrome (no external sheets).
|
||||
* - `connect-src 'none'`: rendered iframe makes no network calls.
|
||||
*/
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
'frame-src data:',
|
||||
"object-src 'self' data:",
|
||||
"script-src 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"connect-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
].join('; ');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<title>Preview</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2937; --muted: #6b7280; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --fg: #e5e7eb; --muted: #9ca3af; } }
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
||||
#lc-pdf { width: 100%; height: 100vh; border: 0; display: block; }
|
||||
#lc-fallback { display: none; padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; }
|
||||
#lc-fallback.visible { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="lc-pdf" src="data:application/pdf;base64,${pdfBase64}#view=FitH" title="PDF preview"></iframe>
|
||||
<div id="lc-fallback">PDF preview unavailable in this browser. Please download the file to view it.</div>
|
||||
<script>
|
||||
(function () {
|
||||
/* Some browsers / kiosk profiles disable the built-in PDF viewer; the
|
||||
* iframe loads but stays blank. We can't reliably detect the inner
|
||||
* viewer's success across browsers, so we use a 4-second heuristic:
|
||||
* if the iframe never reports a load event by then, swap to the
|
||||
* fallback message. False negatives (slow networks, cold viewers)
|
||||
* are acceptable — the user can still download the file. */
|
||||
var pdfFrame = document.getElementById('lc-pdf');
|
||||
var fallback = document.getElementById('lc-fallback');
|
||||
var loaded = false;
|
||||
if (pdfFrame) {
|
||||
pdfFrame.addEventListener('load', function () { loaded = true; });
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (!loaded && fallback) {
|
||||
if (pdfFrame) { pdfFrame.style.display = 'none'; }
|
||||
fallback.classList.add('visible');
|
||||
}
|
||||
}, 4000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the LibreOffice path. Returns the wrapped HTML on success, or `null`
|
||||
* if LibreOffice isn't enabled / available / converted successfully — the
|
||||
* caller falls through to the existing pipeline. Never throws.
|
||||
*
|
||||
* `extensionHint`: `docx` / `pptx` / `xlsx` / `odt` / `odp` / `ods` etc.
|
||||
*
|
||||
* Output-size guarantee: returns `null` if the embedded HTML would exceed
|
||||
* the 512 KB `attachment.text` cache cap. Base64 inflates by ~33% so the
|
||||
* effective PDF cap is ~380 KB; LibreOffice's PDF/A-1 default produces
|
||||
* compact output for typical chat-emitted documents.
|
||||
*/
|
||||
export async function tryLibreOfficePreview(
|
||||
buffer: Buffer,
|
||||
extensionHint: string,
|
||||
outputCap: number,
|
||||
): Promise<string | null> {
|
||||
if (!isLibreOfficeEnabled()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const pdf = await convertOfficeToPdf(buffer, extensionHint);
|
||||
const base64 = pdf.toString('base64');
|
||||
const html = buildPdfEmbedDocument(base64);
|
||||
if (Buffer.byteLength(html, 'utf-8') > outputCap) {
|
||||
return null;
|
||||
}
|
||||
return html;
|
||||
} catch (err) {
|
||||
/* Swallow both unavailable + conversion errors. The dispatcher
|
||||
* pipeline is the authoritative renderer; LibreOffice is opportunistic
|
||||
* augmentation. The caller's logger will surface the underlying
|
||||
* pipeline if they care to instrument it. */
|
||||
void err;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue