diff --git a/server.js b/server.js new file mode 100644 index 0000000..9d6476b --- /dev/null +++ b/server.js @@ -0,0 +1,572 @@ +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+)