168 lines
4.4 KiB
JavaScript
168 lines
4.4 KiB
JavaScript
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)`);
|
|
});
|