This commit is contained in:
pavel 2026-03-23 23:08:40 +01:00
commit 0aee105835
9 changed files with 2017 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
data
.env

31
README.md Normal file
View file

@ -0,0 +1,31 @@
# Convertoor
A small web app that lets a user upload audio, video, or image files, describe an edit in plain English, and have a Zen-powered agent complete it using only `ffmpeg` and ImageMagick (`magick`) commands.
## Requirements
- Node.js 22+
- `ffmpeg`
- ImageMagick (`magick`)
- `ZEN_API_KEY`
## Run
```bash
npm install
ZEN_API_KEY=your_key_here npm start
```
Then open `http://localhost:3000`.
Optionally set `ZEN_MODEL` if you want a different Zen model and `ZEN_BASE_URL` if you need a non-default endpoint.
## Notes
- Uploaded files and generated outputs are stored in `data/<session-id>/`.
- The backend never shells out through bash. It only spawns `ffmpeg` and `magick` directly.
- Command arguments are restricted to the per-job workspace so the agent cannot read or write outside the upload session.
- Uploads are validated server-side to only accept audio, video, and image MIME types.
- FFmpeg is run with a restricted protocol allowlist and blocked file-driven/script-like flags.
- ImageMagick is run with a local restrictive `policy.xml` in [config/imagemagick/policy.xml](/home/pavel/convertoor/config/imagemagick/policy.xml).
- Commands are killed after 60 seconds and final outputs larger than 250 MB are rejected.

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)*>
<!ATTLIST policymap xmlns CDATA #FIXED "">
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED "">
<!ATTLIST policy domain NMTOKEN #REQUIRED>
<!ATTLIST policy name NMTOKEN #IMPLIED>
<!ATTLIST policy pattern CDATA #IMPLIED>
<!ATTLIST policy rights NMTOKEN #IMPLIED>
<!ATTLIST policy stealth NMTOKEN #IMPLIED>
<!ATTLIST policy value CDATA #IMPLIED>
]>
<policymap>
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="disk" value="1GiB"/>
<policy domain="resource" name="file" value="64"/>
<policy domain="resource" name="thread" value="2"/>
<policy domain="resource" name="time" value="60"/>
<policy domain="resource" name="width" value="16000"/>
<policy domain="resource" name="height" value="16000"/>
<policy domain="path" rights="none" pattern="@*"/>
<policy domain="delegate" rights="none" pattern="*"/>
<policy domain="filter" rights="none" pattern="*"/>
<policy domain="coder" rights="none" pattern="HTTP"/>
<policy domain="coder" rights="none" pattern="HTTPS"/>
<policy domain="coder" rights="none" pattern="FTP"/>
<policy domain="coder" rights="none" pattern="URL"/>
<policy domain="coder" rights="none" pattern="MVG"/>
<policy domain="coder" rights="none" pattern="MSL"/>
<policy domain="coder" rights="none" pattern="SVG"/>
<policy domain="coder" rights="none" pattern="PANGO"/>
<policy domain="coder" rights="none" pattern="TEXT"/>
</policymap>

1027
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "convertoor",
"version": "1.0.0",
"description": "Agentic media converter powered by ffmpeg and ImageMagick",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^17.3.1",
"express": "^5.2.1",
"mime-types": "^3.0.2",
"multer": "^2.1.1",
"openai": "^6.32.0"
}
}

64
public/app.js Normal file
View file

@ -0,0 +1,64 @@
const form = document.getElementById("job-form");
const filesInput = document.getElementById("files");
const promptInput = document.getElementById("prompt");
const statusBox = document.getElementById("status");
const resultBox = document.getElementById("result");
const submitButton = document.getElementById("submit-button");
const fileList = document.getElementById("file-list");
function setStatus(kind, message) {
statusBox.className = `status ${kind}`;
statusBox.textContent = message;
}
function renderFiles() {
fileList.innerHTML = "";
for (const file of filesInput.files) {
const pill = document.createElement("div");
pill.className = "file-pill";
pill.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
fileList.appendChild(pill);
}
}
filesInput.addEventListener("change", renderFiles);
form.addEventListener("submit", async (event) => {
event.preventDefault();
resultBox.hidden = true;
resultBox.innerHTML = "";
submitButton.disabled = true;
setStatus("busy", "Uploading files and processing them. This can take a little while.");
try {
const payload = new FormData();
for (const file of filesInput.files) {
payload.append("files", file);
}
payload.append("prompt", promptInput.value.trim());
const response = await fetch("/api/process", {
method: "POST",
body: payload,
});
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || "The request failed.");
}
setStatus("success", "Result ready.");
resultBox.hidden = false;
resultBox.innerHTML = `
<strong>Result ready.</strong>
<a href="${body.downloadUrl}">Download ${body.resultFile}</a>
`;
} catch (error) {
setStatus("error", error.message);
} finally {
submitButton.disabled = false;
}
});

56
public/index.html Normal file
View file

@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Convertoor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<section class="hero">
<p class="eyebrow">Media editing tool</p>
<h1>Describe the media transformation you want.</h1>
<p class="lede">
Upload audio, video, or images, write your prompt in plain English, and the tool will produce a
downloadable result for you.
</p>
</section>
<section class="panel">
<form id="job-form">
<label class="field">
<span>Files</span>
<input id="files" name="files" type="file" multiple accept="audio/*,video/*,image/*" required />
</label>
<div id="file-list" class="file-list" aria-live="polite"></div>
<label class="field">
<span>Prompt</span>
<textarea
id="prompt"
name="prompt"
rows="6"
placeholder="Example: Trim the first 15 seconds from this video, add a soft fade-in, and export as mp4."
required
></textarea>
</label>
<button id="submit-button" type="submit">Process files</button>
</form>
<div id="status" class="status idle">Waiting for a job.</div>
<div id="result" class="result" hidden></div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

208
public/styles.css Normal file
View file

@ -0,0 +1,208 @@
:root {
color-scheme: light;
--bg: #f3efe6;
--bg-accent: #ddd1bd;
--panel: rgba(255, 251, 245, 0.86);
--text: #1d1a17;
--muted: #645a4e;
--line: rgba(29, 26, 23, 0.14);
--brand: #0c7c59;
--brand-dark: #095740;
--shadow: 0 24px 80px rgba(54, 36, 10, 0.16);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.85), transparent 30%),
radial-gradient(circle at bottom right, rgba(12, 124, 89, 0.18), transparent 30%),
linear-gradient(135deg, var(--bg) 0%, var(--bg-accent) 100%);
}
.shell {
width: min(960px, calc(100vw - 2rem));
margin: 0 auto;
padding: 3rem 0 4rem;
}
.hero {
padding: 2rem 0 1.5rem;
animation: rise 700ms ease;
}
.eyebrow {
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.78rem;
color: var(--brand-dark);
}
h1 {
margin: 0;
max-width: 12ch;
font-size: clamp(2.8rem, 9vw, 5.8rem);
line-height: 0.95;
}
.lede {
max-width: 48rem;
color: var(--muted);
font-size: 1.1rem;
line-height: 1.7;
}
code,
textarea,
.status,
.file-pill {
font-family: "IBM Plex Mono", monospace;
}
.panel {
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: 28px;
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
animation: rise 900ms ease;
}
#job-form {
display: grid;
gap: 1.25rem;
}
.field {
display: grid;
gap: 0.55rem;
}
.field span {
font-weight: 700;
}
input[type="file"],
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.78);
color: var(--text);
}
input[type="file"] {
padding: 1rem;
}
textarea {
padding: 1rem 1.1rem;
resize: vertical;
min-height: 9rem;
}
button {
justify-self: start;
padding: 0.95rem 1.35rem;
border: 0;
border-radius: 999px;
background: linear-gradient(135deg, var(--brand) 0%, var(--brand-dark) 100%);
color: white;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease;
box-shadow: 0 18px 40px rgba(12, 124, 89, 0.22);
}
button:hover {
transform: translateY(-1px);
}
button:disabled {
opacity: 0.68;
cursor: wait;
}
.file-list {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
min-height: 1.5rem;
}
.file-pill {
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(12, 124, 89, 0.08);
border: 1px solid rgba(12, 124, 89, 0.18);
font-size: 0.82rem;
}
.status,
.result {
margin-top: 1.25rem;
padding: 1rem 1.1rem;
border-radius: 18px;
border: 1px solid var(--line);
}
.status.idle {
color: var(--muted);
background: rgba(255, 255, 255, 0.42);
}
.status.busy {
color: #5f4309;
background: rgba(255, 210, 117, 0.24);
}
.status.error {
color: #7a1c1c;
background: rgba(211, 75, 75, 0.12);
}
.status.success {
color: #0b5f46;
background: rgba(12, 124, 89, 0.12);
}
.result a {
color: var(--brand-dark);
font-weight: 700;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 720px) {
.shell {
width: min(100vw - 1rem, 100%);
padding-top: 1.25rem;
}
.panel {
padding: 1rem;
border-radius: 22px;
}
h1 {
max-width: 100%;
}
}

574
server.js Normal file
View file

@ -0,0 +1,574 @@
const dotenv = require("dotenv");
dotenv.config();
const express = require("express");
const fs = require("fs/promises");
const fssync = require("fs");
const path = require("path");
const crypto = require("crypto");
const multer = require("multer");
const { spawn } = require("child_process");
const OpenAI = require("openai");
const mime = require("mime-types");
const app = express();
const PORT = process.env.PORT || 3000;
const MODEL = process.env.ZEN_MODEL || "gpt-5.4-mini";
const ZEN_BASE_URL = process.env.ZEN_BASE_URL || "https://opencode.ai/zen/v1";
const DATA_DIR = path.join(__dirname, "data");
const PUBLIC_DIR = path.join(__dirname, "public");
const IMAGEMAGICK_CONFIG_DIR = path.join(__dirname, "config", "imagemagick");
const MAX_FILES = 10;
const MAX_FILE_SIZE = 200 * 1024 * 1024;
const MAX_RESULT_SIZE = 250 * 1024 * 1024;
const COMMAND_TIMEOUT_MS = 60_000;
const ALLOWED_UPLOAD_PREFIXES = ["audio/", "video/", "image/"];
const DISALLOWED_FFMPEG_FLAGS = new Set([
"-protocol_whitelist",
"-protocol_blacklist",
"-filter_script",
"-filter_complex_script",
"-progress",
"-report",
"-attach",
"-dump_attachment",
]);
const DISALLOWED_MAGICK_FLAGS = new Set([
"-authenticate",
"-decipher",
"-encipher",
]);
const DISALLOWED_ARG_SNIPPETS = [
"http://",
"https://",
"ftp://",
"tcp://",
"udp://",
"rtmp://",
"rtsp://",
"amqp://",
"sftp://",
"ssh://",
"gopher://",
"data:",
"pipe:",
"fd:",
"concat:",
"concatf:",
"subfile,",
"crypto:",
];
const DISALLOWED_FFMPEG_VALUE_SNIPPETS = [
"movie=",
"amovie=",
"textfile=",
"fontfile=",
"filename=",
"/etc/",
"../",
];
const DISALLOWED_MAGICK_VALUE_SNIPPETS = [
"@",
"caption:@",
"label:@",
"pango:",
"mvg:",
"msl:",
"svg:",
"/etc/",
"../",
];
const zen = process.env.ZEN_API_KEY
? new OpenAI({
apiKey: process.env.ZEN_API_KEY,
baseURL: ZEN_BASE_URL,
})
: null;
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
function makeId() {
return crypto.randomUUID();
}
function sessionPath(sessionId) {
return path.join(DATA_DIR, sessionId);
}
function safeBasename(name) {
const base = path.basename(name);
return base.replace(/[^a-zA-Z0-9._-]+/g, "_");
}
function sanitizeArg(arg, workDir) {
if (typeof arg !== "string" || !arg.trim()) {
throw new Error("All command arguments must be non-empty strings.");
}
if (arg.includes("\0")) {
throw new Error("Null bytes are not allowed in command arguments.");
}
if (arg.startsWith("-")) {
return arg;
}
const candidate = path.resolve(workDir, arg);
if (!candidate.startsWith(workDir + path.sep) && candidate !== workDir) {
throw new Error(`Path escapes the session workspace: ${arg}`);
}
return path.relative(workDir, candidate) || ".";
}
function validateUpload(file) {
return ALLOWED_UPLOAD_PREFIXES.some((prefix) => file.mimetype.startsWith(prefix));
}
function validateArgValue(arg, disallowedSnippets, errorLabel) {
const lower = arg.toLowerCase();
for (const snippet of DISALLOWED_ARG_SNIPPETS) {
if (lower.includes(snippet)) {
throw new Error(`${errorLabel} cannot use external protocols or stream-like inputs.`);
}
}
for (const snippet of disallowedSnippets) {
if (lower.includes(snippet)) {
throw new Error(`${errorLabel} includes a blocked construct.`);
}
}
}
function validateFfmpegArgs(args) {
for (const arg of args) {
if (DISALLOWED_FFMPEG_FLAGS.has(arg)) {
throw new Error(`Blocked ffmpeg flag: ${arg}`);
}
if (!arg.startsWith("-")) {
validateArgValue(arg, DISALLOWED_FFMPEG_VALUE_SNIPPETS, "ffmpeg argument");
}
}
}
function validateMagickArgs(args) {
for (const arg of args) {
if (DISALLOWED_MAGICK_FLAGS.has(arg)) {
throw new Error(`Blocked ImageMagick flag: ${arg}`);
}
if (!arg.startsWith("-")) {
validateArgValue(arg, DISALLOWED_MAGICK_VALUE_SNIPPETS, "ImageMagick argument");
}
}
}
function makeToolFailure(args, message, extra = {}) {
return {
ok: false,
code: null,
timedOut: false,
blocked: true,
stdout: "",
stderr: message,
args,
...extra,
};
}
async function listFiles(workDir) {
const entries = await fs.readdir(workDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
}
async function runCommand(binary, args, workDir) {
try {
if (binary === "ffmpeg") {
validateFfmpegArgs(args);
} else if (binary === "magick") {
validateMagickArgs(args);
}
const sanitizedArgs = args.map((arg) => sanitizeArg(arg, workDir));
const commandArgs =
binary === "ffmpeg"
? ["-nostdin", "-protocol_whitelist", "file,fd", ...sanitizedArgs]
: sanitizedArgs;
return await new Promise((resolve) => {
const child = spawn(binary, commandArgs, {
cwd: workDir,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
MAGICK_CONFIGURE_PATH: IMAGEMAGICK_CONFIG_DIR,
},
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, COMMAND_TIMEOUT_MS);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({
ok: false,
code: null,
timedOut: false,
blocked: false,
stdout: "",
stderr: error.message,
args: commandArgs,
});
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({
ok: !timedOut && code === 0,
code: timedOut ? null : code,
timedOut,
blocked: false,
stdout: stdout.slice(-12000),
stderr: stderr.slice(-12000),
args: commandArgs,
});
});
});
} catch (error) {
return makeToolFailure(args, error.message);
}
}
async function describeFiles(workDir) {
const files = await listFiles(workDir);
return files
.map((file) => {
const ext = path.extname(file).slice(1).toLowerCase() || "unknown";
return `- ${file} (${ext})`;
})
.join("\n");
}
async function runAgent({ prompt, sessionId }) {
if (!zen) {
throw new Error("ZEN_API_KEY is missing. Set it before running the app.");
}
const workDir = sessionPath(sessionId);
const initialFiles = await describeFiles(workDir);
const instructions = [
"You are a media-editing agent.",
"You can only use the provided ffmpeg and ImageMagick tools.",
"Do not mention or attempt bash, shell pipelines, Python, or any other tool.",
"Only read from and write to files in the current session workspace.",
"Use filenames exactly as provided or create new output files in the same workspace.",
"Prefer preserving the original uploads and write outputs to new files.",
"If a tool output reports blocked=true, timedOut=true, or ok=false, revise your command and try again with safer simpler arguments.",
"When the task is complete, answer with JSON matching this schema:",
'{ "result_file": "filename.ext", "summary": "short description" }',
"The result_file must be a single file that exists in the workspace.",
"If you create intermediate files, pick the final downloadable output in result_file.",
].join(" ");
let response = await zen.responses.create({
model: MODEL,
instructions,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: `User prompt: ${prompt}\n\nFiles in workspace:\n${initialFiles}`,
},
],
},
],
text: {
format: {
type: "json_schema",
name: "media_result",
schema: {
type: "object",
additionalProperties: false,
properties: {
result_file: { type: "string" },
summary: { type: "string" },
},
required: ["result_file", "summary"],
},
},
},
tools: [
{
type: "function",
name: "run_ffmpeg",
description: "Run a single ffmpeg command inside the current workspace.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
args: {
type: "array",
items: { type: "string" },
description: "Arguments for ffmpeg, not including the ffmpeg binary.",
},
},
required: ["args"],
},
},
{
type: "function",
name: "run_magick",
description: "Run a single ImageMagick magick command inside the current workspace.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
args: {
type: "array",
items: { type: "string" },
description: "Arguments for magick, not including the magick binary.",
},
},
required: ["args"],
},
},
],
});
for (let step = 0; step < 12; step += 1) {
const toolCalls = (response.output || []).filter((item) => item.type === "function_call");
if (!toolCalls.length) {
break;
}
const toolOutputs = [];
for (const call of toolCalls) {
const parsed = JSON.parse(call.arguments);
let result;
console.log(`Session ${sessionId}: Executing tool call ${call.name} with arguments ${call.arguments}.`);
if (call.name === "run_ffmpeg") {
result = await runCommand("ffmpeg", parsed.args, workDir);
} else if (call.name === "run_magick") {
result = await runCommand("magick", parsed.args, workDir);
} else {
throw new Error(`Unsupported tool requested: ${call.name}`);
}
const filesAfter = await listFiles(workDir);
toolOutputs.push({
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify({
...result,
files_after: filesAfter,
}),
});
}
response = await zen.responses.create({
model: MODEL,
previous_response_id: response.id,
input: toolOutputs,
text: {
format: {
type: "json_schema",
name: "media_result",
schema: {
type: "object",
additionalProperties: false,
properties: {
result_file: { type: "string" },
summary: { type: "string" },
},
required: ["result_file", "summary"],
},
},
},
tools: [
{
type: "function",
name: "run_ffmpeg",
description: "Run a single ffmpeg command inside the current workspace.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
args: {
type: "array",
items: { type: "string" },
},
},
required: ["args"],
},
},
{
type: "function",
name: "run_magick",
description: "Run a single ImageMagick magick command inside the current workspace.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
args: {
type: "array",
items: { type: "string" },
},
},
required: ["args"],
},
},
],
});
}
const textOutput = response.output_text;
if (!textOutput) {
throw new Error("The agent did not return a final result.");
}
const parsed = JSON.parse(textOutput);
const resultName = safeBasename(parsed.result_file);
const resultPath = path.join(workDir, resultName);
if (!fssync.existsSync(resultPath)) {
throw new Error(`The agent returned a missing file: ${resultName}`);
}
const stats = await fs.stat(resultPath);
if (stats.size > MAX_RESULT_SIZE) {
throw new Error(`The result file exceeds the maximum allowed size of ${MAX_RESULT_SIZE} bytes.`);
}
console.log(`Session ${sessionId}: Agent completed with result file ${resultName} and summary: ${parsed.summary}`);
return {
resultFile: resultName,
summary: parsed.summary,
};
}
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
const sessionId = req.sessionId || makeId();
req.sessionId = sessionId;
const dir = sessionPath(sessionId);
await ensureDir(dir);
cb(null, dir);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
cb(null, safeBasename(file.originalname));
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (!validateUpload(file)) {
cb(new Error("Only audio, video, and image uploads are allowed."));
return;
}
cb(null, true);
},
limits: {
fileSize: MAX_FILE_SIZE,
files: MAX_FILES,
},
});
app.use(express.json({ limit: "2mb" }));
app.use(express.static(PUBLIC_DIR));
app.post("/api/process", upload.array("files", MAX_FILES), async (req, res) => {
try {
const prompt = String(req.body.prompt || "").trim();
if (!prompt) {
return res.status(400).json({ error: "A prompt is required." });
}
if (!req.files || !req.files.length) {
return res.status(400).json({ error: "Upload at least one file." });
}
console.log(`Session ${req.sessionId}: Received prompt ${prompt} and files ${req.files.map((f) => f.originalname)}.`);
const sessionId = req.sessionId;
const result = await runAgent({ prompt, sessionId });
return res.json({
sessionId,
resultFile: result.resultFile,
downloadUrl: `/api/download/${sessionId}/${encodeURIComponent(result.resultFile)}`,
});
} catch (error) {
return res.status(500).json({
error: error.message || "Processing failed.",
});
}
});
app.get("/api/download/:sessionId/:fileName", async (req, res) => {
const sessionId = safeBasename(req.params.sessionId);
const fileName = safeBasename(req.params.fileName);
const filePath = path.join(sessionPath(sessionId), fileName);
if (!fssync.existsSync(filePath)) {
return res.status(404).json({ error: "File not found." });
}
const type = mime.lookup(fileName) || "application/octet-stream";
res.setHeader("Content-Type", type);
res.download(filePath, fileName);
});
app.use((error, req, res, next) => {
if (error instanceof multer.MulterError) {
return res.status(400).json({ error: error.message });
}
if (error) {
return res.status(400).json({ error: error.message || "Request failed." });
}
return next(error);
});
ensureDir(DATA_DIR).then(() => {
app.listen(PORT, () => {
console.log(`Convertoor running at http://localhost:${PORT}`);
});
});