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} * @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*/` Download shared files

Click here to download the file.

`; 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}`)); }