2026-04-05 20:05:34 -04:00
"use strict" ;
const express = require ( "express" ) ;
const multer = require ( "multer" ) ;
const path = require ( "path" ) ;
const crypto = require ( "crypto" ) ;
2026-04-07 00:42:52 -04:00
const { S3Client , PutObjectCommand , HeadBucketCommand , DeleteObjectCommand , ListObjectsV2Command , CreateMultipartUploadCommand , UploadPartCommand , CompleteMultipartUploadCommand , AbortMultipartUploadCommand } = require ( "@aws-sdk/client-s3" ) ;
2026-04-05 20:05:34 -04:00
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" ) ;
2026-04-05 20:33:56 -04:00
const archiver = require ( "archiver" ) ;
2026-04-05 20:05:34 -04:00
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 : [
2026-04-06 19:58:29 -04:00
{ username : DEFAULT _ADMIN _USER , password : hashPassword ( DEFAULT _ADMIN _PASS ) , role : "admin" , created : new Date ( ) . toISOString ( ) ,
quotaMB : 0 , allowedFolders : [ ] , uploadedBytes : 0 }
2026-04-05 20:05:34 -04:00
] ,
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" ) ,
2026-04-06 19:58:29 -04:00
} ,
shareLinks : [ ] ,
2026-04-05 20:05:34 -04:00
} ;
saveData ( data ) ;
return data ;
}
2026-04-06 19:58:29 -04:00
// 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 ;
}
2026-04-05 20:05:34 -04:00
function saveData ( data ) {
ensureDataDir ( ) ;
fs . writeFileSync ( DATA _FILE , JSON . stringify ( data , null , 2 ) , "utf8" ) ;
}
2026-04-06 19:58:29 -04:00
let db = migrateData ( loadData ( ) ) ;
2026-04-05 20:05:34 -04:00
function getTree ( ) { return db . folderTree ; }
function setTree ( t ) { db . folderTree = t ; saveData ( db ) ; }
// ==================== S3 CLIENT (dynamic, reconfigurable) ====================
let s3Client = null ;
function buildS3Client ( cfg ) {
2026-04-05 20:42:21 -04:00
const endpoint = ( cfg . endpoint || "" ) . trim ( ) ;
const hasCustomEndpoint = endpoint . length > 0 ;
const isHttps = ! hasCustomEndpoint || endpoint . startsWith ( "https" ) ;
2026-04-05 20:05:34 -04:00
const agentOpts = { keepAlive : true , maxSockets : 25 } ;
2026-04-05 20:42:21 -04:00
const agent = isHttps
2026-04-05 20:05:34 -04:00
? new https . Agent ( { ... agentOpts , rejectUnauthorized : false } )
: new http . Agent ( agentOpts ) ;
return new S3Client ( {
2026-04-05 20:42:21 -04:00
// No endpoint → AWS SDK uses the official AWS S3 endpoint for the region
... ( hasCustomEndpoint ? { endpoint } : { } ) ,
2026-04-05 20:05:34 -04:00
region : cfg . region || "us-east-1" ,
credentials : {
accessKeyId : cfg . accessKeyId || "" ,
secretAccessKey : cfg . secretAccessKey || "" ,
} ,
2026-04-05 20:42:21 -04:00
// forcePathStyle needed for MinIO/Ceph/generic S3; harmless for AWS
forcePathStyle : hasCustomEndpoint ,
2026-04-05 20:05:34 -04:00
requestHandler : new NodeHttpHandler ( {
connectionTimeout : 15000 ,
socketTimeout : 300000 ,
2026-04-05 20:42:21 -04:00
... ( isHttps ? { httpsAgent : agent } : { httpAgent : agent } ) ,
2026-04-05 20:05:34 -04:00
} ) ,
} ) ;
}
function initS3 ( ) {
const cfg = db . s3Config || { } ;
2026-04-05 20:42:21 -04:00
if ( cfg . accessKeyId && cfg . secretAccessKey && cfg . bucket ) {
2026-04-05 20:05:34 -04:00
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 ;
}
2026-04-06 19:58:29 -04:00
// 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 ;
}
2026-04-05 20:05:34 -04:00
// ==================== MIDDLEWARE ====================
const upload = multer ( {
dest : "/tmp/uploads/" ,
limits : { fileSize : 50 * 1024 * 1024 * 1024 } ,
} ) ;
2026-04-06 21:30:17 -04:00
// 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 ( ) ;
} ) ;
2026-04-05 20:05:34 -04:00
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 } ) ;
} ) ;
2026-04-06 19:58:29 -04:00
// ---- 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" } ) ;
} ) ;
2026-04-05 20:05:34 -04:00
// ==================== 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 ;
2026-04-05 20:42:21 -04:00
// 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" } ) ;
2026-04-07 00:26:43 -04:00
// 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)" } ) ;
2026-04-05 20:05:34 -04:00
if ( ! db . s3Config ) db . s3Config = { } ;
2026-04-07 00:21:31 -04:00
db . s3Config . endpoint = ( endpoint || "" ) . trim ( ) ;
2026-04-05 20:05:34 -04:00
db . s3Config . region = region . trim ( ) ;
db . s3Config . bucket = bucket . trim ( ) ;
db . s3Config . accessKeyId = accessKeyId . trim ( ) ;
2026-04-07 00:26:43 -04:00
if ( secretAccessKey ) db . s3Config . secretAccessKey = secretAccessKey . trim ( ) ;
2026-04-05 20:05:34 -04:00
saveData ( db ) ;
initS3 ( ) ;
res . json ( { success : true , message : "S3 configuration saved" } ) ;
} ) ;
app . post ( "/api/s3/test" , requireAdmin , async ( req , res ) => {
2026-04-07 00:32:59 -04:00
const saved = db . s3Config || { } ;
2026-04-05 20:05:34 -04:00
const testCfg = {
2026-04-07 00:32:59 -04:00
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 ( ) ,
2026-04-05 20:05:34 -04:00
} ;
2026-04-07 00:32:59 -04:00
console . log ( "[S3 Test] Config:" , { endpoint : testCfg . endpoint , region : testCfg . region , bucket : testCfg . bucket , accessKeyId : testCfg . accessKeyId , hasSecret : ! ! testCfg . secretAccessKey } ) ;
2026-04-05 20:42:21 -04:00
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" } ) ;
2026-04-05 20:05:34 -04:00
2026-04-07 00:42:52 -04:00
const testClient = buildS3Client ( testCfg ) ;
2026-04-05 20:05:34 -04:00
try {
2026-04-07 00:42:52 -04:00
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." } ) ;
2026-04-05 20:05:34 -04:00
} catch ( err ) {
2026-04-07 00:42:52 -04:00
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 } ) ;
2026-04-05 20:05:34 -04:00
}
} ) ;
// ==================== RELAY CONFIG (Admin) ====================
app . get ( "/api/relay/config" , requireAdmin , ( req , res ) => {
const cfg = db . relayConfig || { } ;
2026-04-06 22:02:14 -04:00
res . json ( { success : true , config : {
relayUrl : cfg . relayUrl || "" ,
publicRelayUrl : cfg . publicRelayUrl || "" ,
udpPort : cfg . udpPort || 5000
} } ) ;
2026-04-05 20:05:34 -04:00
} ) ;
app . put ( "/api/relay/config" , requireAdmin , ( req , res ) => {
2026-04-06 22:02:14 -04:00
const { relayUrl , publicRelayUrl , udpPort } = req . body ;
2026-04-05 20:05:34 -04:00
if ( ! db . relayConfig ) db . relayConfig = { } ;
db . relayConfig . relayUrl = ( relayUrl || "" ) . trim ( ) ;
2026-04-06 22:02:14 -04:00
db . relayConfig . publicRelayUrl = ( publicRelayUrl || "" ) . trim ( ) ;
2026-04-05 20:05:34 -04:00
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." } ) ;
2026-04-06 19:58:29 -04:00
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 } " ` ) ;
2026-04-05 20:05:34 -04:00
try {
2026-04-06 19:58:29 -04:00
// 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) ` } ) ;
}
2026-04-05 20:05:34 -04:00
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 } ) ` ) ;
2026-04-07 00:52:54 -04:00
// 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 ) ;
2026-04-05 20:05:34 -04:00
if ( result ? . assumed ) console . log ( ` Assumed success (timeout): ${ key } ` ) ;
else console . log ( ` Confirmed success: ${ key } ` ) ;
try { fs . unlinkSync ( file . path ) ; } catch ( _ ) { }
2026-04-06 19:58:29 -04:00
if ( user ) { user . uploadedBytes = ( user . uploadedBytes || 0 ) + file . size ; }
2026-04-05 20:05:34 -04:00
results . push ( { originalName : file . originalname , key , size : file . size , timestamp : new Date ( ) . toISOString ( ) } ) ;
}
2026-04-06 19:58:29 -04:00
saveData ( db ) ;
2026-04-05 20:05:34 -04:00
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 } ) ;
}
} ) ;
2026-04-07 01:08:55 -04:00
// ==================== 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.
2026-04-05 20:05:34 -04:00
app . post ( "/api/presigned" , requireAuth , async ( req , res ) => {
if ( ! s3Client ) return res . status ( 503 ) . json ( { success : false , error : "S3 not configured" } ) ;
2026-04-07 01:08:55 -04:00
const { filename , prefix , contentType , size } = req . body ;
2026-04-05 20:05:34 -04:00
if ( ! filename ) return res . status ( 400 ) . json ( { success : false , error : "filename required" } ) ;
2026-04-07 01:08:55 -04:00
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) ` } ) ;
}
2026-04-05 20:05:34 -04:00
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 } ) ;
}
} ) ;
2026-04-07 01:08:55 -04:00
// Called by client after a successful direct-to-S3 upload to update quota tracking
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 } ) ;
} ) ;
2026-04-05 20:05:34 -04:00
// ==================== UDP UPLOAD SESSION ====================
// Sessions stored in memory; relay handles actual transfer
const udpSessions = new Map ( ) ;
2026-04-06 22:12:22 -04:00
app . post ( "/api/udp/session" , requireAuth , async ( req , res ) => {
2026-04-05 20:05:34 -04:00
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." } ) ;
2026-04-06 22:02:14 -04:00
const publicRelayUrl = ( db . relayConfig ? . publicRelayUrl || "" ) . trim ( ) || relayUrl ;
2026-04-05 20:05:34 -04:00
const sessionId = crypto . randomBytes ( 16 ) . toString ( "hex" ) ;
const key = ( prefix ? ` ${ prefix . replace ( /[-\/]+$/ , "" ) } -- ${ filename } ` : filename ) ;
2026-04-06 22:12:22 -04:00
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 } ` } ) ;
}
2026-04-05 20:05:34 -04:00
udpSessions . set ( sessionId , {
sessionId , filename , size , key ,
status : "pending" ,
createdAt : Date . now ( ) ,
updatedAt : Date . now ( ) ,
} ) ;
setTimeout ( ( ) => udpSessions . delete ( sessionId ) , 2 * 60 * 60 * 1000 ) ;
2026-04-06 22:12:22 -04:00
res . json ( { success : true , sessionId , relayUrl : publicRelayUrl , udpPort , key , s3Bucket : s3Cfg . bucket || "" } ) ;
2026-04-05 20:05:34 -04:00
} ) ;
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 ====================
2026-04-05 20:22:51 -04:00
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 || "" ; }
2026-04-05 20:05:34 -04:00
let amppToken = null ;
let amppTokenExpiry = 0 ;
async function getAmppToken ( ) {
if ( amppToken && Date . now ( ) < amppTokenExpiry - 60000 ) return amppToken ;
2026-04-05 20:22:51 -04:00
const r = await fetch ( ` ${ getAmppBase ( ) } /identity/connect/token ` , {
2026-04-05 20:05:34 -04:00
method : "POST" ,
2026-04-05 20:22:51 -04:00
headers : { "Authorization" : ` Basic ${ getAmppApiKey ( ) } ` , "Content-Type" : "application/x-www-form-urlencoded" } ,
2026-04-05 20:05:34 -04:00
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 ;
}
2026-04-05 20:22:51 -04:00
// ---- 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 } ) ;
}
} ) ;
2026-04-05 20:05:34 -04:00
app . get ( "/api/ampp/jobs" , requireAuth , async ( req , res ) => {
2026-04-05 20:22:51 -04:00
if ( ! getAmppApiKey ( ) ) return res . status ( 503 ) . json ( { success : false , error : "AMPP API key not configured" } ) ;
2026-04-05 20:05:34 -04:00
try {
const token = await getAmppToken ( ) ;
const limit = parseInt ( req . query . limit ) || 100 ;
const skip = parseInt ( req . query . skip ) || 0 ;
2026-04-05 20:22:51 -04:00
const url = ` ${ getAmppBase ( ) } /api/v1/queue/job/jobs/querypage?skip= ${ skip } &limit= ${ limit } &sort=created:dateTime&asc=false ` ;
2026-04-05 20:05:34 -04:00
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 } ` } ) ;
2026-04-07 09:16:24 -04:00
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 } ) ;
2026-04-05 20:05:34 -04:00
} 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 ) => {
2026-04-05 20:22:51 -04:00
if ( ! getAmppApiKey ( ) ) return res . status ( 503 ) . json ( { success : false , error : "AMPP API key not configured" } ) ;
2026-04-05 20:05:34 -04:00
try {
const token = await getAmppToken ( ) ;
2026-04-05 20:22:51 -04:00
const r = await fetch ( ` ${ getAmppBase ( ) } /api/v1/queue/job/jobs/ ${ encodeURIComponent ( req . params . jobId ) } ` , { headers : { "Authorization" : ` Bearer ${ token } ` } } ) ;
2026-04-05 20:05:34 -04:00
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 } ) ; }
} ) ;
2026-04-05 20:33:56 -04:00
// ==================== CHROME EXTENSION DOWNLOAD ====================
2026-04-06 19:58:29 -04:00
// ==================== 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 ) ;
2026-04-07 00:52:54 -04:00
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 ) ;
2026-04-06 19:58:29 -04:00
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" ) ) ;
} ) ;
2026-04-05 20:33:56 -04:00
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 ( ) ;
} ) ;
2026-04-05 20:05:34 -04:00
// ==================== 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 ;
2026-04-06 23:22:51 -04:00
// ==================== 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 } ) ;
} ) ;