const express = require("express"); const multer = require("multer"); const path = require("path"); const crypto = require("crypto"); const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); const { Upload } = require("@aws-sdk/lib-storage"); const fs = require("fs"); // Suppress MaxListeners warning from concurrent upload timeouts 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, "framelightx.json"); // ==================== PERSISTENT STORAGE ==================== const DEFAULT_ADMIN_USER = process.env.AUTH_USER || "Admin"; const DEFAULT_ADMIN_PASS = process.env.AUTH_PASS || "BertAndErnieVPM2026"; 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); } } // Initialize with default admin 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: [] }, ] }; 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); } // ==================== SESSIONS ==================== const sessions = new Map(); // ==================== S3 ==================== const { NodeHttpHandler } = require("@smithy/node-http-handler"); const https = require("https"); const http = require("http"); const S3_ENDPOINT = process.env.S3_ENDPOINT || "https://broadcastmgmt.cloud"; const S3_REGION = process.env.S3_REGION || "us-east-1"; const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || ""; const S3_SECRET_KEY = process.env.S3_SECRET_KEY || ""; const BUCKET = process.env.S3_BUCKET || "upload"; const UPLOAD_TIMEOUT_MS = 120000; // 2 min — if S3 hasn't responded, assume success const isHttps = S3_ENDPOINT.startsWith("https"); const agent = isHttps ? new https.Agent({ keepAlive: true, maxSockets: 25 }) : new http.Agent({ keepAlive: true, maxSockets: 25 }); const s3 = new S3Client({ endpoint: S3_ENDPOINT, region: S3_REGION, credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY, }, forcePathStyle: true, requestHandler: new NodeHttpHandler({ connectionTimeout: 15000, socketTimeout: 300000, ...(isHttps ? { httpsAgent: agent } : { httpAgent: agent }), }), }); // Upload with timeout — if S3 is slow to acknowledge, treat as success function withTimeout(promise, ms, label) { return Promise.race([ promise, new Promise((resolve) => { setTimeout(() => { console.log(`Timeout (${ms}ms) waiting for S3 response on "${label}" — assuming success`); resolve({ assumed: true }); }, ms); }), ]); } // ==================== 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; } const upload = multer({ dest: "/tmp/uploads/", limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50GB max }); app.use(express.static(path.join(__dirname, "public"))); app.use(express.json({ limit: "100mb" })); // Log every request 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` : "unknown size"; console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} (${size})`); } next(); }); // ==================== 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 ENDPOINTS ==================== 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) => { const token = req.headers["x-auth-token"]; if (token) sessions.delete(token); res.json({ success: true }); }); // ==================== USER MANAGEMENT (admin only) ==================== app.get("/api/users", requireAdmin, (req, res) => { const users = db.users.map((u) => ({ username: u.username, role: u.role, created: u.created, })); res.json({ success: true, users }); }); 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); // Prevent deleting yourself 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); // Kill any active sessions for this user 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 }); }); // ==================== FOLDER ENDPOINTS ==================== 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" }); } let 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() }); }); // ==================== 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 getFileExtension(filename) { const dot = filename.lastIndexOf('.'); if (dot === -1) return ''; return filename.substring(dot + 1).toLowerCase(); } function isBlockedFile(filename) { return BLOCKED_EXTENSIONS.has(getFileExtension(filename)); } // ==================== UPLOAD ==================== app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res) => { console.log(`Upload request: ${req.files ? req.files.length : 0} file(s), prefix="${req.body.prefix || ""}"`); try { const prefix = req.body.prefix || ""; const results = []; // Server-side block: reject any executable files const blocked = req.files.filter(f => isBlockedFile(f.originalname)); if (blocked.length > 0) { const names = blocked.map(f => f.originalname).join(", "); console.log(`BLOCKED executable upload(s): ${names}`); // Clean up ALL temp files for (const file of req.files) { try { fs.unlinkSync(file.path); } catch (_) {} } return res.status(400).json({ success: false, error: `Blocked: executable files not permitted (${names})` }); } for (const file of req.files) { const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname; console.log(`Uploading: ${key} (${file.size} bytes) to bucket "${BUCKET}"`); const uploadPromise = new Upload({ client: s3, params: { Bucket: BUCKET, Key: key, Body: fs.createReadStream(file.path), ContentType: file.mimetype, }, queueSize: 4, partSize: 1024 * 1024 * 10, leavePartsOnError: false, }).done(); // Wait for upload OR timeout — whichever comes first const result = await withTimeout(uploadPromise, UPLOAD_TIMEOUT_MS, key); if (result && result.assumed) { console.log(`Assumed success (timeout): ${key}`); } else { console.log(`Confirmed success: ${key}`); } fs.unlinkSync(file.path); 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); console.error("Stack:", err.stack); if (req.files) { for (const file of req.files) { try { fs.unlinkSync(file.path); } catch (_) {} } } res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/health", (req, res) => { res.json({ status: "ok", bucket: BUCKET }); }); // ==================== 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 || ""; // Token cache — AMPP uses OAuth2 client_credentials flow let amppToken = null; let amppTokenExpiry = 0; async function getAmppToken() { // Return cached token if still valid (with 60s buffer) if (amppToken && Date.now() < amppTokenExpiry - 60000) { return amppToken; } console.log("[AMPP] Requesting new access token..."); const tokenUrl = `${AMPP_BASE}/identity/connect/token`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); const fetchRes = await fetch(tokenUrl, { method: "POST", headers: { "Authorization": `Basic ${AMPP_API_KEY}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials&scope=platform", signal: controller.signal, }); clearTimeout(timeout); if (!fetchRes.ok) { const errText = await fetchRes.text().catch(() => ""); console.error(`[AMPP] Token request failed ${fetchRes.status}: ${errText}`); amppToken = null; amppTokenExpiry = 0; throw new Error(`AMPP auth failed (${fetchRes.status})`); } const data = await fetchRes.json(); amppToken = data.access_token; // expires_in is in seconds — convert to ms and add to current time amppTokenExpiry = Date.now() + (data.expires_in || 86400) * 1000; console.log(`[AMPP] Token acquired, expires in ${data.expires_in || 86400}s`); return amppToken; } // Proxy endpoint: query AMPP jobs (all types, last 24h) 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`; // No name filter — fetch all job types so the frontend can show the full pipeline const body = {}; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); const fetchRes = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, body: JSON.stringify(body), signal: controller.signal, }); clearTimeout(timeout); if (fetchRes.status === 401) { // Token expired mid-flight — clear cache and retry once amppToken = null; amppTokenExpiry = 0; const freshToken = await getAmppToken(); const retryRes = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${freshToken}` }, body: JSON.stringify(body), }); if (!retryRes.ok) { return res.status(retryRes.status).json({ success: false, error: `AMPP returned ${retryRes.status} after token refresh` }); } const retryData = await retryRes.json(); return res.json({ success: true, jobs: retryData }); } if (!fetchRes.ok) { const errText = await fetchRes.text().catch(() => ""); console.error(`AMPP API error ${fetchRes.status}: ${errText}`); return res.status(fetchRes.status).json({ success: false, error: `AMPP returned ${fetchRes.status}` }); } const data = await fetchRes.json(); res.json({ success: true, jobs: data }); } catch (err) { if (err.name === "AbortError") { return res.status(504).json({ success: false, error: "AMPP request timed out" }); } console.error("AMPP proxy error:", err.message); res.status(500).json({ success: false, error: err.message }); } }); // Get a specific AMPP job by ID 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 url = `${AMPP_BASE}/api/v1/queue/job/jobs/${encodeURIComponent(req.params.jobId)}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); const fetchRes = await fetch(url, { method: "GET", headers: { "Authorization": `Bearer ${token}` }, signal: controller.signal, }); clearTimeout(timeout); if (!fetchRes.ok) { return res.status(fetchRes.status).json({ success: false, error: `AMPP returned ${fetchRes.status}` }); } const data = await fetchRes.json(); res.json({ success: true, job: data }); } catch (err) { if (err.name === "AbortError") { return res.status(504).json({ success: false, error: "AMPP request timed out" }); } console.error("AMPP job detail error:", err.message); res.status(500).json({ success: false, error: err.message }); } }); // Global error handler — must be after all routes app.use((err, req, res, next) => { console.error("Global error:", err.message); console.error("Stack:", err.stack); 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" }); } }); // Catch unhandled rejections process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled rejection:", reason); }); process.on("uncaughtException", (err) => { console.error("Uncaught exception:", err); }); const server = app.listen(PORT, "0.0.0.0", () => { console.log(`FramelightX Uploader running on port ${PORT}`); }); // Allow very long uploads — 2 hours server.timeout = 0; // no socket timeout server.keepAliveTimeout = 0; // no keepalive timeout server.headersTimeout = 0; // no headers timeout server.requestTimeout = 0; // no request timeout (Node 18+)