import { createServer } from 'node:http' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' const execFileP = promisify(execFile) const PORT = process.env.PORT || 8080 const API_TOKEN = process.env.API_TOKEN || '' // shared secret; if unset, auth is skipped const MAX_BYTES = 50 * 1024 * 1024 // 50 MB upload ceiling const TIMEOUT_MS = 60_000 // Render one slide of a PPTX to a PNG using LibreOffice (PPTX→PDF) + poppler // (PDF→PNG). LibreOffice implements the OOXML spec, so the output matches the // real PowerPoint far more closely than an HTML reconstruction. async function convert(pptxBuffer, page = 1, width = 1280) { const work = await mkdtemp(join(tmpdir(), 'thumb-')) try { const inPath = join(work, 'slide.pptx') await writeFile(inPath, pptxBuffer) // A unique LibreOffice profile dir per job avoids the single-instance lock, // so concurrent requests don't block each other. await execFileP( 'soffice', [ '--headless', '--norestore', '--nolockcheck', `-env:UserInstallation=file://${join(work, 'lo')}`, '--convert-to', 'pdf', '--outdir', work, inPath, ], { timeout: TIMEOUT_MS, env: { ...process.env, HOME: work } }, ) const pdfPath = join(work, 'slide.pdf') const outPrefix = join(work, 'out') // -scale-to-x N keeps aspect ratio (-scale-to-y -1). -singlefile drops the // page-number suffix so the output is exactly ".png". await execFileP( 'pdftoppm', [ '-png', '-f', String(page), '-l', String(page), '-singlefile', '-scale-to-x', String(width), '-scale-to-y', '-1', pdfPath, outPrefix, ], { timeout: TIMEOUT_MS }, ) return await readFile(`${outPrefix}.png`) } finally { await rm(work, { recursive: true, force: true }) } } function readBody(req) { return new Promise((resolve, reject) => { const chunks = [] let size = 0 req.on('data', (c) => { size += c.length if (size > MAX_BYTES) { req.destroy(); reject(new Error('Payload too large')) } else chunks.push(c) }) req.on('end', () => resolve(Buffer.concat(chunks))) req.on('error', reject) }) } const server = createServer(async (req, res) => { if (req.method === 'GET' && (req.url === '/' || req.url === '/healthz')) { res.writeHead(200, { 'Content-Type': 'application/json' }) return res.end(JSON.stringify({ service: 'be-vault-thumbnailer', status: 'ok' })) } if (req.method !== 'POST' || req.url !== '/convert') { res.writeHead(404, { 'Content-Type': 'application/json' }) return res.end(JSON.stringify({ error: 'Not found' })) } if (API_TOKEN && req.headers.authorization !== `Bearer ${API_TOKEN}`) { res.writeHead(401, { 'Content-Type': 'application/json' }) return res.end(JSON.stringify({ error: 'Unauthorized' })) } try { const body = JSON.parse((await readBody(req)).toString() || '{}') const { url, page = 1, width = 1280 } = body if (!url) throw new Error('Missing "url" in request body') const fetched = await fetch(url) if (!fetched.ok) throw new Error(`Could not fetch source PPTX (${fetched.status})`) const pptx = Buffer.from(await fetched.arrayBuffer()) const png = await convert(pptx, page, width) res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length }) res.end(png) } catch (err) { console.error('[thumbnailer]', err?.message ?? err) res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err?.message ?? 'Conversion failed' })) } }) server.listen(PORT, () => console.log(`thumbnailer listening on :${PORT}`))