From e95cc5ae38e5107997913f4ea2f1a06f12836924 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 2 May 2026 23:09:05 -0400 Subject: [PATCH] fix: use buildS3Key in /api/desktop/multipart/init to restore -- folder prefix in S3 keys The /api/desktop/multipart/init endpoint was building the S3 key using a forward slash separator (prefix + "/" + filename) instead of the "--" delimiter convention used everywhere else. This caused files uploaded via the desktop multipart path to land as "Media/Dailies/file.mxf" instead of "Media--Dailies--file.mxf", breaking AMPP folder sorting which relies on the "--" delimiter to parse folder hierarchy. Fix: replace the manual key construction with buildS3Key(prefix, filename), consistent with /api/upload, /api/presigned, /api/upload/initiate, and /api/udp/session. --- server.js | 1128 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1128 insertions(+) create mode 100644 server.js diff --git a/server.js b/server.js new file mode 100644 index 0000000..8733999 --- /dev/null +++ b/server.js @@ -0,0 +1,1128 @@ +"use strict"; +const express = require("express"); +const multer = require("multer"); +const path = require("path"); +const crypto = require("crypto"); +const { S3Client, PutObjectCommand, HeadBucketCommand, DeleteObjectCommand, ListObjectsV2Command, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = 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"); +const archiver = require("archiver"); + +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(), + quotaMB: 0, allowedFolders: [], uploadedBytes: 0 } + ], + 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"), + }, + shareLinks: [], + }; + saveData(data); + return data; +} + +// Migrate existing data to include new fields if missing +function migrateData(data) { + if (!data.shareLinks) data.shareLinks = []; + for (const u of data.users) { + if (u.quotaMB === undefined) u.quotaMB = 0; + if (!u.allowedFolders) u.allowedFolders = []; + if (u.uploadedBytes === undefined) u.uploadedBytes = 0; + } + return data; +} + +function saveData(data) { + ensureDataDir(); + fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf8"); +} + +let db = migrateData(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 || "").trim(); + const hasCustomEndpoint = endpoint.length > 0; + const isHttps = !hasCustomEndpoint || endpoint.startsWith("https"); + const agentOpts = { keepAlive: true, maxSockets: 25 }; + const agent = isHttps + ? new https.Agent({ ...agentOpts, rejectUnauthorized: false }) + : new http.Agent(agentOpts); + + return new S3Client({ + // No endpoint → AWS SDK uses the official AWS S3 endpoint for the region + ...(hasCustomEndpoint ? { endpoint } : {}), + region: cfg.region || "us-east-1", + credentials: { + accessKeyId: cfg.accessKeyId || "", + secretAccessKey: cfg.secretAccessKey || "", + }, + // forcePathStyle needed for MinIO/Ceph/generic S3; harmless for AWS + forcePathStyle: hasCustomEndpoint, + requestHandler: new NodeHttpHandler({ + connectionTimeout: 15000, + socketTimeout: 300000, + ...(isHttps ? { httpsAgent: agent } : { httpAgent: agent }), + }), + }); +} + +function initS3() { + const cfg = db.s3Config || {}; + if (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, ""); +} + +// Build S3 object key from a folder prefix and filename. +// The prefix may use "/" to separate nested folders (from the UI tree), +// but FLX expects "--" as the folder delimiter in object keys. +function buildS3Key(prefix, filename) { + if (!prefix) return filename; + const normalized = prefix.replace(/\//g, "--").replace(/[-]+$/, ""); + return `${normalized}--${filename}`; +} + +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; +} + +// Collect all folder key-paths from the tree (e.g. ["Media", "Media/Dailies"]) +function allFolderPaths(nodes, prefix) { + const result = []; + for (const node of nodes) { + const p = prefix ? `${prefix}/${node.name}` : node.name; + result.push(p); + if (node.children?.length) result.push(...allFolderPaths(node.children, p)); + } + return result; +} + +// Check if a prefix (upload destination) is within the user's allowed folders. +// allowedFolders empty = all allowed. prefix empty = root = always allowed for admins only. +function isFolderAllowed(user, prefix) { + if (user.role === "admin") return true; + const allowed = user.allowedFolders || []; + if (allowed.length === 0) return true; // no restriction + if (!prefix) return false; // non-admin can't upload to root if restrictions set + return allowed.some(f => prefix === f || prefix.startsWith(f + "/") || prefix.startsWith(f + "--")); +} + +// Check quota. quotaMB 0 = unlimited. +function isQuotaExceeded(user, additionalBytes) { + if (user.role === "admin") return false; + if (!user.quotaMB || user.quotaMB === 0) return false; + const limitBytes = user.quotaMB * 1024 * 1024; + return (user.uploadedBytes || 0) + additionalBytes > limitBytes; +} + +// ==================== MIDDLEWARE ==================== +const upload = multer({ + dest: "/tmp/uploads/", + limits: { fileSize: 50 * 1024 * 1024 * 1024 }, +}); + +// CORS — allow Chrome extensions and browser clients +app.use((req, res, next) => { + const origin = req.headers.origin || ""; + // Allow Chrome extensions, localhost, and the configured host + const allowed = origin.startsWith("chrome-extension://") || origin.startsWith("http://localhost") || origin.startsWith("https://localhost"); + res.setHeader("Access-Control-Allow-Origin", allowed ? origin : "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,x-auth-token,Authorization"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + if (req.method === "OPTIONS") return res.sendStatus(204); + next(); +}); + +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 }); +}); + +// ---- User permissions & quota ---- +app.get("/api/users/:username/permissions", requireAdmin, (req, res) => { + const user = db.users.find(u => u.username === decodeURIComponent(req.params.username)); + if (!user) return res.status(404).json({ success: false, error: "User not found" }); + res.json({ success: true, quotaMB: user.quotaMB || 0, allowedFolders: user.allowedFolders || [], uploadedBytes: user.uploadedBytes || 0 }); +}); + +app.put("/api/users/:username/permissions", requireAdmin, (req, res) => { + const user = db.users.find(u => u.username === decodeURIComponent(req.params.username)); + if (!user) return res.status(404).json({ success: false, error: "User not found" }); + const { quotaMB, allowedFolders } = req.body; + if (quotaMB !== undefined) user.quotaMB = Math.max(0, parseInt(quotaMB) || 0); + if (Array.isArray(allowedFolders)) user.allowedFolders = allowedFolders; + saveData(db); + res.json({ success: true, message: "Permissions updated" }); +}); + +app.post("/api/users/:username/quota/reset", requireAdmin, (req, res) => { + const user = db.users.find(u => u.username === decodeURIComponent(req.params.username)); + if (!user) return res.status(404).json({ success: false, error: "User not found" }); + user.uploadedBytes = 0; + saveData(db); + res.json({ success: true, message: "Quota usage reset" }); +}); + +// ==================== 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; + // endpoint is optional for AWS S3 (leave blank to use AWS default) + if (!region || !bucket || !accessKeyId) + return res.status(400).json({ success: false, error: "region, bucket, and accessKeyId are required" }); + // First-time setup requires a secret key + const existingSecret = db.s3Config?.secretAccessKey; + if (!secretAccessKey && !existingSecret) + return res.status(400).json({ success: false, error: "Secret Access Key is required (no existing secret on file)" }); + 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.trim(); + saveData(db); + initS3(); + res.json({ success: true, message: "S3 configuration saved" }); +}); + +app.post("/api/s3/test", requireAdmin, async (req, res) => { + const saved = db.s3Config || {}; + const testCfg = { + endpoint: (req.body.endpoint || saved.endpoint || "").trim(), + region: (req.body.region || saved.region || "us-east-1").trim(), + bucket: (req.body.bucket || saved.bucket || "").trim(), + accessKeyId: (req.body.accessKeyId || saved.accessKeyId || "").trim(), + secretAccessKey: (req.body.secretAccessKey || saved.secretAccessKey || "").trim(), + }; + console.log("[S3 Test] Config:", { endpoint: testCfg.endpoint, region: testCfg.region, bucket: testCfg.bucket, accessKeyId: testCfg.accessKeyId, hasSecret: !!testCfg.secretAccessKey }); + if (!testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey) + return res.status(400).json({ success: false, error: "Bucket, Access Key ID, and Secret Key are required to test" }); + + const testClient = buildS3Client(testCfg); + try { + const result = await testClient.send(new ListObjectsV2Command({ Bucket: testCfg.bucket, MaxKeys: 1 })); + console.log("[S3 Test] ListObjects OK, KeyCount:", result.KeyCount); + res.json({ success: true, message: "✓ S3 connection confirmed! Credentials are valid and the bucket is accessible." }); + } catch (err) { + console.error("[S3 Test] Full error:", JSON.stringify({ name: err.name, message: err.message, code: err.Code || err.code, status: err.$metadata?.httpStatusCode })); + let friendly = err.message || "Unknown error"; + if (err.name === "NoSuchBucket" || err.Code === "NoSuchBucket") friendly = `Bucket "${testCfg.bucket}" does not exist`; + else if (err.name === "InvalidAccessKeyId" || err.Code === "InvalidAccessKeyId") friendly = "Invalid Access Key ID"; + else if (err.name === "SignatureDoesNotMatch" || err.Code === "SignatureDoesNotMatch") friendly = "Invalid Secret Access Key"; + else if (err.name === "AccessDenied" || err.Code === "AccessDenied") friendly = "Access denied — credentials don't have permission for this bucket"; + else if (err.message?.includes("ECONNREFUSED")) friendly = "Connection refused — check endpoint URL"; + else if (err.message?.includes("ENOTFOUND")) friendly = "Endpoint not found — check the URL"; + 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 || "", + publicRelayUrl: cfg.publicRelayUrl || "", + udpPort: cfg.udpPort || 5000 + }}); +}); + +app.put("/api/relay/config", requireAdmin, (req, res) => { + const { relayUrl, publicRelayUrl, udpPort } = req.body; + if (!db.relayConfig) db.relayConfig = {}; + db.relayConfig.relayUrl = (relayUrl || "").trim(); + db.relayConfig.publicRelayUrl = (publicRelayUrl || "").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." }); + const user = db.users.find(u => u.username === req.sessionData.user); + const prefix = req.body.prefix || ""; + console.log(`Upload: ${req.files?.length || 0} file(s), prefix="${prefix}", user="${req.sessionData.user}"`); + try { + // Folder permission check + if (user && !isFolderAllowed(user, prefix)) { + for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} } + return res.status(403).json({ success: false, error: "You do not have permission to upload to this folder" }); + } + // Quota check + const totalBytes = (req.files || []).reduce((s, f) => s + f.size, 0); + if (user && isQuotaExceeded(user, totalBytes)) { + for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} } + const usedMB = ((user.uploadedBytes || 0) / 1024 / 1024).toFixed(1); + return res.status(403).json({ success: false, error: `Upload quota exceeded (${usedMB} MB of ${user.quotaMB} MB used)` }); + } + 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 = buildS3Key(prefix, file.originalname); + const contentType = getMimeType(file.originalname, file.mimetype); + console.log(`Uploading: ${key} (${file.size} bytes, ${contentType})`); + // Use PutObjectCommand (single PUT) — compatible with RustFS/MinIO/generic S3. + // The @aws-sdk/lib-storage Upload class uses CreateMultipartUpload internally + // which returns non-standard XML from RustFS and causes UnknownError. + const fileBuffer = fs.readFileSync(file.path); + const putPromise = s3Client.send(new PutObjectCommand({ + Bucket: bucket, Key: key, Body: fileBuffer, ContentType: contentType, + })); + const result = await withTimeout(putPromise, 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 (_) {} + if (user) { user.uploadedBytes = (user.uploadedBytes || 0) + file.size; } + results.push({ originalName: file.originalname, key, size: file.size, timestamp: new Date().toISOString() }); + } + saveData(db); + 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 (direct-to-S3 upload) ==================== +// Client uploads directly to RustFS/S3 using presigned PUT URLs, bypassing the Node server. +// Server only generates the signed URL and tracks quota/permissions. +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, size } = req.body; + if (!filename) return res.status(400).json({ success: false, error: "filename required" }); + if (isBlockedFile(filename)) return res.status(400).json({ success: false, error: `Blocked file type: ${filename}` }); + + const user = db.users.find(u => u.username === req.sessionData.user); + // Folder permission check + if (user && !isFolderAllowed(user, prefix || "")) { + return res.status(403).json({ success: false, error: "You do not have permission to upload to this folder" }); + } + // Quota check + if (size && user && isQuotaExceeded(user, size)) { + const usedMB = ((user.uploadedBytes || 0) / 1024 / 1024).toFixed(1); + return res.status(403).json({ success: false, error: `Upload quota exceeded (${usedMB} MB of ${user.quotaMB} MB used)` }); + } + + const bucket = db.s3Config?.bucket || ""; + const key = buildS3Key(prefix, 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 }); + } +}); + +// Called by client after a successful direct-to-S3 upload to update quota + record AMPP placement +app.post("/api/presigned/complete", requireAuth, (req, res) => { + const { key, size } = req.body; + const user = db.users.find(u => u.username === req.sessionData.user); + if (user && size) { + user.uploadedBytes = (user.uploadedBytes || 0) + size; + saveData(db); + } + console.log(`[presigned] Completed: ${key} (${size} bytes) by ${req.sessionData.user}`); + res.json({ success: true }); +}); + +// ==================== UDP UPLOAD SESSION ==================== +// Sessions stored in memory; relay handles actual transfer +const udpSessions = new Map(); + +app.post("/api/udp/session", requireAuth, async (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 publicRelayUrl = (db.relayConfig?.publicRelayUrl || "").trim() || relayUrl; + const sessionId = crypto.randomBytes(16).toString("hex"); + const key = buildS3Key(prefix, filename); + const s3Cfg = db.s3Config || {}; + + // Register session on the relay server so it can accept chunks and upload to S3 + try { + const relayResp = await fetch(`${relayUrl}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, filename, size, key, + bucket: s3Cfg.bucket || "", + s3Config: { + endpoint: s3Cfg.endpoint || "", + region: s3Cfg.region || "us-east-1", + accessKeyId: s3Cfg.accessKeyId || "", + secretAccessKey: s3Cfg.secretAccessKey || "", + }, + }), + signal: AbortSignal.timeout(5000), + }); + if (!relayResp.ok) { + const err = await relayResp.json().catch(() => ({})); + return res.status(502).json({ success: false, error: `Relay error: ${err.error || relayResp.status}` }); + } + } catch (err) { + console.error("[UDP] Failed to register session on relay:", err.message); + return res.status(502).json({ success: false, error: `Cannot reach relay: ${err.message}` }); + } + + udpSessions.set(sessionId, { + sessionId, filename, size, key, + status: "pending", + createdAt: Date.now(), + updatedAt: Date.now(), + }); + setTimeout(() => udpSessions.delete(sessionId), 2 * 60 * 60 * 1000); + res.json({ success: true, sessionId, relayUrl: publicRelayUrl, udpPort, key, s3Bucket: s3Cfg.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_DEFAULT = process.env.AMPP_BASE_URL || "https://us-east-1.gvampp.com"; +function getAmppBase() { return (db.amppConfig?.baseUrl || AMPP_BASE_DEFAULT).trim(); } +function getAmppApiKey() { return db.amppConfig?.apiKey || 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(`${getAmppBase()}/identity/connect/token`, { + method: "POST", + headers: { "Authorization": `Basic ${getAmppApiKey()}`, "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; +} + +// ---- AMPP Config admin endpoints ---- +app.get("/api/ampp/config", requireAdmin, (req, res) => { + const cfg = db.amppConfig || {}; + res.json({ + success: true, + config: { + baseUrl: cfg.baseUrl || AMPP_BASE_DEFAULT, + apiKeyExists: !!(cfg.apiKey), + } + }); +}); + +app.put("/api/ampp/config", requireAdmin, (req, res) => { + const { baseUrl, apiKey } = req.body; + if (!db.amppConfig) db.amppConfig = {}; + if (baseUrl !== undefined) db.amppConfig.baseUrl = baseUrl.trim(); + if (apiKey) db.amppConfig.apiKey = apiKey.trim(); + amppToken = null; amppTokenExpiry = 0; // invalidate cached token + saveData(db); + res.json({ success: true, message: "AMPP configuration saved" }); +}); + +app.post("/api/ampp/test", requireAdmin, async (req, res) => { + const { baseUrl, apiKey } = req.body; + const testBase = (baseUrl || getAmppBase()).trim(); + const testApiKey = (apiKey || getAmppApiKey()).trim(); + if (!testApiKey) return res.status(400).json({ success: false, error: "API key is required to test" }); + try { + const r = await fetch(`${testBase}/identity/connect/token`, { + method: "POST", + headers: { "Authorization": `Basic ${testApiKey}`, "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials&scope=platform", + signal: AbortSignal.timeout(8000), + }); + if (!r.ok) { + const text = await r.text().catch(() => ""); + return res.status(400).json({ success: false, error: `Authentication failed (HTTP ${r.status})${text ? ': ' + text.slice(0, 120) : ''}` }); + } + const data = await r.json(); + if (!data.access_token) return res.status(400).json({ success: false, error: "No access token returned — check credentials" }); + res.json({ success: true, message: "AMPP connection confirmed! Authentication successful." }); + } catch (err) { + let msg = err.message; + if (err.name === "TimeoutError" || err.name === "AbortError") msg = "Request timed out — check the base URL"; + else if (msg.includes("ECONNREFUSED")) msg = "Connection refused — check the base URL and network"; + else if (msg.includes("ENOTFOUND")) msg = "Host not found — check the base URL"; + res.status(400).json({ success: false, error: msg }); + } +}); + +app.get("/api/ampp/jobs", requireAuth, async (req, res) => { + if (!getAmppApiKey()) 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 = `${getAmppBase()}/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}` }); + const jobData = await r.json(); + // Debug: log the first job's full structure to identify field names + const items = Array.isArray(jobData) ? jobData : (jobData?.items || jobData?.results || []); + if (items.length > 0) { + console.log("[AMPP] Sample job keys:", Object.keys(items[0])); + console.log("[AMPP] Sample job data:", JSON.stringify(items[0]).substring(0, 1000)); + } + res.json({ success: true, jobs: jobData }); + } 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 (!getAmppApiKey()) return res.status(503).json({ success: false, error: "AMPP API key not configured" }); + try { + const token = await getAmppToken(); + const r = await fetch(`${getAmppBase()}/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 }); } +}); + +// ---- AMPP Folder List (for UI picker) ---- +app.get("/api/ampp/folders/list", requireAuth, async (req, res) => { + if (!getAmppApiKey()) return res.status(503).json({ success: false, error: "AMPP API key not configured" }); + try { + const token = await getAmppToken(); + const result = await listAmppFolders(getAmppBase(), token); + res.json({ success: true, ...result }); + } catch (err) { + console.error("[AMPP folders] List error:", err.message); + res.status(500).json({ success: false, error: err.message }); + } +}); + + +// ---- AMPP Folder Placement ---- +// POST /api/ampp/place — place an already-ingested AMPP asset into virtual folders. +// Body: { assetId: "...", assetName: "NEWS--PKG--clip.mxf" } +// assetName uses the "--" delimiter convention — same as folder-organizer-v3.5.py. +// Finds or creates the nested AMPP folder hierarchy, then links the asset to the +// deepest folder. All v3.5 defensive guards are ported to Node.js (see ampp-folder-placer.js). +app.post("/api/ampp/place", requireAuth, async (req, res) => { + const { assetId, assetName } = req.body; + if (!assetId || !assetName) + return res.status(400).json({ success: false, error: "assetId and assetName are required" }); + if (!getAmppApiKey()) + return res.status(503).json({ success: false, error: "AMPP API key not configured" }); + try { + const token = await getAmppToken(); + const result = await placeAsset(getAmppBase(), assetName, assetId, token); + console.log(`[AMPP place] ${assetName} (${assetId}) → ${JSON.stringify(result)}`); + res.json({ success: true, ...result }); + } catch (err) { + console.error("[AMPP place] Error:", err.message); + res.status(500).json({ success: false, error: err.message }); + } +}); + +// ==================== CHROME EXTENSION DOWNLOAD ==================== +// ==================== SHARE LINKS ==================== +app.get("/api/sharelinks", requireAdmin, (req, res) => { + const links = (db.shareLinks || []).map(l => ({ + token: l.token, label: l.label, folder: l.folder, + expiresAt: l.expiresAt, maxUses: l.maxUses, uses: l.uses, + createdBy: l.createdBy, createdAt: l.createdAt, + active: !l.expiresAt || new Date(l.expiresAt) > new Date(), + })); + res.json({ success: true, links }); +}); + +app.post("/api/sharelinks", requireAdmin, (req, res) => { + const { label, folder, expiresInHours, maxUses } = req.body; + const token = crypto.randomBytes(20).toString("hex"); + const link = { + token, + label: (label || "Share Link").trim(), + folder: folder || "", + expiresAt: expiresInHours ? new Date(Date.now() + expiresInHours * 3600000).toISOString() : null, + maxUses: parseInt(maxUses) || 0, + uses: 0, + createdBy: req.sessionData.user, + createdAt: new Date().toISOString(), + }; + if (!db.shareLinks) db.shareLinks = []; + db.shareLinks.push(link); + saveData(db); + res.json({ success: true, link }); +}); + +app.delete("/api/sharelinks/:token", requireAdmin, (req, res) => { + if (!db.shareLinks) db.shareLinks = []; + const idx = db.shareLinks.findIndex(l => l.token === req.params.token); + if (idx === -1) return res.status(404).json({ success: false, error: "Link not found" }); + db.shareLinks.splice(idx, 1); + saveData(db); + res.json({ success: true }); +}); + +// Public share link info (no auth — just enough for the upload page to render) +app.get("/api/sharelinks/:token/info", (req, res) => { + const link = (db.shareLinks || []).find(l => l.token === req.params.token); + if (!link) return res.status(404).json({ success: false, error: "Link not found or expired" }); + if (link.expiresAt && new Date(link.expiresAt) < new Date()) + return res.status(410).json({ success: false, error: "This upload link has expired" }); + if (link.maxUses > 0 && link.uses >= link.maxUses) + return res.status(410).json({ success: false, error: "This upload link has reached its maximum uses" }); + res.json({ success: true, label: link.label, folder: link.folder, expiresAt: link.expiresAt }); +}); + +// Public upload via share link +app.post("/api/sharelinks/:token/upload", upload.array("files", 50), async (req, res) => { + if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" }); + const link = (db.shareLinks || []).find(l => l.token === req.params.token); + if (!link) return res.status(404).json({ success: false, error: "Invalid upload link" }); + if (link.expiresAt && new Date(link.expiresAt) < new Date()) { + for (const f of req.files || []) { try { fs.unlinkSync(f.path); } catch (_) {} } + return res.status(410).json({ success: false, error: "This upload link has expired" }); + } + if (link.maxUses > 0 && link.uses >= link.maxUses) { + for (const f of req.files || []) { try { fs.unlinkSync(f.path); } catch (_) {} } + return res.status(410).json({ success: false, error: "This upload link has reached its maximum uses" }); + } + 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 file types: ${blocked.map(f => f.originalname).join(", ")}` }); + } + try { + const prefix = link.folder || ""; + const bucket = db.s3Config?.bucket || ""; + const results = []; + for (const file of req.files) { + const key = buildS3Key(prefix, file.originalname); + const contentType = getMimeType(file.originalname, file.mimetype); + const fileBuffer = fs.readFileSync(file.path); + const putPromise = s3Client.send(new PutObjectCommand({ + Bucket: bucket, Key: key, Body: fileBuffer, ContentType: contentType, + })); + await withTimeout(putPromise, UPLOAD_TIMEOUT_MS, key); + try { fs.unlinkSync(file.path); } catch (_) {} + results.push({ originalName: file.originalname, key, size: file.size }); + } + link.uses = (link.uses || 0) + 1; + saveData(db); + res.json({ success: true, uploaded: results }); + } catch (err) { + if (req.files) for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} } + res.status(500).json({ success: false, error: err.message }); + } +}); + +// Public share upload page +app.get("/share/:token", (req, res) => { + res.sendFile(path.join(__dirname, "public", "share.html")); +}); + +app.get("/api/extension/download", requireAdmin, (req, res) => { + const extDir = path.join(__dirname, "chrome-extension"); + if (!fs.existsSync(extDir)) { + return res.status(404).json({ success: false, error: "chrome-extension directory not found" }); + } + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", "attachment; filename=\"dragon-wind-extension.zip\""); + const archive = archiver("zip", { zlib: { level: 6 } }); + archive.on("error", (err) => { console.error("[Extension ZIP]", err.message); res.end(); }); + archive.pipe(res); + archive.directory(extDir, "dragon-wind-extension"); + archive.finalize(); +}); + +// ==================== 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; + +const desktopSessions = new Map(); +app.post("/api/desktop/multipart/init", requireAuth, async (req, res) => { + const { filename, size, prefix, totalParts } = req.body; + if (!filename || !size || !totalParts) return res.status(400).json({ error: "Missing fields" }); + if (!s3Client) return res.status(503).json({ error: "S3 not configured" }); + const s3cfg = db.s3Config || {}; + const key = buildS3Key(prefix, filename); + try { + const cr = await s3Client.send(new CreateMultipartUploadCommand({ Bucket: s3cfg.bucket, Key: key })); + const uid = cr.UploadId; + const pp = await Promise.all(Array.from({ length: totalParts }, (_, i) => + getSignedUrl(s3Client, new UploadPartCommand({ Bucket: s3cfg.bucket, Key: key, UploadId: uid, PartNumber: i+1 }), { expiresIn: 3600 }) + )); + desktopSessions.set(uid, { key, bucket: s3cfg.bucket }); + console.log("[desktop] Init:", key, totalParts, "parts"); + res.json({ uploadId: uid, key, bucket: s3cfg.bucket, presignedParts: pp }); + } catch (err) { console.error("[desktop] Init error:", err.message); res.status(500).json({ error: err.message }); } +}); +app.post("/api/desktop/multipart/complete", requireAuth, async (req, res) => { + const { uploadId, key, bucket, parts } = req.body; + if (!uploadId || !parts) return res.status(400).json({ error: "Missing fields" }); + try { + await s3Client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a,b)=>a.PartNumber-b.PartNumber) } })); + desktopSessions.delete(uploadId); + console.log("[desktop] Complete:", key); + res.json({ success: true }); + } catch (err) { console.error("[desktop] Complete error:", err.message); res.status(500).json({ error: err.message }); } +}); +app.post("/api/desktop/multipart/abort", requireAuth, async (req, res) => { + const { uploadId, key, bucket } = req.body; + try { if (uploadId && key && bucket && s3Client) await s3Client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId })); } catch (_) {} + desktopSessions.delete(uploadId); + res.json({ success: true }); +}); + +// ==================== PARALLEL CHUNK UPLOAD (Option 4 — HTTP parallelism) ==================== +// Client splits files into 32 MB chunks and POSTs 6 concurrently. +// Server proxies each chunk to S3 as a multipart upload part. +// Achieves Aspera-class throughput over plain HTTP — no UDP, no port forwarding needed. +// Based on the same approach used by MASV. + +// In-memory multipart session map +// uploadId → { key, bucket, parts: [{PartNumber, ETag}], partCount } +const chunkSessions = new Map(); + +// 1. Initiate — creates S3 multipart upload, returns uploadId to client +app.post("/api/upload/initiate", requireAuth, async (req, res) => { + if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" }); + const { filename, prefix, contentType, totalParts } = req.body; + if (!filename || !totalParts) return res.status(400).json({ success: false, error: "filename and totalParts required" }); + if (isBlockedFile(filename)) return res.status(400).json({ success: false, error: `Blocked file type: ${filename}` }); + + const bucket = db.s3Config?.bucket || ""; + if (!bucket) return res.status(503).json({ success: false, error: "S3 bucket not configured" }); + + const key = buildS3Key(prefix, filename); + const mime = contentType || getMimeType(filename, "application/octet-stream"); + + try { + const resp = await s3Client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: mime })); + const uploadId = resp.UploadId; + chunkSessions.set(uploadId, { key, bucket, parts: [], partCount: parseInt(totalParts) }); + setTimeout(() => chunkSessions.delete(uploadId), 4 * 60 * 60 * 1000); // 4-hour TTL + console.log(`[chunk] Initiated: ${key} — ${totalParts} parts, uploadId=${uploadId}`); + res.json({ success: true, uploadId, key }); + } catch (err) { + console.error("[chunk] Initiate error:", err.message); + res.status(500).json({ success: false, error: err.message }); + } +}); + +// 2. Chunk — receive one chunk (multipart/form-data), proxy to S3 +const chunkMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 } }); +app.post("/api/upload/chunk", requireAuth, chunkMulter.single("chunk"), async (req, res) => { + if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" }); + const { uploadId, partNumber } = req.body; + const partNum = parseInt(partNumber); + if (!uploadId || !partNum) return res.status(400).json({ success: false, error: "uploadId and partNumber required" }); + + const session = chunkSessions.get(uploadId); + if (!session) return res.status(404).json({ success: false, error: "Session not found or expired" }); + + const body = req.file?.buffer; + if (!body || body.length === 0) return res.status(400).json({ success: false, error: "No chunk data" }); + + try { + const resp = await s3Client.send(new UploadPartCommand({ + Bucket: session.bucket, Key: session.key, + UploadId: uploadId, PartNumber: partNum, Body: body, + })); + session.parts.push({ PartNumber: partNum, ETag: resp.ETag }); + console.log(`[chunk] Part ${partNum}/${session.partCount} OK for ${session.key}`); + res.json({ success: true, partNumber: partNum, etag: resp.ETag }); + } catch (err) { + console.error(`[chunk] Part ${partNum} error:`, err.message); + res.status(500).json({ success: false, error: err.message }); + } +}); + +// 3. Complete — assemble all parts into final S3 object +app.post("/api/upload/complete", requireAuth, async (req, res) => { + if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" }); + const { uploadId } = req.body; + if (!uploadId) return res.status(400).json({ success: false, error: "uploadId required" }); + + const session = chunkSessions.get(uploadId); + if (!session) return res.status(404).json({ success: false, error: "Session not found or expired" }); + + const parts = session.parts.slice().sort((a, b) => a.PartNumber - b.PartNumber); + try { + await s3Client.send(new CompleteMultipartUploadCommand({ + Bucket: session.bucket, Key: session.key, UploadId: uploadId, + MultipartUpload: { Parts: parts }, + })); + chunkSessions.delete(uploadId); + console.log(`[chunk] Completed: ${session.key} (${parts.length} parts)`); + res.json({ success: true, key: session.key }); + } catch (err) { + console.error("[chunk] Complete error:", err.message); + try { await s3Client.send(new AbortMultipartUploadCommand({ Bucket: session.bucket, Key: session.key, UploadId: uploadId })); } catch (_) {} + chunkSessions.delete(uploadId); + res.status(500).json({ success: false, error: err.message }); + } +}); + +// 4. Abort — cancel a multipart upload (called on client-side error) +app.post("/api/upload/abort", requireAuth, async (req, res) => { + const { uploadId } = req.body; + const session = chunkSessions.get(uploadId); + if (session && s3Client) { + try { await s3Client.send(new AbortMultipartUploadCommand({ Bucket: session.bucket, Key: session.key, UploadId: uploadId })); } catch (_) {} + } + if (uploadId) chunkSessions.delete(uploadId); + res.json({ success: true }); +});