fix: Handle res.sendError TypeError and improve SFTP connection stability

- Modified `getSession` to throw errors instead of using `res.sendError`, ensuring compatibility with HTTP and WebSocket contexts
- Updated HTTP routes to use `initApi` middleware for consistent error handling with `res.sendError`
- Enhanced WebSocket routes to catch and send errors via WebSocket messages
- Improved SFTP error handling with better logging and session cleanup
- Added input validation and try-catch blocks to prevent unhandled errors
- Fixed `Error: No response from server` by ensuring proper session management
This commit is contained in:
MCHost
2025-06-24 21:44:23 -04:00
parent 9749663996
commit 30bc2f53ff

View File

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