1015 lines
46 KiB
JavaScript
1015 lines
46 KiB
JavaScript
"use strict";
|
|
const express = require("express");
|
|
const multer = require("multer");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { S3Client, PutObjectCommand, HeadBucketCommand, DeleteObjectCommand, 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, "");
|
|
}
|
|
|
|
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) => {
|
|
// Merge submitted values with saved config — secret is often blank (keep existing)
|
|
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 {
|
|
await testClient.send(new HeadBucketCommand({ Bucket: testCfg.bucket }));
|
|
|
|
res.json({ success: true, message: "✓ S3 connection confirmed! Credentials are valid and the bucket is accessible." });
|
|
} catch (err) {
|
|
let friendly = err.message;
|
|
if (err.name === "NoSuchBucket") friendly = `Bucket "${testCfg.bucket}" does not exist`;
|
|
else if (err.name === "InvalidAccessKeyId") friendly = "Invalid Access Key ID";
|
|
else if (err.name === "SignatureDoesNotMatch") friendly = "Invalid Secret Access Key";
|
|
else if (err.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) friendly = "Connection refused — check endpoint URL and port";
|
|
else if (err.code === "ENOTFOUND" || err.message?.includes("ENOTFOUND")) friendly = "Endpoint not found — check the URL";
|
|
else if (err.message?.includes("TLS") || err.message?.includes("certificate")) friendly = "TLS/SSL error — endpoint certificate issue";
|
|
console.error("[S3 Test] Error:", err.message);
|
|
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 = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname;
|
|
const contentType = getMimeType(file.originalname, file.mimetype);
|
|
console.log(`Uploading: ${key} (${file.size} bytes, ${contentType})`);
|
|
const uploadPromise = new Upload({
|
|
client: s3Client,
|
|
params: { Bucket: bucket, Key: key, Body: fs.createReadStream(file.path), ContentType: contentType },
|
|
queueSize: 4,
|
|
partSize: 10 * 1024 * 1024,
|
|
leavePartsOnError: false,
|
|
}).done();
|
|
const result = await withTimeout(uploadPromise, 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 (for Chrome extension HTTP mode) ====================
|
|
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 } = req.body;
|
|
if (!filename) return res.status(400).json({ success: false, error: "filename required" });
|
|
const bucket = db.s3Config?.bucket || "";
|
|
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : 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 });
|
|
}
|
|
});
|
|
|
|
// ==================== 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 = (prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : 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}` });
|
|
res.json({ success: true, jobs: await r.json() });
|
|
} 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 }); }
|
|
});
|
|
|
|
// ==================== 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 = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname;
|
|
const contentType = getMimeType(file.originalname, file.mimetype);
|
|
const uploadPromise = new Upload({
|
|
client: s3Client,
|
|
params: { Bucket: bucket, Key: key, Body: fs.createReadStream(file.path), ContentType: contentType },
|
|
queueSize: 4, partSize: 10 * 1024 * 1024, leavePartsOnError: false,
|
|
}).done();
|
|
await withTimeout(uploadPromise, 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;
|
|
|
|
// ==================== 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 = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : 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 });
|
|
});
|