s3-uploader/server.js

573 lines
19 KiB
JavaScript
Raw Permalink Normal View History

2026-03-31 15:29:53 -04:00
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+)