import { createServer } from 'http'; import { exec } from 'child_process'; import { writeFile, readFile, unlink, mkdir, readdir } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; const PORT = process.env.PORT || 3002; const TEMP_DIR = '/tmp/convert'; const TIMEOUT_MS = 30000; const MAX_FILE_SIZE = 50 * 1024 * 1024; const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || '3', 10); const EXTENSION_TO_OUTPUT = { '.ppt': 'pptx', '.doc': 'docx', '.xls': 'xlsx', }; let activeJobs = 0; let queuedJobs = 0; async function cleanup(dir) { if (!existsSync(dir)) return; try { for (const file of await readdir(dir)) { await unlink(join(dir, file)).catch(() => {}); } await unlink(dir).catch(() => {}); } catch {} } function runLibreOffice(inputPath, outputDir, outputExt) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error('timeout')); }, TIMEOUT_MS); exec( `libreoffice --headless --convert-to ${outputExt} --outdir "${outputDir}" "${inputPath}"`, { maxBuffer: 10 * 1024 * 1024 }, (error) => { clearTimeout(timer); if (error) reject(error); else resolve(); } ); }); } async function processConversion(buffer, ext) { const jobId = randomUUID(); const jobDir = join(TEMP_DIR, jobId); const inputPath = join(jobDir, `input${ext}`); const outputExt = EXTENSION_TO_OUTPUT[ext]; const outputPath = join(jobDir, `input.${outputExt}`); try { await mkdir(jobDir, { mode: 0o700 }); await writeFile(inputPath, buffer, { mode: 0o600 }); await runLibreOffice(inputPath, jobDir, outputExt); if (!existsSync(outputPath)) { throw new Error('no_output'); } return await readFile(outputPath); } finally { await cleanup(jobDir); } } async function handleConvert(req, res) { if (activeJobs >= MAX_CONCURRENT) { queuedJobs++; if (queuedJobs > MAX_CONCURRENT * 2) { queuedJobs--; res.writeHead(503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Server busy, try again later' })); return; } await new Promise((resolve) => { const check = setInterval(() => { if (activeJobs < MAX_CONCURRENT) { clearInterval(check); queuedJobs--; resolve(); } }, 100); }); } activeJobs++; const chunks = []; let size = 0; req.on('data', (chunk) => { size += chunk.length; if (size <= MAX_FILE_SIZE) { chunks.push(chunk); } }); req.on('end', async () => { if (size > MAX_FILE_SIZE) { activeJobs--; res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'File too large' })); return; } const url = new URL(req.url, `http://localhost:${PORT}`); const ext = url.searchParams.get('ext'); if (!ext || !EXTENSION_TO_OUTPUT[ext]) { activeJobs--; res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid or missing extension parameter' })); return; } try { const converted = await processConversion(Buffer.concat(chunks), ext); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': converted.length, }); res.end(converted); } catch (err) { const message = err.message === 'timeout' ? 'Conversion timed out' : err.message === 'no_output' ? 'Conversion produced no output' : 'Conversion failed'; res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: message })); } finally { activeJobs--; } }); } const server = createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', active: activeJobs, queued: queuedJobs })); return; } if (req.method === 'POST' && req.url?.startsWith('/convert')) { handleConvert(req, res); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); }); if (!existsSync(TEMP_DIR)) { await mkdir(TEMP_DIR, { recursive: true, mode: 0o700 }); } server.listen(PORT, '0.0.0.0', () => { console.log(`Sandbox running on port ${PORT} (max ${MAX_CONCURRENT} concurrent)`); });