import { spawn } from 'node:child_process'; import { constants } from 'node:fs'; import { open, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, extname, isAbsolute } from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; const failure = (code, message) => Object.assign(new Error(message), { code }); // No shell; terminate the whole group, including soffice.bin, on cancellation. export function runProcess(executable, args, signal, env = process.env) { signal.throwIfAborted(); return new Promise((resolve, reject) => { const child = spawn(executable, args, { detached: true, stdio: 'ignore', env }); let error; const kill = () => { if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch (cause) { if (cause.code !== 'ESRCH') error ??= cause; } } }; const abort = () => { error = signal.reason; kill(); }; signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort(); child.once('error', cause => { error ??= failure('unavailable', 'Cannot start helper: ' + cause.code); }); child.once('close', code => { signal.removeEventListener('abort', abort); kill(); if (error) reject(error); else if (code !== 0) reject(failure('invalid-document', 'Conversion helper exit: ' + code)); else resolve(); }); }); } async function readBounded(path, maximum, kind, signal) { const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const stat = await file.stat(); if (!stat.isFile()) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'Regular file required'); if (stat.size > maximum) throw failure(kind + '-too-large', 'File exceeds limit'); const bytes = Buffer.alloc(stat.size + 1); let length = 0; while (length < bytes.length) { signal.throwIfAborted(); const read = await file.read(bytes, length, bytes.length - length, null); if (!read.bytesRead) break; length += read.bytesRead; } if (length !== stat.size) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'File changed'); return bytes.subarray(0, length); } finally { await file.close(); } } export async function createConverter(options = {}) { if (process.platform !== 'linux') throw failure('unavailable', 'Native preview requires Linux'); if (options.fontDirectories !== undefined || options.fontFallbacks !== undefined) { throw failure('unavailable', 'Native preview uses system fonts; remove kit-only font overrides'); } const lifetime = new AbortController(); const active = new Set(); const limits = { timeoutMs: 60000, maxInputBytes: 50 * 1024 ** 2, maxOutputBytes: 100 * 1024 ** 2, maxArchiveEntries: 10000, maxUncompressedBytes: 250 * 1024 ** 2, maxImageResolution: 192, ...options }; for (const key of ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'maxArchiveEntries', 'maxUncompressedBytes', 'maxImageResolution']) { if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new TypeError('Invalid ' + key); } async function render({ inputPath, outputPath }, external = new AbortController().signal) { const deadline = new AbortController(); const signal = AbortSignal.any([external, lifetime.signal, deadline.signal]); const timer = setTimeout(() => deadline.abort(failure('timeout', 'Native Office conversion timed out')), limits.timeoutMs); let scratch; let output; let success = false; try { signal.throwIfAborted(); const extension = extname(inputPath).slice(1).toLowerCase(); if (!['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(extension)) throw failure('unsupported-format', 'Unsupported Office extension'); if (!isAbsolute(inputPath) || !isAbsolute(outputPath)) throw failure('invalid-document', 'Absolute paths required'); output = await open(outputPath, 'wx', 0o600); scratch = await mkdtemp(join(tmpdir(), 'dsh-native-office-')); const source = join(scratch, 'source.' + extension); await writeFile(source, await readBounded(inputPath, limits.maxInputBytes, 'input', signal), { flag: 'wx', mode: 0o600, signal }); const launcher = fileURLToPath(new URL('./office-worker.py', import.meta.url)); const env = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', HOME: scratch, TMPDIR: scratch, SAL_USE_VCLPLUGIN: 'svp', SAL_DISABLE_PRINTERLIST: '1', SAL_DISABLE_DEFAULTPRINTER: '1' }; await runProcess('/usr/bin/python3', ['-I', launcher, String(512 * 1024 ** 2), String(limits.maxOutputBytes), '/usr/bin/python3', '-I', fileURLToPath(new URL('./validate-office.py', import.meta.url)), source, extension, String(limits.maxArchiveEntries), String(limits.maxUncompressedBytes)], signal, env); const profile = join(scratch, 'profile'); await mkdir(join(profile, 'user'), { recursive: true, mode: 0o700 }); await writeFile(join(profile, 'user/registrymodifications.xcu'), '3'); const filter = extension.startsWith('ppt') ? 'impress_pdf_Export' : extension.startsWith('xls') ? 'calc_pdf_Export' : 'writer_pdf_Export'; const pdfOptions = JSON.stringify({ ReduceImageResolution: { type: 'boolean', value: 'true' }, MaxImageResolution: { type: 'long', value: String(limits.maxImageResolution) } }); await runProcess('/usr/bin/python3', ['-I', launcher, String(2 * 1024 ** 3), String(Math.max(limits.maxOutputBytes, 16 * 1024 ** 2)), '/usr/bin/libreoffice', '-env:UserInstallation=' + pathToFileURL(profile).href, '--headless', '--nologo', '--nodefault', '--nofirststartwizard', '--convert-to', 'pdf:' + filter + ':' + pdfOptions, '--outdir', scratch, source], signal, env); let bytes; try { bytes = await readBounded(join(scratch, 'source.pdf'), limits.maxOutputBytes, 'output', signal); } catch (cause) { if (cause.code === 'ENOENT') throw failure('invalid-output', 'LibreOffice produced no PDF'); throw cause; } if (!/^%PDF-[0-9][.][0-9]/.test(bytes.subarray(0, 8).toString()) || !bytes.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) throw failure('invalid-output', 'Incomplete PDF'); signal.throwIfAborted(); await output.writeFile(bytes); signal.throwIfAborted(); success = true; // System LibreOffice has no kit missing-font diagnostic API. return { backend: 'native-system', missingFonts: [] }; } finally { clearTimeout(timer); try { await output?.close(); } finally { try { if (output && !success) await rm(outputPath, { force: true }); } finally { if (scratch) await rm(scratch, { recursive: true, force: true }); } } } } return { render(request, signal) { const task = render(request, signal); active.add(task); task.then(() => active.delete(task), () => active.delete(task)); return task; }, async dispose() { lifetime.abort(failure('unavailable', 'Converter disposed')); await Promise.allSettled([...active]); } }; }