From dcd70e9763d69a8d78a38310254059c39f472f42 Mon Sep 17 00:00:00 2001 From: chaoszhu Date: Tue, 8 Jul 2025 22:06:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E5=B8=83=E5=B1=80&SFTP=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +- README.md | 5 +- server/app/config/index.js | 1 + server/app/middlewares/auth.js | 2 +- server/app/middlewares/index.js | 2 + server/app/middlewares/sftp-cache.js | 32 + server/app/server.js | 2 + server/app/socket/sftp-v2.js | 1527 ++++++++++ server/app/utils/db-class.js | 14 +- server/app/utils/verify-auth.js | 2 +- server/package.json | 2 +- server/version.json | 13 + web/package.json | 3 +- web/src/assets/scss/mobile.scss | 2 +- web/src/components/image-preview/index.vue | 553 ++++ web/src/components/text-editor/index.vue | 674 +++++ web/src/store/index.js | 2 +- web/src/views/terminal/components/docker.vue | 10 +- .../views/terminal/components/footer-bar.vue | 113 +- .../terminal/components/script-input.vue | 1 - web/src/views/terminal/components/sftp-v2.vue | 2653 ++++++++++++++++- .../terminal/components/terminal-tab.vue | 27 +- .../views/terminal/components/terminal.vue | 513 +++- web/vite.config.mjs | 5 + yarn.lock | 5 + 25 files changed, 5839 insertions(+), 332 deletions(-) create mode 100644 server/app/middlewares/sftp-cache.js create mode 100644 server/app/socket/sftp-v2.js create mode 100644 web/src/components/image-preview/index.vue create mode 100644 web/src/components/text-editor/index.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f8fbb..7c75d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,17 @@ ## TODO * 新增服务器定时任务功能 -* 重构SFTP面板 * 添加文件对传功能 -## [3.0.8](https://github.com/chaos-zhu/easynode/releases) (2025-06-18) +## [3.1.0](https://github.com/chaos-zhu/easynode/releases) (2025-07-13) +* 重构终端布局,支持单服务器多屏展示(下版本将上线单窗口模式, 单屏展示连接的多个服务器终端 +* 重构SFTP面板,实现全功能交互 * 终端页支持实例分组菜单并支持一键连接分组所有实例 * 优化终端渲染,提升缓存行数至5000 * 已授权ip优化UI并支持一键设置到登录白名单 * AI组件对话编辑功能与字体大小优化 -* 优化终端右键菜单 -* 修复脚本库下拉菜单重复选择 +* 其他优化 ## [3.0.7](https://github.com/chaos-zhu/easynode/releases) (2025-05-24) diff --git a/README.md b/README.md index c31ddb0..6cf88d3 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ _✨ 一个多功能Linux服务器WEB终端面板(webSSH&webSFTP) ✨_ ## 项目部署 -- v3.0.8版本开始用户名密码不再是admin/admin. 需查看**终端日志**,另外登录后请及时修改,避免日志残留敏感信息 +- v3.1.0版本开始用户名密码不再是admin/admin. 需查看**终端日志**,另外登录后请及时修改,避免日志残留敏感信息。请牢记账号密码,出于安全原因,不提供一键重置密码的脚本 - 默认web端口:**8082** ### docker-compose部署-自动更新(推荐) @@ -133,6 +133,9 @@ webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建 ## 项目赞助 +CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne. +[![EdgeOne Logo](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/?from=github) + [![image](https://img.shields.io/badge/NodeSupport-YXVM-red)](https://yxvm.com/) [![image](https://img.shields.io/badge/VPS-ZMTO-red)](https://zmto.com/) diff --git a/server/app/config/index.js b/server/app/config/index.js index f0ead44..99ab336 100644 --- a/server/app/config/index.js +++ b/server/app/config/index.js @@ -21,6 +21,7 @@ module.exports = { plusDBPath: path.join(process.cwd(),'app/db/plus.db'), aiConfigDBPath: path.join(process.cwd(),'app/db/ai-config.db'), chatHistoryDBPath: path.join(process.cwd(),'app/db/chat-history.db'), + favoriteSftpDBPath: path.join(process.cwd(),'app/db/favorite-sftp.db'), apiPrefix: '/api/v1', logConfig: { outDir: path.join(process.cwd(),'./app/db/logs'), diff --git a/server/app/middlewares/auth.js b/server/app/middlewares/auth.js index 6308c02..9ac7983 100644 --- a/server/app/middlewares/auth.js +++ b/server/app/middlewares/auth.js @@ -9,7 +9,7 @@ consola.info('路由白名单:', whitePath) const useAuth = async ({ request, res }, next) => { const { path, headers: { token } } = request - consola.info('verify path: ', path) + // consola.info('verify path: ', path) if (whitePath.includes(path)) return next() if (!token) return res.fail({ msg: '未登录', status: 403 }) // 验证token diff --git a/server/app/middlewares/index.js b/server/app/middlewares/index.js index 0e3ff7d..9975f71 100644 --- a/server/app/middlewares/index.js +++ b/server/app/middlewares/index.js @@ -6,11 +6,13 @@ const useLog = require('./log4') // 记录日志,需要等待路由处理完 const useKoaBody = require('./body') // 处理body参数 【请求需先走该中间件】 const { useRoutes, useAllowedMethods } = require('./router') // 路由管理 const useStatic = require('./static') // 静态目录 +const useSftpCacheStatic = require('./sftp-cache') // SFTP缓存文件服务 const compress = require('./compress') // br/gzip压缩 const history = require('./history') // vue-router的history模式 module.exports = [ ipFilter, + useSftpCacheStatic, // SFTP缓存文件服务 compress, history, useStatic, // staic先注册,不然会被jwt拦截 diff --git a/server/app/middlewares/sftp-cache.js b/server/app/middlewares/sftp-cache.js new file mode 100644 index 0000000..0073a76 --- /dev/null +++ b/server/app/middlewares/sftp-cache.js @@ -0,0 +1,32 @@ +const koaStatic = require('koa-static') +const path = require('path') +const { sftpCacheDir } = require('../config') + +const useSftpCacheStatic = async (ctx, next) => { + // 只处理 /sftp-cache 路径 + if (ctx.path.startsWith('/sftp-cache')) { + const relativePath = ctx.path.replace('/sftp-cache', '') + ctx.path = relativePath + + ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate') + ctx.set('Pragma', 'no-cache') + ctx.set('Expires', '0') + + const staticMiddleware = koaStatic(sftpCacheDir, { + maxage: 0, + gzip: true, + setHeaders: (res, filePath) => { + // 强制下载而不是在浏览器中打开 + const filename = path.basename(filePath) + + res.setHeader('Content-Disposition', `attachment; filename="${ encodeURIComponent(filename) }"`) + } + }) + + await staticMiddleware(ctx, next) + } else { + await next() + } +} + +module.exports = useSftpCacheStatic \ No newline at end of file diff --git a/server/app/server.js b/server/app/server.js index a4d9c3b..97e348d 100644 --- a/server/app/server.js +++ b/server/app/server.js @@ -5,6 +5,7 @@ const { httpPort } = require('./config') const middlewares = require('./middlewares') const wsTerminal = require('./socket/terminal') const wsSftp = require('./socket/sftp') +const wsSftpV2 = require('./socket/sftp-v2') const wsDocker = require('./socket/docker') const wsClientInfo = require('./socket/clients') const wsOnekey = require('./socket/onekey') @@ -25,6 +26,7 @@ function serverHandler(app, server) { app.proxy = true // 用于nginx反代时获取真实客户端ip wsTerminal(server) // 终端 wsSftp(server) // sftp + wsSftpV2(server) // sftp-v2 wsDocker(server) // docker wsOnekey(server) // 一键指令 wsClientInfo(server) // 客户端信息 diff --git a/server/app/socket/sftp-v2.js b/server/app/socket/sftp-v2.js new file mode 100644 index 0000000..b6cd0a7 --- /dev/null +++ b/server/app/socket/sftp-v2.js @@ -0,0 +1,1527 @@ +const rawPath = require('path') +const fs = require('fs-extra') +const SFTPClient = require('ssh2-sftp-client') +const { Server } = require('socket.io') +const { sftpCacheDir } = require('../config') +const { verifyAuthSync } = require('../utils/verify-auth') +const { isAllowedIp } = require('../utils/tools') +const { HostListDB, FavoriteSftpDB } = require('../utils/db-class') +const { getConnectionOptions } = require('./terminal') +const decryptAndExecuteAsync = require('../utils/decrypt-file') +const hostListDB = new HostListDB().getInstance() +const favoriteSftpDB = new FavoriteSftpDB().getInstance() +const { Client: SSHClient } = require('ssh2') + +const listenAction = (sftpClient, socket) => { + // 下载任务管理 + const downloadTasks = new Map() // taskId -> { abortController, startTime, totalSize, downloadedSize } + + // 上传任务管理 + const uploadTasks = new Map() // taskId -> { abortController, startTime, totalSize, uploadedSize, chunks } + const uploadCache = new Map() // taskId -> { chunks: [], totalChunks: 0 } + + socket.on('open_dir', async (path) => { + try { + const dirLs = await sftpClient.list(path) + console.log('dirLs:', dirLs.length) + socket.emit('dir_ls', dirLs, path) + } catch (err) { + socket.emit('not_exists_dir', err.message) + } + }) + + // rename file / directory + socket.on('rename', async ({ dirPath, oldName, newName }) => { + try { + if (!oldName || !newName || oldName === newName) throw new Error('无效文件名') + const src = rawPath.posix.join(dirPath, oldName) + const dest = rawPath.posix.join(dirPath, newName) + await sftpClient.rename(src, dest) + socket.emit('rename_success', { oldName, newName }) + // 返回最新目录列表 + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + socket.emit('rename_fail', err.message) + } + }) + + // delete file/dir + socket.on('delete', async ({ dirPath, name, type }) => { + try { + const target = rawPath.posix.join(dirPath, name) + if (type === 'd') { + await sftpClient.rmdir(target, true) + } else { + await sftpClient.delete(target) + } + socket.emit('delete_success') + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + socket.emit('delete_fail', err.message) + } + }) + + socket.on('delete_batch', async ({ dirPath, targets }) => { + try { + for (const { name, type } of targets) { + const target = rawPath.posix.join(dirPath, name) + if (type === 'd') { + await sftpClient.rmdir(target, true) + } else { + await sftpClient.delete(target) + } + } + socket.emit('delete_success') + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + socket.emit('delete_fail', err.message) + } + }) + + // move file/dir + socket.on('move', async ({ dirPath, destDir, name }) => { + try { + const src = rawPath.posix.join(dirPath, name) + await ensureDir(destDir) + const dest = rawPath.posix.join(destDir, name) + await sftpClient.rename(src, dest) + socket.emit('move_success') + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + socket.emit('move_fail', err.message) + } + }) + + socket.on('move_batch', async ({ dirPath, destDir, targets }) => { + try { + await ensureDir(destDir) + for (const { name } of targets) { + const src = rawPath.posix.join(dirPath, name) + const dest = rawPath.posix.join(destDir, name) + await sftpClient.rename(src, dest) + } + socket.emit('move_success') + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + socket.emit('move_fail', err.message) + } + }) + + async function ensureDir(dir) { + const exists = await sftpClient.exists(dir) + if (!exists) { + await sftpClient.mkdir(dir, true) + } + } + + const execCommand = (cmd) => new Promise((res, rej) => { + // 检查SSH客户端连接状态 + if (!sftpClient || !sftpClient.client) { + return rej(new Error('SSH客户端未初始化')) + } + + // 检查底层socket连接状态 + if (sftpClient.client._sock && sftpClient.client._sock.destroyed) { + return rej(new Error('SSH连接已断开')) + } + + // 检查SSH客户端状态 + if (sftpClient.client._readyTimeout === null && !sftpClient.client._authenticated) { + return rej(new Error('SSH客户端未认证')) + } + + try { + sftpClient.client.exec(cmd, (err, stream) => { + if (err) { + consola.error('执行命令失败:', cmd, err.message) + // 检查是否是连接相关错误 + if (err.message.includes('Not connected') || err.message.includes('Connection lost')) { + return rej(new Error('SSH连接已断开,无法执行命令')) + } + return rej(err) + } + + if (!stream) { + return rej(new Error('无法创建命令执行流')) + } + + let errMsg = '' + let isResolved = false + let timeout = null + + // 清理函数 + const cleanup = () => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (stream) { + try { + stream.removeAllListeners() + if (!stream.destroyed) { + stream.destroy() + } + } catch (cleanupErr) { + consola.warn('清理stream失败:', cleanupErr.message) + } + } + } + + // 统一的结果处理函数 + const resolveOnce = (result, isError = false) => { + if (isResolved) return + isResolved = true + cleanup() + + if (isError) { + rej(result) + } else { + res(result) + } + } + + // 设置超时保护(60秒) + timeout = setTimeout(() => { + consola.warn('命令执行超时:', cmd) + resolveOnce(new Error(`命令执行超时: ${ cmd }`), true) + }, 60000) + + // 处理错误输出 + stream.stderr.on('data', d => { + errMsg += d.toString() + }) + + // 处理错误事件 + stream.on('error', (streamErr) => { + consola.error('Stream错误:', cmd, streamErr.message) + if (streamErr.message.includes('Not connected')) { + resolveOnce(new Error('SSH连接在命令执行过程中断开'), true) + } else { + resolveOnce(streamErr, true) + } + }) + + // 处理连接断开 + stream.on('close', (code) => { + if (errMsg) { + consola.error('命令执行错误:', cmd, errMsg) + resolveOnce(new Error(`命令执行失败: ${ code }: ${ errMsg }`), true) + } else { + consola.info('命令执行完成:', cmd) + resolveOnce('success') + } + }) + + // 处理命令退出 + stream.on('exit', (code) => { + if (code === 0) { + consola.info('命令执行成功:', cmd) + resolveOnce() // 成功 + } else { + const errorMessage = errMsg || `命令退出码: ${ code }` + consola.error('命令执行失败:', cmd, errorMessage) + resolveOnce(new Error(errorMessage), true) + } + }) + + // 消耗 stdout 防止阻塞 + stream.on('data', () => {}) + }) + } catch (execErr) { + consola.error('execCommand异常:', cmd, execErr.message) + rej(execErr) + } + }) + + // -------- copy (download then upload) -------- + socket.on('copy_server_batch', async ({ dirPath, destDir, targets }) => { + try { + await ensureDir(destDir) + for (const { name } of targets) { + const src = rawPath.posix.join(dirPath, name) + // cp -r preserves dir/file, will overwrite if exists + const cmd = `cp -r -- "${ src }" "${ destDir }/"` + await execCommand(cmd) + } + + socket.emit('copy_success') + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + consola.error('copy error:', err.message) + socket.emit('copy_fail', err.message) + } + }) + + // -------- create file/folder -------- + socket.on('create_item', async ({ dirPath, name, type }) => { + try { + if (!name || !name.trim()) { + throw new Error('文件名不能为空') + } + + const trimmedName = name.trim() + + // 验证文件名(不能包含路径分隔符和其他无效字符) + const invalidChars = ['/', '\0'] + if (invalidChars.some(char => trimmedName.includes(char))) { + throw new Error('文件名包含无效字符') + } + + const targetPath = rawPath.posix.join(dirPath, trimmedName) + + // 检查文件/文件夹是否已存在 + const exists = await sftpClient.exists(targetPath) + if (exists) { + throw new Error(`${ type === 'folder' ? '文件夹' : '文件' } "${ trimmedName }" 已存在`) + } + + if (type === 'folder') { + consola.info(`创建文件夹: ${ targetPath }`) + await sftpClient.mkdir(targetPath) + socket.emit('create_success', `文件夹 "${ trimmedName }" 创建成功`) + } else if (type === 'file') { + consola.info(`创建文件: ${ targetPath }`) + // 创建空文件,使用 touch 命令 + const cmd = `touch "${ targetPath }"` + await execCommand(cmd) + socket.emit('create_success', `文件 "${ trimmedName }" 创建成功`) + } else { + throw new Error('无效的创建类型') + } + + // 返回最新目录列表 + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + consola.error('create error:', err.message) + socket.emit('create_fail', err.message) + } + }) + + // -------- compress files -------- + socket.on('compress_files', async ({ dirPath, targets, archiveName }) => { + try { + if (!targets || targets.length === 0) { + throw new Error('未选择要压缩的文件') + } + + if (!archiveName || !archiveName.trim()) { + throw new Error('压缩文件名不能为空') + } + + const trimmedArchiveName = archiveName.trim() + + // 验证压缩文件名 + const invalidChars = ['/', '\0'] + if (invalidChars.some(char => trimmedArchiveName.includes(char))) { + throw new Error('压缩文件名包含无效字符') + } + + const archivePath = rawPath.posix.join(dirPath, trimmedArchiveName) + + // 检查压缩文件是否已存在 + const exists = await sftpClient.exists(archivePath) + if (exists) { + throw new Error(`文件 "${ trimmedArchiveName }" 已存在`) + } + + // 构建要压缩的文件列表 + const fileNames = targets.map(t => `"${ t.name }"`).join(' ') + + // 使用 tar 命令压缩 + const tarCmd = `cd "${ dirPath }" && tar -czf "${ trimmedArchiveName }" ${ fileNames }` + + consola.info(`开始压缩文件: ${ targets.map(t => t.name).join(', ') } -> ${ trimmedArchiveName }`) + await execCommand(tarCmd) + consola.info(`压缩完成: ${ trimmedArchiveName }`) + + socket.emit('compress_success', `压缩文件 "${ trimmedArchiveName }" 创建成功`) + + // 返回最新目录列表 + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + consola.error('compress error:', err.message) + socket.emit('compress_fail', err.message) + } + }) + + // -------- decompress file -------- + socket.on('decompress_file', async ({ dirPath, fileName, mode, folderName }) => { + try { + if (!fileName || !fileName.trim()) { + throw new Error('文件名不能为空') + } + + const trimmedFileName = fileName.trim() + const archivePath = rawPath.posix.join(dirPath, trimmedFileName) + + // 检查压缩文件是否存在 + const exists = await sftpClient.exists(archivePath) + if (!exists) { + throw new Error(`文件 "${ trimmedFileName }" 不存在`) + } + + // 检查是否为支持的压缩格式 + const isValidArchive = /(\.tar\.gz|\.tgz|\.tar|\.zip)$/i.test(trimmedFileName) + if (!isValidArchive) { + throw new Error('不支持的压缩格式,仅支持 .tar.gz、.tgz、.tar、.zip 格式') + } + + // 确定解压目标目录 + let targetDir = dirPath + let targetDirName = '当前文件夹' + + if (mode === 'folder' && folderName) { + // 解压到同名文件夹 + targetDir = rawPath.posix.join(dirPath, folderName) + targetDirName = `文件夹 "${ folderName }"` + + // 检查目标文件夹是否已存在 + const targetExists = await sftpClient.exists(targetDir) + if (targetExists) { + throw new Error(`目标文件夹 "${ folderName }" 已存在`) + } + + // 创建目标文件夹 + await sftpClient.mkdir(targetDir) + consola.info(`创建目标文件夹: ${ targetDir }`) + } + + // 根据文件扩展名选择解压命令 + let decompressCmd = '' + if (/\.tar\.gz$|\.tgz$/i.test(trimmedFileName)) { + // tar.gz 或 tgz 格式 + if (mode === 'folder') { + decompressCmd = `cd "${ dirPath }" && tar -xzf "${ trimmedFileName }" -C "${ folderName }"` + } else { + decompressCmd = `cd "${ dirPath }" && tar -xzf "${ trimmedFileName }"` + } + } else if (/\.tar$/i.test(trimmedFileName)) { + // tar 格式 + if (mode === 'folder') { + decompressCmd = `cd "${ dirPath }" && tar -xf "${ trimmedFileName }" -C "${ folderName }"` + } else { + decompressCmd = `cd "${ dirPath }" && tar -xf "${ trimmedFileName }"` + } + } else if (/\.zip$/i.test(trimmedFileName)) { + // zip 格式 + if (mode === 'folder') { + decompressCmd = `cd "${ dirPath }" && unzip -o "${ trimmedFileName }" -d "${ folderName }"` + } else { + decompressCmd = `cd "${ dirPath }" && unzip -o "${ trimmedFileName }"` + } + } + + consola.info(`开始解压文件: ${ trimmedFileName } -> ${ targetDirName }`) + await execCommand(decompressCmd) + consola.info(`解压完成: ${ trimmedFileName } -> ${ targetDirName }`) + + socket.emit('decompress_success', `文件 "${ trimmedFileName }" 解压到${ targetDirName }成功`) + + // 返回最新目录列表 + const dirLs = await sftpClient.list(dirPath) + socket.emit('dir_ls', dirLs, dirPath) + } catch (err) { + consola.error('decompress error:', err.message) + socket.emit('decompress_fail', err.message) + } + }) + + // 下载功能 + socket.on('download_request', async ({ dirPath, targets }) => { + let remoteTarPath = null // 跟踪远程临时文件路径 + let taskId = null // 声明在外层以便错误处理时访问 + try { + if (!targets || targets.length === 0) { + throw new Error('未选择要下载的文件') + } + + taskId = Date.now() + '-' + Math.random().toString(36).slice(2) + const taskDir = rawPath.join(sftpCacheDir, taskId) + await fs.ensureDir(taskDir) + + const abortController = new AbortController() + downloadTasks.set(taskId, { + abortController, + startTime: Date.now(), + totalSize: 0, + downloadedSize: 0, + remoteTarPath: null // 跟踪远程临时文件 + }) + + if (targets.length === 1) { + // 单文件/文件夹逻辑(保持原有逻辑) + const target = targets[0] + socket.emit('download_started', { taskId, fileName: target.name }) + const srcPath = rawPath.posix.join(dirPath, target.name) + + if (target.type === 'd') { + // 文件夹:先在远端打包 + const tarFileName = `${ target.name }.tar.gz` + remoteTarPath = `/tmp/${ taskId }.tar.gz` + const localTarPath = rawPath.join(taskDir, tarFileName) + + // 保存远程文件路径到任务中 + downloadTasks.get(taskId).remoteTarPath = remoteTarPath + + // 检查是否被取消 + if (abortController.signal.aborted) { + await cleanupRemoteTarFile(remoteTarPath) + throw new Error('下载已取消') + } + + // 在远端打包 + consola.info(`开始打包文件夹: ${ srcPath }`) + const tarCmd = `cd "${ dirPath }" && tar -czf "${ remoteTarPath }" "${ target.name }"` + try { + await execCommand(tarCmd) + consola.info(`打包文件夹: ${ srcPath } 成功`) + } catch (tarErr) { + consola.error('打包文件夹失败:', tarErr.message) + throw new Error(`打包文件夹失败: ${ tarErr.message }`) + } + + // 获取打包文件大小 + const statResult = await sftpClient.stat(remoteTarPath) + downloadTasks.get(taskId).totalSize = statResult.size + + // 下载打包文件 + await downloadFileWithProgress(sftpClient, remoteTarPath, localTarPath, taskId, downloadTasks, socket, abortController) + + // 下载完成后立即清理远端临时文件 + await cleanupRemoteTarFile(remoteTarPath) + remoteTarPath = null // 已清理,置空 + + socket.emit('download_ready', { taskId, fileName: tarFileName }) + } else { + // 单文件:直接下载 + const localFilePath = rawPath.join(taskDir, target.name) + + // 获取文件大小 + const statResult = await sftpClient.stat(srcPath) + downloadTasks.get(taskId).totalSize = statResult.size + + await downloadFileWithProgress(sftpClient, srcPath, localFilePath, taskId, downloadTasks, socket, abortController) + + socket.emit('download_ready', { taskId, fileName: target.name }) + } + } else { + // 多文件逻辑:打包所有选中的文件/文件夹 + const archiveName = `selected-files-${ Date.now() }.tar.gz` + remoteTarPath = `/tmp/${ taskId }.tar.gz` + const localTarPath = rawPath.join(taskDir, archiveName) + + // 保存远程文件路径到任务中 + downloadTasks.get(taskId).remoteTarPath = remoteTarPath + + socket.emit('download_started', { taskId, fileName: archiveName }) + + // 检查是否被取消 + if (abortController.signal.aborted) { + await cleanupRemoteTarFile(remoteTarPath) + throw new Error('下载已取消') + } + + // 构建tar命令,打包所有选中的文件/文件夹 + const fileNames = targets.map(t => `"${ t.name }"`).join(' ') + const tarCmd = `cd "${ dirPath }" && tar -czf "${ remoteTarPath }" ${ fileNames }` + + consola.info(`开始打包多个文件: ${ targets.map(t => t.name).join(', ') }`) + try { + await execCommand(tarCmd) + consola.info('打包多个文件成功') + } catch (tarErr) { + consola.error('打包失败:', tarErr.message) + throw new Error(`打包失败: ${ tarErr.message }`) + } + + // 获取打包文件大小 + const statResult = await sftpClient.stat(remoteTarPath) + downloadTasks.get(taskId).totalSize = statResult.size + + // 下载打包文件 + await downloadFileWithProgress(sftpClient, remoteTarPath, localTarPath, taskId, downloadTasks, socket, abortController) + + // 下载完成后立即清理远端临时文件 + await cleanupRemoteTarFile(remoteTarPath) + remoteTarPath = null // 已清理,置空 + + socket.emit('download_ready', { taskId, fileName: archiveName }) + } + + downloadTasks.delete(taskId) + } catch (err) { + consola.error('下载失败:', err.message) + socket.emit('download_fail', err.message) + + // 清理远程临时文件(如果还存在) + if (remoteTarPath) { + await cleanupRemoteTarFile(remoteTarPath) + } + + // 清理任务(如果taskId存在) + if (taskId) { + downloadTasks.delete(taskId) + } + } + }) + + // 清理远程tar文件的辅助函数 + async function cleanupRemoteTarFile(remoteTarPath) { + if (!remoteTarPath) return + try { + await execCommand(`rm -f "${ remoteTarPath }"`) + consola.info(`已清理远程临时文件: ${ remoteTarPath }`) + } catch (cleanupErr) { + consola.warn('清理远程临时文件失败:', remoteTarPath, cleanupErr.message) + } + } + + // 取消下载 + socket.on('download_cancel', ({ taskId }) => { + const task = downloadTasks.get(taskId) + if (task) { + task.abortController.abort() + downloadTasks.delete(taskId) + socket.emit('download_cancelled', { taskId }) + + // 如果没有其他下载任务,清理缓存目录 + if (downloadTasks.size === 0) { + cleanupCacheDir() + } + } + }) + + // 清理缓存目录 + const cleanupCacheDir = () => { + try { + fs.emptyDirSync(sftpCacheDir) + consola.success('已清理 sftpCacheDir:', sftpCacheDir) + } catch (err) { + consola.warn('清理缓存目录失败:', err.message) + } + } + + // -------- 收藏相关功能 -------- + + // 获取收藏列表 + socket.on('get_favorites', async ({ hostId }) => { + try { + const favorites = await favoriteSftpDB.findAsync({ hostId }, { sort: { createTime: -1 } }) + socket.emit('favorites_list', favorites) + } catch (err) { + consola.error('获取收藏列表失败:', err.message) + socket.emit('favorite_error', '获取收藏列表失败') + } + }) + + // 添加收藏 + socket.on('add_favorite', async ({ hostId, path, name, type }) => { + try { + if (!hostId || !path || !name || !type) { + throw new Error('缺少必要参数') + } + + // 检查是否已存在相同路径的收藏 + const existing = await favoriteSftpDB.findOneAsync({ hostId, path }) + if (existing) { + socket.emit('favorite_error', '该路径已经收藏过了') + return + } + + // 添加新收藏 + const newFavorite = { + hostId, + path, + name, + type, + createTime: Date.now() + } + + await favoriteSftpDB.insertAsync(newFavorite) + socket.emit('favorite_added', `收藏 "${ name }" 成功`) + + consola.info(`用户收藏了路径: ${ path }`) + } catch (err) { + consola.error('添加收藏失败:', err.message) + socket.emit('favorite_error', err.message) + } + }) + + // 删除收藏 + socket.on('remove_favorite', async ({ hostId, path }) => { + try { + if (!hostId || !path) { + throw new Error('缺少必要参数') + } + + const result = await favoriteSftpDB.removeAsync({ hostId, path }, {}) + + if (result === 0) { + socket.emit('favorite_error', '收藏不存在') + return + } + + socket.emit('favorite_removed', '取消收藏成功') + consola.info(`用户取消收藏路径: ${ path }`) + } catch (err) { + consola.error('删除收藏失败:', err.message) + socket.emit('favorite_error', err.message) + } + }) + + // -------- 软链接解析功能 -------- + + // 解析软链接的真实路径 + socket.on('resolve_symlink', async ({ symlinkPath }) => { + try { + // 获取软链接的真实路径 + consola.info(`解析软链接: ${ symlinkPath }`) + const realPath = await sftpClient.realPath(symlinkPath) + consola.info(`软链接真实路径: ${ realPath }`) + + // 检查真实路径是否存在 + const stats = await sftpClient.stat(realPath) + const isDirectory = stats.isDirectory + + socket.emit('symlink_resolved', { + realPath, + isDirectory, + symlinkPath + }) + + consola.info(`软链接解析成功: ${ symlinkPath } -> ${ realPath } (${ isDirectory ? '目录' : '文件' })`) + + } catch (err) { + consola.error('软链接解析失败:', err.message) + socket.emit('symlink_resolve_error', { + error: err.message, + symlinkPath + }) + } + }) + + // -------- 文本文件编辑功能 -------- + + // 读取文件内容 + socket.on('read_file', async ({ filePath, fileSize }) => { + try { + // 检查文件大小限制 (1MB) + const maxFileSize = 1024 * 1024 + if (fileSize > maxFileSize) { + socket.emit('file_read_error', { + error: `文件过大(${ Math.round(fileSize / 1024 / 1024 * 100) / 100 }MB),仅支持编辑小于1MB的文件`, + filePath + }) + return + } + + // 检查文件是否存在 + const exists = await sftpClient.exists(filePath) + if (!exists) { + socket.emit('file_read_error', { + error: '文件不存在', + filePath + }) + return + } + + // 读取文件内容 + consola.info(`开始读取文件: ${ filePath }`) + const buffer = await sftpClient.get(filePath) + const content = buffer.toString('utf8') + + consola.info(`文件读取成功: ${ filePath },大小: ${ content.length } 字符`) + socket.emit('file_content', { content, filePath }) + + } catch (err) { + consola.error('读取文件失败:', err.message) + socket.emit('file_read_error', { + error: err.message, + filePath + }) + } + }) + + // -------- 图片预览功能 -------- + + // 读取图片文件内容 + socket.on('read_image', async ({ filePath, fileSize }) => { + try { + // 检查文件大小限制 (10MB) + const maxFileSize = 10 * 1024 * 1024 + if (fileSize > maxFileSize) { + socket.emit('image_read_error', { + error: `图片过大(${ Math.round(fileSize / 1024 / 1024 * 100) / 100 }MB),仅支持预览小于10MB的图片`, + filePath + }) + return + } + + // 检查文件是否存在 + const exists = await sftpClient.exists(filePath) + if (!exists) { + socket.emit('image_read_error', { + error: '图片文件不存在', + filePath + }) + return + } + + // 检查是否为图片文件 + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'] + const ext = rawPath.extname(filePath).toLowerCase().slice(1) + if (!imageExtensions.includes(ext)) { + socket.emit('image_read_error', { + error: '不支持的图片格式', + filePath + }) + return + } + + // 生成缓存文件名(使用时间戳避免冲突) + const fileName = rawPath.basename(filePath) + const timestamp = Date.now() + const cacheFileName = `image_${ timestamp }_${ fileName }` + const localImagePath = rawPath.join(sftpCacheDir, cacheFileName) + + consola.info(`开始下载图片到缓存: ${ filePath } -> ${ localImagePath }`) + + // 下载图片到本地缓存 + await sftpClient.fastGet(filePath, localImagePath) + + // 生成访问URL + const imageUrl = `/sftp-cache/${ cacheFileName }` + + consola.info(`图片下载成功: ${ filePath },缓存路径: ${ localImagePath }`) + + socket.emit('image_content', { + imageUrl, + filePath, + fileName, + fileSize + }) + + } catch (err) { + consola.error('图片预览失败:', err.message) + socket.emit('image_read_error', { + error: err.message, + filePath + }) + } + }) + + // 保存文件内容 + socket.on('save_file', async ({ filePath, content }) => { + try { + // 检查内容大小限制 (1MB) + const maxFileSize = 1024 * 1024 + const contentSize = Buffer.byteLength(content, 'utf8') + if (contentSize > maxFileSize) { + socket.emit('file_save_error', { + error: `文件内容过大(${ Math.round(contentSize / 1024 / 1024 * 100) / 100 }MB),仅支持保存小于1MB的文件`, + filePath + }) + return + } + + // 保存文件内容 + consola.info(`开始保存文件: ${ filePath },大小: ${ content.length } 字符`) + await sftpClient.put(Buffer.from(content, 'utf8'), filePath) + + consola.info(`文件保存成功: ${ filePath }`) + socket.emit('file_saved', { filePath }) + + } catch (err) { + consola.error('保存文件失败:', err.message) + socket.emit('file_save_error', { + error: err.message, + filePath + }) + } + }) + + // -------- 上传相关功能 -------- + + // 开始上传 + socket.on('upload_start', async ({ taskId, fileName, fileSize, targetPath }) => { + try { + if (!taskId || !fileName || !fileSize || !targetPath) { + throw new Error('上传参数不完整') + } + + // 创建上传任务 + const uploadTask = { + taskId, + fileName, + fileSize, + targetPath, + uploadedSize: 0, + chunks: [], + totalChunks: 0, + startTime: Date.now(), + lastProgressTime: Date.now(), + abortController: new AbortController() + } + + uploadTasks.set(taskId, uploadTask) + uploadCache.set(taskId, { chunks: [], totalChunks: 0 }) + + consola.info(`开始上传任务: ${ taskId } - ${ fileName }`) + socket.emit('upload_started', { taskId, fileName }) + + } catch (err) { + consola.error('开始上传失败:', err.message) + socket.emit('upload_fail', { taskId, error: err.message }) + } + }) + + // 上传文件分片 + socket.on('upload_chunk', async ({ taskId, chunkIndex, chunkData, totalChunks, isLastChunk }) => { + try { + const task = uploadTasks.get(taskId) + const cache = uploadCache.get(taskId) + + if (!task || !cache) { + throw new Error('上传任务不存在') + } + + if (task.abortController.signal.aborted) { + throw new Error('上传已取消') + } + + // 存储分片数据 + cache.chunks[chunkIndex] = chunkData + cache.totalChunks = totalChunks + + // 更新上传进度 + const uploadedChunks = cache.chunks.filter(chunk => chunk !== undefined).length + task.uploadedSize = (uploadedChunks / totalChunks) * task.fileSize + + // 计算速度和ETA + const now = Date.now() + const elapsed = (now - task.lastProgressTime) / 1000 + if (elapsed >= 1) { // 每秒更新一次进度 + const chunkProgress = Math.min((uploadedChunks / totalChunks) * 100, 100) + const totalElapsed = (now - task.startTime) / 1000 + const speed = totalElapsed > 0 ? task.uploadedSize / totalElapsed : 0 + const eta = speed > 0 ? (task.fileSize - task.uploadedSize) / speed : 0 + + socket.emit('upload_progress', { + taskId, + chunkProgress, + chunkUploadedSize: task.uploadedSize, + chunkTotalSize: task.fileSize, + sftpProgress: 0, + sftpUploadedSize: 0, + sftpTotalSize: task.fileSize, + speed: Math.round(speed), + eta: Math.round(eta), + stage: 'uploading' + }) + + task.lastProgressTime = now + } + + consola.info(`上传分片: ${ taskId } - ${ chunkIndex + 1 }/${ totalChunks }`) + socket.emit('upload_chunk_success', { taskId, chunkIndex }) + + // 如果是最后一个分片,开始合并和传输 + if (isLastChunk && uploadedChunks === totalChunks) { + await completeUpload(task, cache) + } + + } catch (err) { + consola.error('上传分片失败:', err.message) + socket.emit('upload_chunk_fail', { taskId, chunkIndex, error: err.message }) + } + }) + + // 完成上传的辅助函数 + async function completeUpload(task, cache) { + let tempFilePath = null + + try { + consola.info(`开始合并文件: ${ task.fileName }`) + + // 发送合并开始事件 + socket.emit('upload_progress', { + taskId: task.taskId, + chunkProgress: 100, + chunkUploadedSize: task.fileSize, + chunkTotalSize: task.fileSize, + sftpProgress: 0, + sftpUploadedSize: 0, + sftpTotalSize: task.fileSize, + speed: 0, + eta: 0, + stage: 'merging' + }) + + // 创建临时文件用于SFTP传输 + tempFilePath = rawPath.join(sftpCacheDir, `temp_${ task.taskId }_${ task.fileName }`) + const writeStream = fs.createWriteStream(tempFilePath) + + // 流式合并分片,避免大文件占用过多内存 + try { + for (let i = 0; i < cache.totalChunks; i++) { + if (!cache.chunks[i]) { + throw new Error(`分片 ${ i } 缺失`) + } + + const chunkBuffer = Buffer.from(cache.chunks[i]) + await new Promise((resolve, reject) => { + writeStream.write(chunkBuffer, (err) => { + if (err) reject(err) + else resolve() + }) + }) + + // 立即释放已写入的分片内存 + cache.chunks[i] = null + } + + await new Promise((resolve, reject) => { + writeStream.end((err) => { + if (err) reject(err) + else resolve() + }) + }) + } catch (mergeErr) { + writeStream.destroy() + throw mergeErr + } + + // 验证文件大小 + const stats = fs.statSync(tempFilePath) + if (stats.size !== task.fileSize) { + throw new Error(`文件大小不匹配: 期望 ${ task.fileSize }, 实际 ${ stats.size }`) + } + + // 发送SFTP传输开始事件 + socket.emit('upload_progress', { + taskId: task.taskId, + chunkProgress: 100, + chunkUploadedSize: task.fileSize, + chunkTotalSize: task.fileSize, + sftpProgress: 0, + sftpUploadedSize: 0, + sftpTotalSize: task.fileSize, + speed: 0, + eta: 0, + stage: 'transferring' + }) + + // 通过SFTP传输到远程服务器,使用fastPut带进度回调 + consola.info(`开始传输文件到远程服务器: ${ task.targetPath }`) + + let sftpStartTime = Date.now() + let lastSftpUpdateTime = sftpStartTime + let lastSftpUploadedSize = 0 + + await sftpClient.fastPut(tempFilePath, task.targetPath, { + step: (transferredBytes) => { + const now = Date.now() + const sftpProgress = Math.min((transferredBytes / task.fileSize) * 100, 100) + + // 计算SFTP传输速度和ETA(每500ms更新一次) + if (now - lastSftpUpdateTime >= 500) { + const elapsed = (now - lastSftpUpdateTime) / 1000 + const sizeDiff = transferredBytes - lastSftpUploadedSize + const sftpSpeed = elapsed > 0 ? sizeDiff / elapsed : 0 + const remainingBytes = task.fileSize - transferredBytes + const sftpEta = sftpSpeed > 0 ? remainingBytes / sftpSpeed : 0 + + socket.emit('upload_progress', { + taskId: task.taskId, + chunkProgress: 100, + chunkUploadedSize: task.fileSize, + chunkTotalSize: task.fileSize, + sftpProgress, + sftpUploadedSize: transferredBytes, + sftpTotalSize: task.fileSize, + speed: Math.round(sftpSpeed), + eta: Math.round(sftpEta), + stage: 'transferring' + }) + + lastSftpUpdateTime = now + lastSftpUploadedSize = transferredBytes + } + } + }) + + // 传输完成 + consola.info(`文件上传成功: ${ task.fileName }`) + socket.emit('upload_complete', { + taskId: task.taskId, + fileName: task.fileName, + targetPath: task.targetPath + }) + + } catch (err) { + consola.error('完成上传失败:', err.message) + socket.emit('upload_fail', { + taskId: task.taskId, + error: err.message + }) + } finally { + // 确保清理临时文件 + if (tempFilePath) { + try { + fs.unlinkSync(tempFilePath) + } catch (cleanupErr) { + consola.warn('清理临时文件失败:', cleanupErr.message) + } + } + + // 清理任务和缓存 + uploadTasks.delete(task.taskId) + uploadCache.delete(task.taskId) + } + } + + // 取消上传 + socket.on('upload_cancel', ({ taskId }) => { + const task = uploadTasks.get(taskId) + if (task) { + task.abortController.abort() + uploadTasks.delete(taskId) + uploadCache.delete(taskId) + + consola.info(`取消上传任务: ${ taskId }`) + socket.emit('upload_cancelled', { taskId }) + } + }) + + // 监听连接断开,清理下载任务和缓存 + socket.on('disconnect', async (reason) => { + try { + consola.info('SFTP连接断开,开始清理资源...', reason) + + // 清理定时器 + if (memoryCleanupInterval) { + clearInterval(memoryCleanupInterval) + } + + // 清理所有远程临时文件 + const remoteCleanupPromises = [] + for (const [taskId, task] of downloadTasks) { + try { + // 取消下载任务 + if (task.abortController) { + task.abortController.abort() + } + + // 如果有远程临时文件,添加到清理列表 + if (task.remoteTarPath) { + remoteCleanupPromises.push(cleanupRemoteTarFile(task.remoteTarPath)) + } + } catch (taskError) { + consola.warn(`清理下载任务 ${ taskId } 失败:`, taskError.message) + } + } + + // 并行清理所有远程临时文件 + if (remoteCleanupPromises.length > 0) { + try { + await Promise.all(remoteCleanupPromises) + consola.info(`连接断开时已清理 ${ remoteCleanupPromises.length } 个远程临时文件`) + } catch (err) { + consola.warn('连接断开时清理远程临时文件部分失败:', err.message) + } + } + + // 清理上传任务和相关资源 + for (const [taskId, task] of uploadTasks) { + try { + if (task.abortController) { + task.abortController.abort() + } + } catch (taskError) { + consola.warn(`清理上传任务 ${ taskId } 失败:`, taskError.message) + } + } + + // 清理上传缓存中的大文件数据 + for (const [taskId, cache] of uploadCache) { + try { + if (cache.chunks) { + // 释放分片内存 + cache.chunks.length = 0 + } + } catch (cacheError) { + consola.warn(`清理上传缓存 ${ taskId } 失败:`, cacheError.message) + } + } + + // 清理所有任务映射 + downloadTasks.clear() + uploadTasks.clear() + uploadCache.clear() + + // 清理本地缓存目录 + try { + cleanupCacheDir() + } catch (cleanupError) { + consola.warn('清理本地缓存目录失败:', cleanupError.message) + } + + consola.info('SFTP资源清理完成') + } catch (disconnectError) { + consola.error('Socket断开连接清理过程中发生错误:', disconnectError.message) + } + }) + + // 定期内存清理(可选优化) + const memoryCleanupInterval = setInterval(() => { + // 检查并清理超时的上传任务(超过1小时) + const now = Date.now() + const timeout = 2 * 60 * 60 * 1000 // 2小时 + + for (const [taskId, task] of uploadTasks) { + if (now - task.startTime > timeout) { + consola.warn(`清理超时上传任务: ${ taskId }`) + if (task.abortController) { + task.abortController.abort() + } + uploadTasks.delete(taskId) + uploadCache.delete(taskId) + } + } + + // 检查并清理超时的下载任务 + for (const [taskId, task] of downloadTasks) { + if (now - task.startTime > timeout) { + consola.warn(`清理超时下载任务: ${ taskId }`) + if (task.abortController) { + task.abortController.abort() + } + downloadTasks.delete(taskId) + } + } + }, 5 * 60 * 1000) // 每5分钟检查一次 + + // 带进度的文件下载函数 + async function downloadFileWithProgress(sftpClient, remotePath, localPath, taskId, downloadTasks, socket, abortController) { + return new Promise((resolve, reject) => { + const task = downloadTasks.get(taskId) + if (!task) { + return reject(new Error('任务不存在')) + } + + let lastUpdateTime = Date.now() + let lastDownloadedSize = 0 + let progressInterval = null + let readStream = null + let writeStream = null + + // 清理函数 + const cleanup = () => { + if (progressInterval) { + clearInterval(progressInterval) + progressInterval = null + } + if (readStream) { + readStream.destroy() + readStream = null + } + if (writeStream) { + writeStream.destroy() + writeStream = null + } + } + + // 错误处理函数 + const handleError = (err) => { + cleanup() + fs.unlink(localPath).catch(() => {}) + reject(err) + } + + progressInterval = setInterval(() => { + if (abortController.signal.aborted) { + cleanup() + reject(new Error('下载已取消')) + return + } + + const now = Date.now() + const currentTask = downloadTasks.get(taskId) + if (!currentTask) { + cleanup() + reject(new Error('任务已被删除')) + return + } + + const { totalSize, downloadedSize } = currentTask // , startTime + // const elapsed = (now - startTime) / 1000 // 秒 + const progress = totalSize > 0 ? (downloadedSize / totalSize * 100) : 0 + + // 计算速度 (bytes/s) + const timeDiff = (now - lastUpdateTime) / 1000 + const sizeDiff = downloadedSize - lastDownloadedSize + const speed = timeDiff > 0 ? sizeDiff / timeDiff : 0 + + // 计算剩余时间 + const remainingBytes = totalSize - downloadedSize + const eta = speed > 0 ? remainingBytes / speed : 0 + + socket.emit('download_progress', { + taskId, + progress: Math.min(progress, 100), + downloadedSize, + totalSize, + speed: Math.round(speed), + eta: Math.round(eta) + }) + + lastUpdateTime = now + lastDownloadedSize = downloadedSize + }, 1000) // 每1秒更新 + + try { + readStream = sftpClient.createReadStream(remotePath) + writeStream = fs.createWriteStream(localPath) + + readStream.on('data', (chunk) => { + if (abortController.signal.aborted) { + cleanup() + fs.unlink(localPath).catch(() => {}) // 删除部分下载的文件 + reject(new Error('下载已取消')) + return + } + + const currentTask = downloadTasks.get(taskId) + if (currentTask) { + currentTask.downloadedSize += chunk.length + } + }) + + readStream.on('error', handleError) + writeStream.on('error', handleError) + + writeStream.on('finish', () => { + cleanup() + resolve() + }) + + readStream.pipe(writeStream) + } catch (err) { + handleError(err) + } + }) + } +} + +module.exports = (httpServer) => { + const serverIo = new Server(httpServer, { + path: '/sftp-v2', + cors: { + origin: '*' + } + }) + serverIo.on('connection', (socket) => { + let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + if (!isAllowedIp(requestIP)) { + socket.emit('ip_forbidden', 'IP地址不在白名单中') + socket.disconnect() + return + } + let sftpClient = new SFTPClient() + consola.success('sftp-v2 websocket 已连接') + let jumpSshClients = [] + + // 添加socket本身的错误处理 + socket.on('error', (err) => { + consola.error('SFTP-v2 Socket连接错误:', err.message) + }) + + socket.on('ws_sftp', async ({ hostId, token }) => { + try { + const { code } = await verifyAuthSync(token, requestIP) + if (code !== 1) { + socket.emit('token_verify_fail') + socket.disconnect() + return + } + const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId }) + if (!targetHostInfo) throw new Error(`Host with ID ${ hostId } not found`) + + let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(rawPath.join(__dirname, 'plus.js'))) || {} + let { authType, host, port, username, jumpHosts } = targetHostInfo + + let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId) + let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket)) + if (jumpHostResult) { + targetConnectionOptions.sock = jumpHostResult.sock + jumpSshClients = jumpHostResult.sshClients + consola.success('sftp-v2 跳板机连接成功') + } + + consola.info('准备连接sftp-v2 面板:', host) + consola.log('连接信息', { username, port, authType }) + + sftpClient.client = new SSHClient() + + // 添加错误处理器,防止程序崩溃 + sftpClient.client.on('error', (err) => { + consola.error('SFTP SSH连接错误:', err.message) + try { + // 发送SSH连接错误事件 + socket.emit('shell_connection_error', { + message: `SSH连接错误: ${ err.message }`, + code: err.code || 'UNKNOWN' + }) + } catch (emitError) { + consola.error('发送错误事件失败:', emitError.message) + } + }) + + sftpClient.client.on('end', () => { + consola.info('SSH连接正常结束') + }) + + sftpClient.client.on('close', (hadError) => { + if (hadError) { + consola.warn('SSH连接异常关闭') + try { + socket.emit('shell_connection_error', { + message: 'SSH连接异常关闭', + code: 'CONNECTION_CLOSED' + }) + } catch (emitError) { + consola.error('发送连接关闭事件失败:', emitError.message) + } + } else { + consola.info('SSH连接已关闭') + } + }) + + // 添加未处理异常捕获 + sftpClient.client.on('timeout', () => { + consola.warn('SSH连接超时') + try { + socket.emit('shell_connection_error', { + message: 'SSH连接超时', + code: 'CONNECTION_TIMEOUT' + }) + } catch (emitError) { + consola.error('发送超时事件失败:', emitError.message) + } + }) + + sftpClient.client.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) { + finish([targetConnectionOptions[authType]]) + }) + + try { + await sftpClient.connect({ + tryKeyboard: true, + ...targetConnectionOptions + }) + fs.ensureDirSync(sftpCacheDir) + let rootList = [] + let isRootUser = true + let currentWorkingDir = '/' + + try { + rootList = await sftpClient.list('/') + consola.success('获取根目录成功') + } catch (error) { + consola.error('获取根目录失败:', error.message) + consola.info('尝试获取当前目录') + isRootUser = false + + try { + // 获取当前工作目录的绝对路径 + currentWorkingDir = await sftpClient.cwd() + consola.info('当前工作目录:', currentWorkingDir) + rootList = await sftpClient.list(currentWorkingDir) + consola.success('获取当前目录成功') + } catch (cwdError) { + consola.warn('获取工作目录失败,使用相对路径:', cwdError.message) + currentWorkingDir = '~' + rootList = await sftpClient.list('./') + consola.success('获取当前目录成功') + } + } + + // 普通文件-、目录文件d、链接文件l + socket.emit('connect_success', { + rootList, + isRootUser, + currentPath: currentWorkingDir + }) + consola.success('连接sftp-v2 成功:', host) + listenAction(sftpClient, socket) + } catch (error) { + consola.error('连接sftp-v2 失败:', error.message) + + // 发送详细的错误信息给前端 + let errorMessage = error.message + if (error.code) { + errorMessage += ` (错误代码: ${ error.code })` + } + if (error.errno) { + errorMessage += ` (错误号: ${ error.errno })` + } + + socket.emit('connect_fail', errorMessage) + + // 清理资源 + try { + if (sftpClient && sftpClient.sftp) { + await sftpClient.end() + } + } catch (cleanupError) { + consola.warn('清理SFTP客户端资源失败:', cleanupError.message) + } + + // 清理跳板机连接 + if (jumpSshClients && jumpSshClients.length > 0) { + jumpSshClients.forEach((client, index) => { + try { + client.end() + consola.info(`已清理跳板机连接 ${ index + 1 }`) + } catch (cleanupError) { + consola.warn(`清理跳板机连接 ${ index + 1 } 失败:`, cleanupError.message) + } + }) + } + + socket.disconnect() + } + } catch (globalError) { + consola.error('SFTP连接处理过程中发生未预期错误:', globalError.message) + try { + socket.emit('connect_fail', `连接失败: ${ globalError.message }`) + socket.disconnect() + } catch (cleanupError) { + consola.error('清理失败连接时发生错误:', cleanupError.message) + } + } + }) + + socket.on('disconnect', async () => { + try { + await sftpClient.end() + consola.info('sftp-v2 连接断开') + } catch (error) { + consola.info('sftp断开连接失败:', error.message) + } finally { + sftpClient = null + // 这里不再清理缓存目录,因为在 listenAction 中的 disconnect 处理器会处理 + jumpSshClients?.forEach(sshClient => sshClient && sshClient.end()) + jumpSshClients = null + } + }) + }) +} diff --git a/server/app/utils/db-class.js b/server/app/utils/db-class.js index b6e0b77..63d8b03 100644 --- a/server/app/utils/db-class.js +++ b/server/app/utils/db-class.js @@ -12,7 +12,8 @@ const { logDBPath, plusDBPath, aiConfigDBPath, - chatHistoryDBPath + chatHistoryDBPath, + favoriteSftpDBPath } = require('../config') module.exports.KeyDB = class KeyDB { @@ -166,3 +167,14 @@ module.exports.ChatHistoryDB = class ChatHistoryDB { return ChatHistoryDB.instance } } + +module.exports.FavoriteSftpDB = class FavoriteSftpDB { + constructor() { + if (!FavoriteSftpDB.instance) { + FavoriteSftpDB.instance = new Datastore({ filename: favoriteSftpDBPath, autoload: true }) + } + } + getInstance() { + return FavoriteSftpDB.instance + } +} diff --git a/server/app/utils/verify-auth.js b/server/app/utils/verify-auth.js index c73d16d..85688cc 100644 --- a/server/app/utils/verify-auth.js +++ b/server/app/utils/verify-auth.js @@ -12,7 +12,7 @@ const enumLoginCode = { // 校验token const verifyAuthSync = async (token, clientIp) => { - consola.info('verifyAuthSync IP:', clientIp) + // consola.info('verifyAuthSync IP:', clientIp) try { token = await AESDecryptAsync(token) // 先aes解密 const { commonKey } = await keyDB.findOneAsync({}) diff --git a/server/package.json b/server/package.json index 52df8b1..52c5021 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "3.0.7", + "version": "3.1.0", "description": "easynode-server", "bin": "./bin/www", "scripts": { diff --git a/server/version.json b/server/version.json index 9c43256..8fb1720 100644 --- a/server/version.json +++ b/server/version.json @@ -1,4 +1,17 @@ [ + { + "version": "v3.1.0", + "date": "2025-07-13", + "features": [ + "重构终端布局,支持单服务器多屏展示", + "重构SFTP面板,实现全功能交互", + "终端页支持实例分组菜单并支持一键连接分组所有实例", + "优化终端渲染,提升缓存行数至5000", + "已授权ip优化UI并支持一键设置到登录白名单", + "AI组件对话编辑功能与字体大小优化", + "其他优化与bug修复" + ] + }, { "version": "v3.0.7", "date": "2025-05-24", diff --git a/web/package.json b/web/package.json index 21c95d4..ffb4d7a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "3.0.7", + "version": "3.1.0", "description": "easynode-web", "private": true, "scripts": { @@ -40,6 +40,7 @@ "highlight.js": "^11.11.1", "jsencrypt": "^3.3.2", "markdown-it": "^14.1.0", + "monaco-editor": "^0.52.2", "pinia": "^3.0.1", "socket.io-client": "^4.8.1", "vue": "^3.5.13", diff --git a/web/src/assets/scss/mobile.scss b/web/src/assets/scss/mobile.scss index 1a85e8a..64d6516 100644 --- a/web/src/assets/scss/mobile.scss +++ b/web/src/assets/scss/mobile.scss @@ -4,7 +4,7 @@ } .mobile_menu_drawer { - width: auto !important; + width: 82% !important; .mobile_logo_wrap { display: flex; align-items: center; diff --git a/web/src/components/image-preview/index.vue b/web/src/components/image-preview/index.vue new file mode 100644 index 0000000..c11f2ec --- /dev/null +++ b/web/src/components/image-preview/index.vue @@ -0,0 +1,553 @@ + + + + + + + \ No newline at end of file diff --git a/web/src/components/text-editor/index.vue b/web/src/components/text-editor/index.vue new file mode 100644 index 0000000..fcbc3f1 --- /dev/null +++ b/web/src/components/text-editor/index.vue @@ -0,0 +1,674 @@ + + + + + diff --git a/web/src/store/index.js b/web/src/store/index.js index 2f40f47..05706c0 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -191,7 +191,7 @@ const useStore = defineStore('global', { localStorage.setItem('isDark', isDark) this.$patch({ isDark }) } - if (animate) { + if (animate && typeof document.startViewTransition === 'function') { let transition = document.startViewTransition(() => { document.documentElement.classList.toggle('dark') }) diff --git a/web/src/views/terminal/components/docker.vue b/web/src/views/terminal/components/docker.vue index 1e5c34d..bcbea47 100644 --- a/web/src/views/terminal/components/docker.vue +++ b/web/src/views/terminal/components/docker.vue @@ -205,7 +205,7 @@ + + diff --git a/web/src/views/terminal/components/terminal-tab.vue b/web/src/views/terminal/components/terminal-tab.vue index 4ac5391..d65a458 100644 --- a/web/src/views/terminal/components/terminal-tab.vue +++ b/web/src/views/terminal/components/terminal-tab.vue @@ -5,6 +5,7 @@ class="terminal_container" @contextmenu="handleRightClick" @mouseup="handleMouseUp" + @mousedown="emit('tab-focus', uid)" /> - 功能项 + 功能项