Files
sftp-browser/sftp-browser.js
MCHost 30bc2f53ff 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
2025-06-24 21:44:23 -04:00

735 lines
25 KiB
JavaScript

const path = require('path');
const express = require('express');
const expressWs = require('express-ws');
const asyncHandler = require('express-async-handler');
const logger = require('cyber-express-logger');
const sftp = require('ssh2-sftp-client');
const crypto = require('crypto');
const mime = require('mime');
const cors = require('cors');
const bodyParser = require('body-parser');
const archiver = require('archiver');
const rawBodyParser = bodyParser.raw({
limit: '16mb',
type: '*/*'
});
const dayjs = require('dayjs');
const dayjsAdvancedFormat = require('dayjs/plugin/advancedFormat');
dayjs.extend(dayjsAdvancedFormat);
const utils = require('web-resources');
const Electron = require('electron');
const config = require('./config.json');
const normalizeRemotePath = remotePath => {
remotePath = path.normalize(remotePath).replace(/\\/g, '/');
const split = remotePath.split('/').filter(String);
const joined = `/${split.join('/')}`;
return joined;
};
const sessions = {};
const sessionActivity = {};
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<sftp>}
* @throws {Error} If connection fails
*/
const getSession = async (opts) => {
const hash = getObjectHash(opts);
const address = `${opts.username}@${opts.host}:${opts.port}`;
if (sessions[hash]) {
console.log(`Using existing connection to ${address}`);
sessionActivity[hash] = Date.now();
return sessions[hash];
}
console.log(`Creating new connection to ${address}`);
const session = new sftp();
sessions[hash] = session;
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.error(`Connection to ${address} failed: ${error.message}`);
throw error;
}
};
const srv = express();
expressWs(srv, undefined, {
wsOptions: {
maxPayload: 1024 * 1024 * 4
}
});
srv.use(cors({
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');
srv.use(express.static(staticDir));
console.log(`Serving static files from ${staticDir}`);
const initApi = asyncHandler(async (req, res, next) => {
res.sendData = (status = 200) => res.status(status).json(res.data);
res.sendError = (error, status = 400) => {
res.data.success = false;
res.data.error = `${error}`.replace('Error: ', '');
res.sendData(status);
};
res.data = {
success: true
};
req.connectionOpts = {
host: req.headers['sftp-host'],
port: req.headers['sftp-port'] || 22,
username: req.headers['sftp-username'],
password: decodeURIComponent(req.headers['sftp-password'] || '') || undefined,
privateKey: decodeURIComponent(req.headers['sftp-key'] || '') || undefined,
};
if (!req.connectionOpts.host)
return res.sendError('Missing host header');
if (!req.connectionOpts.username)
return res.sendError('Missing username header');
if (!req.connectionOpts.password && !req.connectionOpts.privateKey)
return res.sendError('Missing password or key header');
try {
req.session = await getSession(req.connectionOpts);
next();
} catch (error) {
res.sendError(error);
}
});
srv.get('/api/connect/:connectionId', asyncHandler(async (req, res) => {
const connectionId = req.params.connectionId;
const connection = connections[connectionId];
if (!connection) {
return res.status(404).json({ success: false, error: 'Connection not found' });
}
res.json({
success: true,
connection: {
host: connection.host,
port: connection.port,
username: connection.username,
password: connection.password,
privateKey: connection.privateKey
}
});
}));
srv.post('/auto-connection', bodyParser.json(), asyncHandler(async (req, res) => {
const connectionDetails = {
host: req.body.host,
port: req.body.port || 22,
username: req.body.username,
password: req.body.password || undefined,
privateKey: req.body.privateKey || undefined
};
if (!connectionDetails.host) {
return res.status(400).json({ success: false, error: 'Missing host' });
}
if (!connectionDetails.username) {
return res.status(400).json({ success: false, error: 'Missing username' });
}
if (!connectionDetails.password && !connectionDetails.privateKey) {
return res.status(400).json({ success: false, error: 'Missing password or key' });
}
const connectionId = utils.randomHex(32);
connections[connectionId] = {
...connectionDetails,
created: Date.now()
};
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];
res.status(400).json({ success: false, error: `Failed to establish connection: ${error.message}` });
}
}));
srv.get('/connect/:connectionId', asyncHandler(async (req, res) => {
const connectionId = req.params.connectionId;
if (!connections[connectionId]) {
return res.status(404).send('Connection not found');
}
res.sendFile(path.join(staticDir, 'index.html'));
}));
const keyedRequests = {};
srv.get('/api/sftp/key', initApi, async (req, res) => {
res.data.key = utils.randomHex(32);
keyedRequests[res.data.key] = req;
res.sendData();
});
srv.get('/api/sftp/directories/list', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
res.data.includesFiles = req.query.dirsOnly === 'true' ? false : true;
if (!res.data.path) return res.sendError('Missing path', 400);
try {
res.data.list = await session.list(res.data.path);
if (res.data.list && !res.data.includesFiles) {
res.data.list = res.data.list.filter(item => item.type === 'd');
}
res.sendData();
} catch (error) {
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();
req.connectionOpts.ts = Date.now();
let session;
try {
session = await getSession(req.connectionOpts);
} catch (error) {
ws.send(JSON.stringify({
success: false,
error: `Failed to create session: ${error.message}`
}));
return ws.close();
}
const sessionHash = getObjectHash(req.connectionOpts);
const filePath = normalizeRemotePath(wsReq.query.path);
if (!filePath) {
ws.send(JSON.stringify({
success: false,
error: 'Missing path'
}));
return ws.close();
}
const query = wsReq.query.query;
if (!query) {
ws.send(JSON.stringify({
success: false,
error: 'Missing query'
}));
return ws.close();
}
let interval;
const updateActivity = () => {
sessionActivity[sessionHash] = Date.now();
};
interval = setInterval(updateActivity, 1000 * 1);
let isClosed = false;
ws.on('close', () => {
console.log(`Directory search websocket closed`);
session.end();
clearInterval(interval);
delete sessionActivity[sessionHash];
isClosed = true;
});
console.log(`Websocket opened to search directory ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`);
const scanDir = async (dirPath) => {
try {
const list = await session.list(dirPath);
return [...list].sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
} catch (error) {
return null;
}
};
let matchedFiles = [];
let lastSend = 0;
const sendList = () => {
if (matchedFiles.length > 0) {
ws.send(JSON.stringify({
success: true,
status: 'list',
list: matchedFiles
}));
matchedFiles = [];
lastSend = Date.now();
}
};
const recurse = async (dirPath) => {
if (isClosed) return;
ws.send(JSON.stringify({
success: true,
status: 'scanning',
path: dirPath
}));
const list = await scanDir(dirPath);
if (!list) {
ws.send(JSON.stringify({
success: false,
error: `Failed to scan directory ${dirPath}`
}));
return;
}
for (const file of list) {
if (isClosed) return;
file.path = `${dirPath}/${file.name}`;
if (file.name.toLowerCase().includes(query.toLowerCase())) {
matchedFiles.push(file);
}
if ((Date.now() - lastSend) > 1000) sendList();
if (file.type === 'd') {
await recurse(file.path);
}
}
};
await recurse(filePath);
if (isClosed) return;
sendList();
ws.send(JSON.stringify({ success: true, status: 'complete' }));
ws.close();
});
srv.post('/api/sftp/directories/create', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.mkdir(res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.delete('/api/sftp/directories/delete', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.rmdir(res.data.path, true);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.get('/api/sftp/files/exists', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
const type = await session.exists(res.data.path);
res.data.exists = type !== false;
res.data.type = type;
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.post('/api/sftp/files/create', initApi, rawBodyParser, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.put(req.body, res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/append', initApi, rawBodyParser, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.append(req.body, res.data.path);
res.sendData();
} catch (error) {
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();
req.connectionOpts.ts = Date.now();
let session;
try {
session = await getSession(req.connectionOpts);
} catch (error) {
ws.send(JSON.stringify({
success: false,
error: `Failed to create session: ${error.message}`
}));
return ws.close();
}
const sessionHash = getObjectHash(req.connectionOpts);
const filePath = normalizeRemotePath(wsReq.query.path);
if (!filePath) {
ws.send(JSON.stringify({
success: false,
error: 'Missing path'
}));
return ws.close();
}
ws.on('close', () => {
console.log(`File append websocket closed`);
session.end();
delete sessionActivity[sessionHash];
});
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 (isWriting) {
return ws.send(JSON.stringify({
success: false,
error: 'Writing in progress'
}));
}
try {
isWriting = true;
await session.append(data, filePath);
ws.send(JSON.stringify({ success: true }));
} catch (error) {
ws.send(JSON.stringify({
success: false,
error: error.toString()
}));
return ws.close();
}
isWriting = false;
sessionActivity[sessionHash] = Date.now();
});
ws.send(JSON.stringify({ success: true, status: 'ready' }));
});
srv.delete('/api/sftp/files/delete', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.delete(res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/move', initApi, async (req, res) => {
const session = req.session;
res.data.pathOld = normalizeRemotePath(req.query.pathOld);
res.data.pathNew = normalizeRemotePath(req.query.pathNew);
if (!res.data.pathOld) return res.sendError('Missing source path', 400);
if (!res.data.pathNew) return res.sendError('Missing destination path', 400);
try {
await session.rename(res.data.pathOld, res.data.pathNew);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/copy', initApi, async (req, res) => {
const session = req.session;
res.data.pathSrc = normalizeRemotePath(req.query.pathSrc);
res.data.pathDest = normalizeRemotePath(req.query.pathDest);
if (!res.data.pathSrc) return res.sendError('Missing source path', 400);
if (!res.data.pathDest) return res.sendError('Missing destination path', 400);
try {
await session.rcopy(res.data.pathSrc, res.data.pathDest);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/chmod', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
res.data.mode = req.query.mode;
try {
await session.chmod(res.data.path, res.data.mode);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.get('/api/sftp/files/stat', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
const stats = await session.stat(res.data.path);
res.data.stats = stats;
res.sendData();
} catch (error) {
res.sendError(error, 404);
}
});
const downloadSingleFileHandler = async (connectionOpts, res, remotePath, stats) => {
let interval;
try {
if (!stats.isFile) throw new Error('Not a file');
connectionOpts.ts = Date.now();
const session = await getSession(connectionOpts);
interval = setInterval(() => {
const hash = getObjectHash(connectionOpts);
sessionActivity[hash] = Date.now();
}, 1000 * 1);
const handleClose = () => {
clearInterval(interval);
session.end();
};
res.on('end', handleClose);
res.on('close', handleClose);
res.on('error', handleClose);
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);
console.log(`Starting download: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`);
await session.get(remotePath, res);
res.end();
} 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;
try {
connectionOpts.ts = Date.now();
const session = await getSession(connectionOpts);
interval = setInterval(() => {
const hash = getObjectHash(connectionOpts);
sessionActivity[hash] = Date.now();
}, 1000 * 1);
let fileName = `Files (${path.basename(rootPath) || 'Root'})`;
if (remotePaths.length === 1)
fileName = path.basename(remotePaths[0]);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}.zip"`);
const archive = archiver('zip');
archive.pipe(res);
const handleClose = () => {
clearInterval(interval);
archive.end();
session.end();
};
res.on('end', handleClose);
res.on('close', handleClose);
res.on('error', handleClose);
const addToArchive = async (remotePath) => {
const archivePath = normalizeRemotePath(remotePath.replace(rootPath, ''));
console.log(`Zipping: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`);
const stream = session.createReadStream(remotePath);
const waitToEnd = new Promise(resolve => {
stream.on('end', resolve);
});
archive.append(stream, {
name: archivePath
});
await waitToEnd;
};
const recurse = async (remotePath) => {
try {
const stats = await session.stat(remotePath);
if (stats.isFile) {
await addToArchive(remotePath);
} else if (stats.isDirectory) {
const list = await session.list(remotePath);
for (const item of list) {
const subPath = `${remotePath}/${item.name}`;
if (item.type === '-') {
await addToArchive(subPath);
} else {
await recurse(subPath);
}
}
}
} catch (error) {
console.error(`Error processing ${remotePath}: ${error.message}`);
}
};
for (const remotePath of remotePaths) {
await recurse(remotePath);
}
archive.on('close', () => res.end());
archive.finalize();
} 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) => {
const session = req.session;
const remotePath = normalizeRemotePath(req.query.path);
if (!remotePath) return res.sendError('Missing path', 400);
try {
const stats = await session.stat(remotePath);
await downloadSingleFileHandler(req.connectionOpts, res, remotePath, stats);
} catch (error) {
res.sendError(error, 400);
}
});
const rawDownloads = {};
srv.get('/api/sftp/files/get/single/url', initApi, async (req, res) => {
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
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);
}
const id = utils.randomHex(8);
res.data.download_url = `https://${req.get('host')}/dl/${id}`;
rawDownloads[id] = {
created: Date.now(),
paths: [res.data.path],
handler: async (req2, res2) => {
await downloadSingleFileHandler(req.connectionOpts, res2, res.data.path, stats);
}
};
res.sendData();
});
srv.get('/api/sftp/files/get/multi/url', initApi, async (req, res) => {
try {
res.data.paths = JSON.parse(req.query.paths);
if (!res.data.paths) throw new Error('Missing path(s)');
} catch (error) {
return res.sendError(error);
}
const id = utils.randomHex(8);
res.data.download_url = `https://${req.get('host')}/dl/${id}`;
rawDownloads[id] = {
created: Date.now(),
paths: res.data.paths,
isZip: true,
handler: async (req2, res2) => {
await downloadMultiFileHandler(req.connectionOpts, res2, res.data.paths, req.query.rootPath);
}
};
res.sendData();
});
srv.get('/dl/:id', async (req, res) => {
const entry = rawDownloads[req.params.id];
if (!entry) return res.status(404).end();
if (req.get('user-agent').match(/(bot|scrape)/)) {
res.setHeader('Content-Type', 'text/html');
const html = /*html*/`
<html>
<head>
<title>Download shared files</title>
<meta property="og:site_name" content="SFTP Browser" />
<meta property="og:title" content="Shared ${entry.isZip ? 'files' : 'file'}" />
<meta property="og:description" content="Click to download ${entry.isZip ? `these files compressed into a zip.` : `${path.basename(entry.paths[0])}.`} This link will expire on ${dayjs(entry.created + (1000 * 60 * 60 * 24)).format('YYYY-MM-DD [at] hh:mm:ss ([GMT]Z)')}." />
<meta name="theme-color" content="#1f2733">
<meta property="og:image" content="https://${req.get('host')}/icon.png" />
</head>
<body>
<p>Click <a href="${req.originalUrl}">here</a> to download the file.</p>
</body>
</html>
`;
res.send(html);
} else {
entry.handler(req, res);
}
});
srv.use((req, res) => res.status(404).end());
setInterval(() => {
for (const hash in sessions) {
const lastActive = sessionActivity[hash];
if (!lastActive) continue;
if ((Date.now() - lastActive) > 1000 * 60 * 5) {
console.log(`Deleting inactive session`);
sessions[hash].end();
delete sessions[hash];
delete sessionActivity[hash];
}
}
for (const id in rawDownloads) {
const download = rawDownloads[id];
if ((Date.now() - download.created) > 1000 * 60 * 60 * 12) {
console.log(`Deleting unused download`);
delete rawDownloads[id];
}
}
for (const id in connections) {
const connection = connections[id];
if ((Date.now() - connection.created) > 1000 * 60 * 60 * 12) {
console.log(`Deleting expired connection ${id}`);
delete connections[id];
}
}
}, 1000 * 30);
if (Electron.app) {
Electron.app.whenReady().then(async () => {
let port = 8001 + Math.floor(Math.random() * 999);
await new Promise(resolve => {
srv.listen(port, () => {
console.log(`App server listening on port ${port}`);
resolve();
});
});
const window = new Electron.BrowserWindow({
width: 1100,
height: 720,
autoHideMenuBar: true,
minWidth: 320,
minHeight: 200
});
window.loadURL(`http://localhost:${port}`);
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}`));
}