BeVault-Thumbnailer / server.js
AuxenceFrmt's picture
Deploy LibreOffice thumbnailer service
423ac63
Raw
History Blame Contribute Delete
3.86 kB
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}`))