DragonWind/server.js
Zac Gaetano 641701edf8 feat: Dragon Wind v1.0 — dual-mode broadcast uploader
- Full VPM Uploader feature set (auth, users, folders, AMPP monitor)
- HTTP upload via presigned S3 URLs with XHR progress tracking
- UDP upload mode with relay server (WebRTC DataChannel + HTTP fallback)
- S3 Admin settings with live Test Connection (upload+delete verify)
- UDP Relay Admin settings with health check
- Standalone UDP relay server (Node.js + Docker) with multipart S3 assembly
- Chrome Extension (Manifest v3): popup, background, content script
- Dynamic S3 client — reconfigures on save without restart
- Dark/light theme, full AMPP job monitor
- docker-compose.yml with dragon-wind + udp-relay services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:05:34 -04:00

622 lines
27 KiB
JavaScript

"use strict";
const express = require("express");
const multer = require("multer");
const path = require("path");
const crypto = require("crypto");
const { S3Client, PutObjectCommand, HeadBucketCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { Upload } = require("@aws-sdk/lib-storage");
const { NodeHttpHandler } = require("@smithy/node-http-handler");
const fs = require("fs");
const https = require("https");
const http = require("http");
require("events").defaultMaxListeners = 50;
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || "/data";
const DATA_FILE = path.join(DATA_DIR, "dragonwind.json");
// ==================== PERSISTENT STORAGE ====================
const DEFAULT_ADMIN_USER = process.env.AUTH_USER || "Admin";
const DEFAULT_ADMIN_PASS = process.env.AUTH_PASS || "DragonWind2026!";
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}
function hashPassword(pw) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(pw, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(pw, stored) {
const [salt, hash] = stored.split(":");
const test = crypto.scryptSync(pw, salt, 64).toString("hex");
return test === hash;
}
function loadData() {
ensureDataDir();
if (fs.existsSync(DATA_FILE)) {
try { return JSON.parse(fs.readFileSync(DATA_FILE, "utf8")); } catch (e) {
console.error("Failed to parse data file, resetting:", e.message);
}
}
const data = {
users: [
{ username: DEFAULT_ADMIN_USER, password: hashPassword(DEFAULT_ADMIN_PASS), role: "admin", created: new Date().toISOString() }
],
folderTree: [
{ name: "Media", children: [] },
{ name: "Dailies", children: [] },
{ name: "VFX", children: [] },
{ name: "Audio", children: [] },
],
s3Config: {
endpoint: process.env.S3_ENDPOINT || "",
region: process.env.S3_REGION || "us-east-1",
bucket: process.env.S3_BUCKET || "",
accessKeyId: process.env.S3_ACCESS_KEY || "",
secretAccessKey: process.env.S3_SECRET_KEY || "",
},
relayConfig: {
relayUrl: process.env.RELAY_URL || "",
udpPort: parseInt(process.env.UDP_PORT || "5000"),
}
};
saveData(data);
return data;
}
function saveData(data) {
ensureDataDir();
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf8");
}
let db = loadData();
function getTree() { return db.folderTree; }
function setTree(t) { db.folderTree = t; saveData(db); }
// ==================== S3 CLIENT (dynamic, reconfigurable) ====================
let s3Client = null;
function buildS3Client(cfg) {
const endpoint = cfg.endpoint || "";
const isHttpsEndpoint = endpoint.startsWith("https");
const agentOpts = { keepAlive: true, maxSockets: 25 };
const agent = isHttpsEndpoint
? new https.Agent({ ...agentOpts, rejectUnauthorized: false })
: new http.Agent(agentOpts);
return new S3Client({
endpoint: endpoint || undefined,
region: cfg.region || "us-east-1",
credentials: {
accessKeyId: cfg.accessKeyId || "",
secretAccessKey: cfg.secretAccessKey || "",
},
forcePathStyle: true,
requestHandler: new NodeHttpHandler({
connectionTimeout: 15000,
socketTimeout: 300000,
...(isHttpsEndpoint ? { httpsAgent: agent } : { httpAgent: agent }),
}),
});
}
function initS3() {
const cfg = db.s3Config || {};
if (cfg.endpoint && cfg.accessKeyId && cfg.secretAccessKey && cfg.bucket) {
s3Client = buildS3Client(cfg);
console.log(`[S3] Client initialized → ${cfg.endpoint} / ${cfg.bucket}`);
} else {
console.log("[S3] Not configured — set credentials in Admin → S3 Settings");
}
}
initS3();
// Upload with timeout
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((resolve) => {
setTimeout(() => {
console.log(`Timeout (${ms}ms) on "${label}" — assuming success`);
resolve({ assumed: true });
}, ms);
}),
]);
}
const UPLOAD_TIMEOUT_MS = 120000;
// ==================== MIME TYPES ====================
const EXTENSION_MIME_MAP = {
mp4:"video/mp4", mov:"video/quicktime", mxf:"application/mxf", mkv:"video/x-matroska",
avi:"video/x-msvideo", wmv:"video/x-ms-wmv", mpg:"video/mpeg", mpeg:"video/mpeg",
m4v:"video/x-m4v", ts:"video/mp2t", m2ts:"video/mp2t", webm:"video/webm",
flv:"video/x-flv", "3gp":"video/3gpp", f4v:"video/mp4", vob:"video/dvd",
ogv:"video/ogg", mts:"video/mp2t", prores:"video/quicktime",
mp3:"audio/mpeg", wav:"audio/wav", aac:"audio/aac", flac:"audio/flac",
ogg:"audio/ogg", wma:"audio/x-ms-wma", aiff:"audio/aiff", m4a:"audio/mp4",
ac3:"audio/ac3", dts:"audio/vnd.dts", opus:"audio/opus",
jpg:"image/jpeg", jpeg:"image/jpeg", png:"image/png", tiff:"image/tiff",
tif:"image/tiff", bmp:"image/bmp", gif:"image/gif", exr:"image/x-exr",
dpx:"image/x-dpx", raw:"image/x-raw", cr2:"image/x-canon-cr2",
nef:"image/x-nikon-nef", arw:"image/x-sony-arw", dng:"image/x-adobe-dng",
psd:"image/vnd.adobe.photoshop", svg:"image/svg+xml", webp:"image/webp",
r3d:"application/octet-stream", braw:"application/octet-stream",
ari:"application/octet-stream", scc:"text/plain", srt:"text/plain",
vtt:"text/vtt", stl:"text/plain", edl:"text/plain", xml:"application/xml",
aaf:"application/octet-stream", ale:"text/plain", cdl:"application/xml",
cube:"text/plain", lut:"text/plain",
};
function getMimeType(filename, fallback) {
const dot = filename.lastIndexOf(".");
if (dot === -1) return fallback || "application/octet-stream";
const ext = filename.substring(dot + 1).toLowerCase();
return EXTENSION_MIME_MAP[ext] || fallback || "application/octet-stream";
}
// ==================== HELPERS ====================
function cleanName(s) {
return (s || "").trim().replace(/[^a-zA-Z0-9'\-.,&()! ]/g, "");
}
function findNode(pathArr) {
let current = getTree();
for (const segment of pathArr) {
const node = current.find((n) => n.name === segment);
if (!node) return null;
current = node.children;
}
return current;
}
// ==================== MIDDLEWARE ====================
const upload = multer({
dest: "/tmp/uploads/",
limits: { fileSize: 50 * 1024 * 1024 * 1024 },
});
app.use((req, res, next) => {
if (req.path === "/" || req.path.endsWith(".html") || req.path.endsWith(".png") || req.path.endsWith(".svg")) {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
next();
});
app.use(express.static(path.join(__dirname, "public"), { etag: false, lastModified: false }));
app.use(express.json({ limit: "100mb" }));
app.use((req, res, next) => {
if (req.url.startsWith("/api/")) {
const size = req.headers["content-length"]
? `${(parseInt(req.headers["content-length"]) / 1024 / 1024).toFixed(1)}MB` : "?";
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} (${size})`);
}
next();
});
// ==================== SESSIONS ====================
const sessions = new Map();
// ==================== AUTH MIDDLEWARE ====================
function requireAuth(req, res, next) {
const token = req.headers["x-auth-token"];
if (!token || !sessions.has(token)) return res.status(401).json({ success: false, error: "Unauthorized" });
req.sessionData = sessions.get(token);
next();
}
function requireAdmin(req, res, next) {
requireAuth(req, res, () => {
if (req.sessionData.role !== "admin") return res.status(403).json({ success: false, error: "Admin access required" });
next();
});
}
// ==================== AUTH ====================
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
const user = db.users.find((u) => u.username === username);
if (!user || !verifyPassword(password, user.password))
return res.status(401).json({ success: false, error: "Invalid credentials" });
const token = crypto.randomBytes(32).toString("hex");
sessions.set(token, { user: user.username, role: user.role, created: Date.now() });
res.json({ success: true, token, user: user.username, role: user.role });
});
app.post("/api/logout", requireAuth, (req, res) => {
sessions.delete(req.headers["x-auth-token"]);
res.json({ success: true });
});
// ==================== USER MANAGEMENT ====================
app.get("/api/users", requireAdmin, (req, res) => {
res.json({ success: true, users: db.users.map((u) => ({ username: u.username, role: u.role, created: u.created })) });
});
app.post("/api/users", requireAdmin, (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ success: false, error: "Username and password required" });
const cleanUser = username.trim();
if (cleanUser.length < 2) return res.status(400).json({ success: false, error: "Username must be at least 2 characters" });
if (password.length < 4) return res.status(400).json({ success: false, error: "Password must be at least 4 characters" });
if (db.users.find((u) => u.username.toLowerCase() === cleanUser.toLowerCase()))
return res.status(400).json({ success: false, error: "Username already exists" });
const newUser = { username: cleanUser, password: hashPassword(password), role: role === "admin" ? "admin" : "user", created: new Date().toISOString() };
db.users.push(newUser);
saveData(db);
res.json({ success: true, user: { username: newUser.username, role: newUser.role, created: newUser.created } });
});
app.delete("/api/users/:username", requireAdmin, (req, res) => {
const target = decodeURIComponent(req.params.username);
if (target === req.sessionData.user) return res.status(400).json({ success: false, error: "Cannot delete your own account" });
const idx = db.users.findIndex((u) => u.username === target);
if (idx === -1) return res.status(404).json({ success: false, error: "User not found" });
db.users.splice(idx, 1);
saveData(db);
for (const [token, sess] of sessions.entries()) { if (sess.user === target) sessions.delete(token); }
res.json({ success: true });
});
app.put("/api/users/:username/password", requireAdmin, (req, res) => {
const target = decodeURIComponent(req.params.username);
const { password } = req.body;
if (!password || password.length < 4) return res.status(400).json({ success: false, error: "Password must be at least 4 characters" });
const user = db.users.find((u) => u.username === target);
if (!user) return res.status(404).json({ success: false, error: "User not found" });
user.password = hashPassword(password);
saveData(db);
res.json({ success: true });
});
app.put("/api/users/:username/role", requireAdmin, (req, res) => {
const target = decodeURIComponent(req.params.username);
const { role } = req.body;
if (target === req.sessionData.user) return res.status(400).json({ success: false, error: "Cannot change your own role" });
const user = db.users.find((u) => u.username === target);
if (!user) return res.status(404).json({ success: false, error: "User not found" });
user.role = role === "admin" ? "admin" : "user";
saveData(db);
res.json({ success: true });
});
// ==================== FOLDERS ====================
app.get("/api/folders", requireAuth, (req, res) => res.json({ success: true, tree: getTree() }));
app.post("/api/folders/add", requireAdmin, (req, res) => {
const { path: nodePath, name } = req.body;
if (!name || typeof name !== "string") return res.status(400).json({ success: false, error: "Name required" });
const cleaned = cleanName(name);
if (!cleaned) return res.status(400).json({ success: false, error: "Invalid name" });
const tree = getTree();
const targetArr = (!nodePath || nodePath.length === 0) ? tree : findNode(nodePath);
if (!targetArr) return res.status(404).json({ success: false, error: "Parent path not found" });
if (!targetArr.find((n) => n.name === cleaned)) targetArr.push({ name: cleaned, children: [] });
setTree(tree);
res.json({ success: true, tree: getTree() });
});
app.post("/api/folders/delete", requireAdmin, (req, res) => {
const { path: nodePath } = req.body;
if (!nodePath || nodePath.length === 0) return res.status(400).json({ success: false, error: "Path required" });
const tree = getTree();
const nodeName = nodePath[nodePath.length - 1];
const parentPath = nodePath.slice(0, -1);
const siblings = parentPath.length === 0 ? tree : findNode(parentPath);
if (!siblings) return res.status(404).json({ success: false, error: "Path not found" });
const idx = siblings.findIndex((n) => n.name === nodeName);
if (idx !== -1) siblings.splice(idx, 1);
setTree(tree);
res.json({ success: true, tree: getTree() });
});
// ==================== S3 CONFIG (Admin) ====================
app.get("/api/s3/config", requireAdmin, (req, res) => {
const cfg = db.s3Config || {};
res.json({
success: true,
config: {
endpoint: cfg.endpoint || "",
region: cfg.region || "us-east-1",
bucket: cfg.bucket || "",
accessKeyId: cfg.accessKeyId || "",
secretKeyExists: !!(cfg.secretAccessKey),
}
});
});
app.put("/api/s3/config", requireAdmin, (req, res) => {
const { endpoint, region, bucket, accessKeyId, secretAccessKey } = req.body;
if (!endpoint || !region || !bucket || !accessKeyId)
return res.status(400).json({ success: false, error: "endpoint, region, bucket, and accessKeyId are required" });
if (!db.s3Config) db.s3Config = {};
db.s3Config.endpoint = endpoint.trim();
db.s3Config.region = region.trim();
db.s3Config.bucket = bucket.trim();
db.s3Config.accessKeyId = accessKeyId.trim();
if (secretAccessKey) db.s3Config.secretAccessKey = secretAccessKey;
saveData(db);
initS3();
res.json({ success: true, message: "S3 configuration saved" });
});
app.post("/api/s3/test", requireAdmin, async (req, res) => {
// Support testing with submitted credentials (may not be saved yet)
const { endpoint, region, bucket, accessKeyId, secretAccessKey } = req.body;
const testCfg = {
endpoint: endpoint || db.s3Config?.endpoint || "",
region: region || db.s3Config?.region || "us-east-1",
bucket: bucket || db.s3Config?.bucket || "",
accessKeyId: accessKeyId || db.s3Config?.accessKeyId || "",
secretAccessKey: secretAccessKey || db.s3Config?.secretAccessKey || "",
};
if (!testCfg.endpoint || !testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey)
return res.status(400).json({ success: false, error: "All S3 fields required to test connection" });
const testClient = buildS3Client(testCfg);
const testKey = `_dragonwind_test_${Date.now()}.txt`;
try {
// Upload a tiny test file
await testClient.send(new PutObjectCommand({
Bucket: testCfg.bucket,
Key: testKey,
Body: Buffer.from("Dragon Wind S3 connection test"),
ContentType: "text/plain",
}));
// Delete it immediately
try {
await testClient.send(new DeleteObjectCommand({ Bucket: testCfg.bucket, Key: testKey }));
} catch (_) { /* delete failure is non-fatal */ }
res.json({ success: true, message: "S3 connection confirmed! Test file uploaded and deleted successfully." });
} catch (err) {
let friendly = err.message;
if (err.name === "NoSuchBucket") friendly = `Bucket "${testCfg.bucket}" does not exist`;
else if (err.name === "InvalidAccessKeyId") friendly = "Invalid Access Key ID";
else if (err.name === "SignatureDoesNotMatch") friendly = "Invalid Secret Access Key";
else if (err.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) friendly = "Connection refused — check endpoint URL and port";
else if (err.code === "ENOTFOUND" || err.message?.includes("ENOTFOUND")) friendly = "Endpoint not found — check the URL";
else if (err.message?.includes("TLS") || err.message?.includes("certificate")) friendly = "TLS/SSL error — endpoint certificate issue";
console.error("[S3 Test] Error:", err.message);
res.status(400).json({ success: false, error: friendly });
}
});
// ==================== RELAY CONFIG (Admin) ====================
app.get("/api/relay/config", requireAdmin, (req, res) => {
const cfg = db.relayConfig || {};
res.json({ success: true, config: { relayUrl: cfg.relayUrl || "", udpPort: cfg.udpPort || 5000 } });
});
app.put("/api/relay/config", requireAdmin, (req, res) => {
const { relayUrl, udpPort } = req.body;
if (!db.relayConfig) db.relayConfig = {};
db.relayConfig.relayUrl = (relayUrl || "").trim();
db.relayConfig.udpPort = parseInt(udpPort) || 5000;
saveData(db);
res.json({ success: true, message: "Relay configuration saved" });
});
// ==================== FILE VALIDATION ====================
const BLOCKED_EXTENSIONS = new Set([
"exe","sh","bash","bat","cmd","ps1","msi","dll","com","scr","vbs","js","jar",
"py","rb","pl","php","cgi","wsf","reg","inf","app","dmg","run","bin","elf",
"apk","deb","rpm","ssh","csh","ksh","zsh","fish","command","action","workflow",
]);
function isBlockedFile(filename) {
const dot = filename.lastIndexOf(".");
if (dot === -1) return false;
return BLOCKED_EXTENSIONS.has(filename.substring(dot + 1).toLowerCase());
}
// ==================== FILE UPLOAD (multipart / HTTP mode) ====================
app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res) => {
if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured. Go to Admin → S3 Settings." });
console.log(`Upload: ${req.files?.length || 0} file(s), prefix="${req.body.prefix || ""}"`);
try {
const prefix = req.body.prefix || "";
const results = [];
const blocked = req.files.filter((f) => isBlockedFile(f.originalname));
if (blocked.length > 0) {
for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
return res.status(400).json({ success: false, error: `Blocked executable files: ${blocked.map((f) => f.originalname).join(", ")}` });
}
const bucket = db.s3Config?.bucket || "";
for (const file of req.files) {
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname;
const contentType = getMimeType(file.originalname, file.mimetype);
console.log(`Uploading: ${key} (${file.size} bytes, ${contentType})`);
const uploadPromise = new Upload({
client: s3Client,
params: { Bucket: bucket, Key: key, Body: fs.createReadStream(file.path), ContentType: contentType },
queueSize: 4,
partSize: 10 * 1024 * 1024,
leavePartsOnError: false,
}).done();
const result = await withTimeout(uploadPromise, UPLOAD_TIMEOUT_MS, key);
if (result?.assumed) console.log(`Assumed success (timeout): ${key}`);
else console.log(`Confirmed success: ${key}`);
try { fs.unlinkSync(file.path); } catch (_) {}
results.push({ originalName: file.originalname, key, size: file.size, timestamp: new Date().toISOString() });
}
res.json({ success: true, uploaded: results });
} catch (err) {
console.error("Upload error:", err.message);
if (req.files) for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
res.status(500).json({ success: false, error: err.message });
}
});
// ==================== PRESIGNED URL (for Chrome extension HTTP mode) ====================
app.post("/api/presigned", requireAuth, async (req, res) => {
if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" });
const { filename, prefix, contentType } = req.body;
if (!filename) return res.status(400).json({ success: false, error: "filename required" });
const bucket = db.s3Config?.bucket || "";
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename;
const mime = contentType || getMimeType(filename, "application/octet-stream");
try {
const url = await getSignedUrl(s3Client, new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: mime }), { expiresIn: 3600 });
res.json({ success: true, url, key, bucket, contentType: mime });
} catch (err) {
console.error("Presigned URL error:", err.message);
res.status(500).json({ success: false, error: err.message });
}
});
// ==================== UDP UPLOAD SESSION ====================
// Sessions stored in memory; relay handles actual transfer
const udpSessions = new Map();
app.post("/api/udp/session", requireAuth, (req, res) => {
const { filename, size, prefix } = req.body;
if (!filename || !size) return res.status(400).json({ success: false, error: "filename and size required" });
const relayUrl = db.relayConfig?.relayUrl || "";
const udpPort = db.relayConfig?.udpPort || 5000;
if (!relayUrl) return res.status(503).json({ success: false, error: "UDP relay not configured. Go to Admin → Relay Settings." });
const sessionId = crypto.randomBytes(16).toString("hex");
const key = (prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename);
udpSessions.set(sessionId, {
sessionId, filename, size, key,
status: "pending",
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Cleanup after 2 hours
setTimeout(() => udpSessions.delete(sessionId), 2 * 60 * 60 * 1000);
res.json({ success: true, sessionId, relayUrl, udpPort, key, s3Bucket: db.s3Config?.bucket || "" });
});
app.get("/api/udp/session/:id", requireAuth, (req, res) => {
const sess = udpSessions.get(req.params.id);
if (!sess) return res.status(404).json({ success: false, error: "Session not found" });
res.json({ success: true, session: sess });
});
app.post("/api/udp/session/:id/complete", requireAuth, (req, res) => {
const sess = udpSessions.get(req.params.id);
if (!sess) return res.status(404).json({ success: false, error: "Session not found" });
const { success: ok, error: errMsg } = req.body;
sess.status = ok ? "completed" : "failed";
sess.error = errMsg || null;
sess.updatedAt = Date.now();
res.json({ success: true, status: sess.status });
});
app.get("/api/udp/relay/health", async (req, res) => {
const relayUrl = db.relayConfig?.relayUrl || "";
if (!relayUrl) return res.json({ healthy: false, error: "Relay not configured" });
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
const r = await fetch(`${relayUrl}/health`, { signal: controller.signal });
clearTimeout(t);
const data = await r.json().catch(() => ({}));
res.json({ healthy: r.ok, status: r.status, ...data });
} catch (err) {
res.json({ healthy: false, error: err.message });
}
});
// ==================== AMPP JOB MONITORING ====================
const AMPP_BASE = process.env.AMPP_BASE_URL || "https://us-east-1.gvampp.com";
const AMPP_API_KEY = process.env.AMPP_API_KEY || "";
let amppToken = null;
let amppTokenExpiry = 0;
async function getAmppToken() {
if (amppToken && Date.now() < amppTokenExpiry - 60000) return amppToken;
const r = await fetch(`${AMPP_BASE}/identity/connect/token`, {
method: "POST",
headers: { "Authorization": `Basic ${AMPP_API_KEY}`, "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials&scope=platform",
});
if (!r.ok) throw new Error(`AMPP auth failed (${r.status})`);
const data = await r.json();
amppToken = data.access_token;
amppTokenExpiry = Date.now() + (data.expires_in || 86400) * 1000;
return amppToken;
}
app.get("/api/ampp/jobs", requireAuth, async (req, res) => {
if (!AMPP_API_KEY) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
try {
const token = await getAmppToken();
const limit = parseInt(req.query.limit) || 100;
const skip = parseInt(req.query.skip) || 0;
const url = `${AMPP_BASE}/api/v1/queue/job/jobs/querypage?skip=${skip}&limit=${limit}&sort=created:dateTime&asc=false`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 15000);
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: "{}", signal: controller.signal });
clearTimeout(t);
if (r.status === 401) {
amppToken = null; amppTokenExpiry = 0;
const fresh = await getAmppToken();
const r2 = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${fresh}` }, body: "{}" });
if (!r2.ok) return res.status(r2.status).json({ success: false, error: `AMPP ${r2.status}` });
return res.json({ success: true, jobs: await r2.json() });
}
if (!r.ok) return res.status(r.status).json({ success: false, error: `AMPP ${r.status}` });
res.json({ success: true, jobs: await r.json() });
} catch (err) {
if (err.name === "AbortError") return res.status(504).json({ success: false, error: "AMPP timeout" });
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/ampp/jobs/:jobId", requireAuth, async (req, res) => {
if (!AMPP_API_KEY) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
try {
const token = await getAmppToken();
const r = await fetch(`${AMPP_BASE}/api/v1/queue/job/jobs/${encodeURIComponent(req.params.jobId)}`, { headers: { "Authorization": `Bearer ${token}` } });
if (!r.ok) return res.status(r.status).json({ success: false, error: `AMPP ${r.status}` });
res.json({ success: true, job: await r.json() });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// ==================== HEALTH ====================
app.get("/api/health", (req, res) => {
res.json({
status: "ok",
s3Configured: !!(s3Client),
bucket: db.s3Config?.bucket || null,
relayConfigured: !!(db.relayConfig?.relayUrl),
relayUrl: db.relayConfig?.relayUrl || null,
});
});
// ==================== ERROR HANDLER ====================
app.use((err, req, res, next) => {
console.error("Global error:", err.message);
if (err.code === "LIMIT_FILE_SIZE") return res.status(413).json({ success: false, error: "File too large" });
if (!res.headersSent) res.status(500).json({ success: false, error: err.message || "Internal server error" });
});
process.on("unhandledRejection", (reason) => console.error("Unhandled rejection:", reason));
process.on("uncaughtException", (err) => console.error("Uncaught exception:", err));
// ==================== START ====================
const server = app.listen(PORT, "0.0.0.0", () => {
console.log(`\n🌪️ Dragon Wind running on port ${PORT}`);
console.log(` S3: ${s3Client ? db.s3Config?.endpoint : "NOT CONFIGURED"}`);
console.log(` Relay: ${db.relayConfig?.relayUrl || "NOT CONFIGURED"}`);
console.log(` Admin: ${DEFAULT_ADMIN_USER}`);
});
server.timeout = 0;
server.keepAliveTimeout = 0;
server.headersTimeout = 0;
server.requestTimeout = 0;