media-transform/server.js
2026-03-23 23:08:40 +01:00

574 lines
15 KiB
JavaScript

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}`);
});
});