init
This commit is contained in:
commit
0aee105835
9 changed files with 2017 additions and 0 deletions
574
server.js
Normal file
574
server.js
Normal 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}`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue