572 lines
19 KiB
JavaScript
572 lines
19 KiB
JavaScript
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+)
|