Spaces:
Runtime error
Runtime error
| 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 "<prefix>.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}`)) | |