diff --git a/sftp-browser.js b/sftp-browser.js index e7bbefd..11a5cff 100644 --- a/sftp-browser.js +++ b/sftp-browser.js @@ -6,7 +6,7 @@ const logger = require('cyber-express-logger'); const sftp = require('ssh2-sftp-client'); const crypto = require('crypto'); const mime = require('mime'); -const cors = require('cors'); // Added CORS package +const cors = require('cors'); const bodyParser = require('body-parser'); const archiver = require('archiver'); const rawBodyParser = bodyParser.raw({ @@ -29,19 +29,20 @@ const normalizeRemotePath = remotePath => { const sessions = {}; const sessionActivity = {}; -const connections = {}; // Store connection details +const connections = {}; const getObjectHash = obj => { const hash = crypto.createHash('sha256'); hash.update(JSON.stringify(obj)); return hash.digest('hex'); -} +}; /** * @param {sftp.ConnectOptions} opts - * @returns {Promise|null} - * */ -const getSession = async (res, opts) => { + * @returns {Promise} + * @throws {Error} If connection fails + */ +const getSession = async (opts) => { const hash = getObjectHash(opts); const address = `${opts.username}@${opts.host}:${opts.port}`; if (sessions[hash]) { @@ -52,17 +53,25 @@ const getSession = async (res, opts) => { console.log(`Creating new connection to ${address}`); const session = new sftp(); sessions[hash] = session; - session.on('end', () => delete sessions[hash]); - session.on('close', () => delete sessions[hash]); + session.on('end', () => { + console.log(`Session ended for ${address}`); + delete sessions[hash]; + delete sessionActivity[hash]; + }); + session.on('close', () => { + console.log(`Session closed for ${address}`); + delete sessions[hash]; + delete sessionActivity[hash]; + }); try { await session.connect(opts); sessionActivity[hash] = Date.now(); + return session; } catch (error) { delete sessions[hash]; - console.log(`Connection to ${address} failed`); - return res ? res.sendError(error) : null; + console.error(`Connection to ${address} failed: ${error.message}`); + throw error; } - return session; }; const srv = express(); @@ -72,12 +81,11 @@ expressWs(srv, undefined, { } }); -// Enable CORS for all routes srv.use(cors({ - origin: '*', // Allow all origins; can be restricted to specific origins if needed - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Allow necessary methods - allowedHeaders: ['Content-Type', 'sftp-host', 'sftp-port', 'sftp-username', 'sftp-password', 'sftp-key'], // Allow relevant headers - credentials: false // Set to true if cookies or auth headers are needed + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'sftp-host', 'sftp-port', 'sftp-username', 'sftp-password', 'sftp-key'], + credentials: false })); srv.use(logger()); const staticDir = path.join(__dirname, 'web'); @@ -90,7 +98,7 @@ const initApi = asyncHandler(async (req, res, next) => { res.data.success = false; res.data.error = `${error}`.replace('Error: ', ''); res.sendData(status); - } + }; res.data = { success: true }; @@ -107,12 +115,14 @@ const initApi = asyncHandler(async (req, res, next) => { return res.sendError('Missing username header'); if (!req.connectionOpts.password && !req.connectionOpts.privateKey) return res.sendError('Missing password or key header'); - req.session = await getSession(res, req.connectionOpts); - if (!req.session) return; - next(); + try { + req.session = await getSession(req.connectionOpts); + next(); + } catch (error) { + res.sendError(error); + } }); -// API endpoint to fetch connection details by connectionId srv.get('/api/connect/:connectionId', asyncHandler(async (req, res) => { const connectionId = req.params.connectionId; const connection = connections[connectionId]; @@ -131,7 +141,6 @@ srv.get('/api/connect/:connectionId', asyncHandler(async (req, res) => { }); })); -// Auto-connection endpoint srv.post('/auto-connection', bodyParser.json(), asyncHandler(async (req, res) => { const connectionDetails = { host: req.body.host, @@ -141,7 +150,6 @@ srv.post('/auto-connection', bodyParser.json(), asyncHandler(async (req, res) => privateKey: req.body.privateKey || undefined }; - // Validate required fields if (!connectionDetails.host) { return res.status(400).json({ success: false, error: 'Missing host' }); } @@ -152,39 +160,32 @@ srv.post('/auto-connection', bodyParser.json(), asyncHandler(async (req, res) => return res.status(400).json({ success: false, error: 'Missing password or key' }); } - // Generate connection ID const connectionId = utils.randomHex(32); - - // Store connection details connections[connectionId] = { ...connectionDetails, created: Date.now() }; - // Test connection - const session = await getSession(res, connectionDetails); - if (!session) { + try { + const session = await getSession(connectionDetails); + session.end(); + const connectionUrl = `https://${req.get('host')}/connect/${connectionId}`; + res.json({ + success: true, + connectionId, + connectionUrl + }); + } catch (error) { delete connections[connectionId]; - return res.status(400).json({ success: false, error: 'Failed to establish connection' }); + res.status(400).json({ success: false, error: `Failed to establish connection: ${error.message}` }); } - session.end(); - - // Return connection URL - const connectionUrl = `https://${req.get('host')}/connect/${connectionId}`; - res.json({ - success: true, - connectionId, - connectionUrl - }); })); -// Handle connection URL access srv.get('/connect/:connectionId', asyncHandler(async (req, res) => { const connectionId = req.params.connectionId; if (!connections[connectionId]) { return res.status(404).send('Connection not found'); } - // Serve the main application page (index.html) res.sendFile(path.join(staticDir, 'index.html')); })); @@ -194,8 +195,8 @@ srv.get('/api/sftp/key', initApi, async (req, res) => { keyedRequests[res.data.key] = req; res.sendData(); }); + srv.get('/api/sftp/directories/list', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); res.data.includesFiles = req.query.dirsOnly === 'true' ? false : true; @@ -210,25 +211,23 @@ srv.get('/api/sftp/directories/list', initApi, async (req, res) => { res.sendError(error); } }); + srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { if (!wsReq.query.key) return ws.close(); const req = keyedRequests[wsReq.query.key]; if (!req) return ws.close(); - // Add uniqueness to the connection opts - // This forces a new connection to be created req.connectionOpts.ts = Date.now(); - // Create the session and throw an error if it fails - /** @type {sftp} */ - const session = await getSession(null, req.connectionOpts); - const sessionHash = getObjectHash(req.connectionOpts); - if (!session) { + let session; + try { + session = await getSession(req.connectionOpts); + } catch (error) { ws.send(JSON.stringify({ success: false, - error: 'Failed to create session!' + error: `Failed to create session: ${error.message}` })); return ws.close(); } - // Normalize the file path or throw an error if it's missing + const sessionHash = getObjectHash(req.connectionOpts); const filePath = normalizeRemotePath(wsReq.query.path); if (!filePath) { ws.send(JSON.stringify({ @@ -237,7 +236,6 @@ srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { })); return ws.close(); } - // Get the query const query = wsReq.query.query; if (!query) { ws.send(JSON.stringify({ @@ -246,13 +244,11 @@ srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { })); return ws.close(); } - // Update the session activity periodically let interval; const updateActivity = () => { sessionActivity[sessionHash] = Date.now(); }; interval = setInterval(updateActivity, 1000 * 1); - // Handle websocket closure let isClosed = false; ws.on('close', () => { console.log(`Directory search websocket closed`); @@ -261,14 +257,11 @@ srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { delete sessionActivity[sessionHash]; isClosed = true; }); - // Listen for messages console.log(`Websocket opened to search directory ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`); - // Function to get a directory listing const scanDir = async (dirPath) => { try { const list = await session.list(dirPath); return [...list].sort((a, b) => { - // Sort by name if (a.name < b.name) return -1; if (a.name > b.name) return 1; return 0; @@ -277,7 +270,6 @@ srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { return null; } }; - // Function to send a list when there are enough files let matchedFiles = []; let lastSend = 0; const sendList = () => { @@ -291,8 +283,7 @@ srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { lastSend = Date.now(); } }; - // Function to recursively search a directory - const recurse = async dirPath => { + const recurse = async (dirPath) => { if (isClosed) return; ws.send(JSON.stringify({ success: true, @@ -314,22 +305,19 @@ srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { matchedFiles.push(file); } if ((Date.now() - lastSend) > 1000) sendList(); - if (file.type == 'd') { + if (file.type === 'd') { await recurse(file.path); } } }; - // Start the search await recurse(filePath); if (isClosed) return; sendList(); - // Send a complete message ws.send(JSON.stringify({ success: true, status: 'complete' })); - // Close the websocket ws.close(); }); + srv.post('/api/sftp/directories/create', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -340,8 +328,8 @@ srv.post('/api/sftp/directories/create', initApi, async (req, res) => { res.sendError(error); } }); + srv.delete('/api/sftp/directories/delete', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -352,8 +340,8 @@ srv.delete('/api/sftp/directories/delete', initApi, async (req, res) => { res.sendError(error); } }); + srv.get('/api/sftp/files/exists', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -366,8 +354,8 @@ srv.get('/api/sftp/files/exists', initApi, async (req, res) => { res.sendError(error); } }); + srv.post('/api/sftp/files/create', initApi, rawBodyParser, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -378,8 +366,8 @@ srv.post('/api/sftp/files/create', initApi, rawBodyParser, async (req, res) => { res.sendError(error); } }); + srv.put('/api/sftp/files/append', initApi, rawBodyParser, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -390,25 +378,23 @@ srv.put('/api/sftp/files/append', initApi, rawBodyParser, async (req, res) => { res.sendError(error); } }); + srv.ws('/api/sftp/files/append', async (ws, wsReq) => { if (!wsReq.query.key) return ws.close(); const req = keyedRequests[wsReq.query.key]; if (!req) return ws.close(); - // Add uniqueness to the connection opts - // This forces a new connection to be created req.connectionOpts.ts = Date.now(); - // Create the session and throw an error if it fails - /** @type {sftp} */ - const session = await getSession(null, req.connectionOpts); - const sessionHash = getObjectHash(req.connectionOpts); - if (!session) { + let session; + try { + session = await getSession(req.connectionOpts); + } catch (error) { ws.send(JSON.stringify({ success: false, - error: 'Failed to create session!' + error: `Failed to create session: ${error.message}` })); return ws.close(); } - // Normalize the file path or throw an error if it's missing + const sessionHash = getObjectHash(req.connectionOpts); const filePath = normalizeRemotePath(wsReq.query.path); if (!filePath) { ws.send(JSON.stringify({ @@ -417,17 +403,14 @@ srv.ws('/api/sftp/files/append', async (ws, wsReq) => { })); return ws.close(); } - // Handle websocket closure ws.on('close', () => { console.log(`File append websocket closed`); session.end(); delete sessionActivity[sessionHash]; }); - // Listen for messages console.log(`Websocket opened to append to ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`); let isWriting = false; ws.on('message', async (data) => { - // If we're already writing, send an error if (isWriting) { return ws.send(JSON.stringify({ success: false, @@ -435,7 +418,6 @@ srv.ws('/api/sftp/files/append', async (ws, wsReq) => { })); } try { - // Append the data to the file isWriting = true; await session.append(data, filePath); ws.send(JSON.stringify({ success: true })); @@ -447,14 +429,12 @@ srv.ws('/api/sftp/files/append', async (ws, wsReq) => { return ws.close(); } isWriting = false; - // Update the session activity sessionActivity[sessionHash] = Date.now(); }); - // Send a ready message ws.send(JSON.stringify({ success: true, status: 'ready' })); }); + srv.delete('/api/sftp/files/delete', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -465,8 +445,8 @@ srv.delete('/api/sftp/files/delete', initApi, async (req, res) => { res.sendError(error); } }); + srv.put('/api/sftp/files/move', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.pathOld = normalizeRemotePath(req.query.pathOld); res.data.pathNew = normalizeRemotePath(req.query.pathNew); @@ -479,8 +459,8 @@ srv.put('/api/sftp/files/move', initApi, async (req, res) => { res.sendError(error); } }); + srv.put('/api/sftp/files/copy', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.pathSrc = normalizeRemotePath(req.query.pathSrc); res.data.pathDest = normalizeRemotePath(req.query.pathDest); @@ -493,8 +473,8 @@ srv.put('/api/sftp/files/copy', initApi, async (req, res) => { res.sendError(error); } }); + srv.put('/api/sftp/files/chmod', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); @@ -506,38 +486,30 @@ srv.put('/api/sftp/files/chmod', initApi, async (req, res) => { res.sendError(error); } }); + srv.get('/api/sftp/files/stat', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); - let stats = null; try { - stats = await session.stat(res.data.path); + const stats = await session.stat(res.data.path); + res.data.stats = stats; + res.sendData(); } catch (error) { - return res.sendError(error, 404); + res.sendError(error, 404); } - res.data.stats = stats; - res.sendData(); }); + const downloadSingleFileHandler = async (connectionOpts, res, remotePath, stats) => { let interval; - // Gracefully handle any errors try { - // Throw an error if it's not a file if (!stats.isFile) throw new Error('Not a file'); - // Add uniqueness to the connection opts - // This forces a new connection to be created connectionOpts.ts = Date.now(); - // Create the session and throw an error if it fails - const session = await getSession(res, connectionOpts); - if (!session) throw new Error('Failed to create session'); - // Continuously update the session activity + const session = await getSession(connectionOpts); interval = setInterval(() => { const hash = getObjectHash(connectionOpts); sessionActivity[hash] = Date.now(); }, 1000 * 1); - // When the response closes, end the session const handleClose = () => { clearInterval(interval); session.end(); @@ -545,46 +517,35 @@ const downloadSingleFileHandler = async (connectionOpts, res, remotePath, stats) res.on('end', handleClose); res.on('close', handleClose); res.on('error', handleClose); - // Set response headers res.setHeader('Content-Type', mime.getType(remotePath) || 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${path.basename(remotePath)}"`); res.setHeader('Content-Length', stats.size); - // Start the download console.log(`Starting download: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`); await session.get(remotePath, res); - // Force-end the response res.end(); - // On error, clear the interval and send a 400 response } catch (error) { + console.error(`Download failed: ${error.message}`); clearInterval(interval); res.status(400).end(); } }; + const downloadMultiFileHandler = async (connectionOpts, res, remotePaths, rootPath = '/') => { rootPath = normalizeRemotePath(rootPath); let interval; - // Gracefully handle any errors try { - // Add uniqueness to the connection opts - // This forces a new connection to be created connectionOpts.ts = Date.now(); - // Create the session and throw an error if it fails - const session = await getSession(res, connectionOpts); - if (!session) throw new Error('Failed to create session'); - // Continuously update the session activity - setInterval(() => { + const session = await getSession(connectionOpts); + interval = setInterval(() => { const hash = getObjectHash(connectionOpts); sessionActivity[hash] = Date.now(); }, 1000 * 1); - // Set response headers let fileName = `Files (${path.basename(rootPath) || 'Root'})`; - if (remotePaths.length == 1) + if (remotePaths.length === 1) fileName = path.basename(remotePaths[0]); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}.zip"`); - // Create the archive and start piping to the response const archive = archiver('zip'); archive.pipe(res); - // When the response closes, end the session const handleClose = () => { clearInterval(interval); archive.end(); @@ -593,23 +554,18 @@ const downloadMultiFileHandler = async (connectionOpts, res, remotePaths, rootPa res.on('end', handleClose); res.on('close', handleClose); res.on('error', handleClose); - // Add file to the archive const addToArchive = async (remotePath) => { const archivePath = normalizeRemotePath(remotePath.replace(rootPath, '')); console.log(`Zipping: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`); - // Get file read stream const stream = session.createReadStream(remotePath); const waitToEnd = new Promise(resolve => { stream.on('end', resolve); }); - // Add file to archive archive.append(stream, { name: archivePath }); - // Wait for the stream to end await waitToEnd; }; - // Recurse through directories and archive files const recurse = async (remotePath) => { try { const stats = await session.stat(remotePath); @@ -626,93 +582,82 @@ const downloadMultiFileHandler = async (connectionOpts, res, remotePaths, rootPa } } } - } catch (error) { } + } catch (error) { + console.error(`Error processing ${remotePath}: ${error.message}`); + } }; for (const remotePath of remotePaths) { await recurse(remotePath); } - // Finalize the archive archive.on('close', () => res.end()); archive.finalize(); - // On error, clear the interval and send a 400 response } catch (error) { + console.error(`Multi-file download failed: ${error.message}`); clearInterval(interval); res.status(400).end(); } }; + srv.get('/api/sftp/files/get/single', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; - // Get the normalized path and throw an error if it's missing const remotePath = normalizeRemotePath(req.query.path); if (!remotePath) return res.sendError('Missing path', 400); try { const stats = await session.stat(remotePath); - // Handle the download await downloadSingleFileHandler(req.connectionOpts, res, remotePath, stats); } catch (error) { - res.status(400).end(); + res.sendError(error, 400); } }); + const rawDownloads = {}; srv.get('/api/sftp/files/get/single/url', initApi, async (req, res) => { - /** @type {sftp} */ const session = req.session; - // Get the normalized path and throw an error if it's missing res.data.path = normalizeRemotePath(req.query.path); if (!res.data.path) return res.sendError('Missing path', 400); - // Get path stats and throw an error if it's not a file - let stats = null; + let stats; try { stats = await session.stat(res.data.path); if (!stats?.isFile) throw new Error('Not a file'); } catch (error) { return res.sendError(error); } - // Generate download URL const id = utils.randomHex(8); res.data.download_url = `https://${req.get('host')}/dl/${id}`; - // Create download handler rawDownloads[id] = { created: Date.now(), paths: [res.data.path], handler: async (req2, res2) => { - // Handle the download await downloadSingleFileHandler(req.connectionOpts, res2, res.data.path, stats); } - } + }; res.sendData(); }); + srv.get('/api/sftp/files/get/multi/url', initApi, async (req, res) => { try { - // Get the normalized path and throw an error if it's missing res.data.paths = JSON.parse(req.query.paths); if (!res.data.paths) throw new Error('Missing path(s)'); } catch (error) { return res.sendError(error); } - // Generate download URL const id = utils.randomHex(8); res.data.download_url = `https://${req.get('host')}/dl/${id}`; - // Create download handler rawDownloads[id] = { created: Date.now(), paths: res.data.paths, isZip: true, handler: async (req2, res2) => { - // Handle the download await downloadMultiFileHandler(req.connectionOpts, res2, res.data.paths, req.query.rootPath); } - } + }; res.sendData(); }); + srv.get('/dl/:id', async (req, res) => { - // Get the download handler const entry = rawDownloads[req.params.id]; if (!entry) return res.status(404).end(); - // If the user agent looks like a bot if (req.get('user-agent').match(/(bot|scrape)/)) { - // Send some HTML res.setHeader('Content-Type', 'text/html'); const html = /*html*/` @@ -738,7 +683,6 @@ srv.get('/dl/:id', async (req, res) => { srv.use((req, res) => res.status(404).end()); setInterval(() => { - // Delete inactive sessions for (const hash in sessions) { const lastActive = sessionActivity[hash]; if (!lastActive) continue; @@ -749,7 +693,6 @@ setInterval(() => { delete sessionActivity[hash]; } } - // Delete unused downloads for (const id in rawDownloads) { const download = rawDownloads[id]; if ((Date.now() - download.created) > 1000 * 60 * 60 * 12) { @@ -757,7 +700,6 @@ setInterval(() => { delete rawDownloads[id]; } } - // Delete expired connections for (const id in connections) { const connection = connections[id]; if ((Date.now() - connection.created) > 1000 * 60 * 60 * 12) { @@ -769,15 +711,13 @@ setInterval(() => { if (Electron.app) { Electron.app.whenReady().then(async () => { - // Start the server let port = 8001 + Math.floor(Math.random() * 999); await new Promise(resolve => { srv.listen(port, () => { - console.log(`App server listening on port ${port}`) + console.log(`App server listening on port ${port}`); resolve(); }); }); - // Open the window const window = new Electron.BrowserWindow({ width: 1100, height: 720, @@ -786,12 +726,10 @@ if (Electron.app) { minHeight: 200 }); window.loadURL(`http://localhost:${port}`); - // Quit the app when all windows are closed - // unless we're on macOS Electron.app.on('window-all-closed', () => { if (process.platform !== 'darwin') Electron.app.quit(); }); }); } else { srv.listen(config.port, () => console.log(`Standalone server listening on port ${config.port}`)); -} +} \ No newline at end of file