diff --git a/src/main/manage/apis/api.ts b/src/main/manage/apis/api.ts index e240f9f..efc9bb5 100644 --- a/src/main/manage/apis/api.ts +++ b/src/main/manage/apis/api.ts @@ -4,6 +4,7 @@ import ImgurApi from './imgur' import LocalApi from './local' import QiniuApi from './qiniu' import S3plistApi from './s3plist' +import SftpApi from './sftp' import SmmsApi from './smms' import TcyunApi from './tcyun' import UpyunApi from './upyun' @@ -16,6 +17,7 @@ export default { LocalApi, QiniuApi, S3plistApi, + SftpApi, SmmsApi, TcyunApi, UpyunApi, diff --git a/src/main/manage/apis/sftp.ts b/src/main/manage/apis/sftp.ts index e69de29..17896ed 100644 --- a/src/main/manage/apis/sftp.ts +++ b/src/main/manage/apis/sftp.ts @@ -0,0 +1,409 @@ +// 日志记录器 +import ManageLogger from '../utils/logger' + +// SSH 客户端 +import SSHClient from '~/main/utils/sshClient' + +// 错误格式化函数、新的下载器、并发异步任务池 +import { formatError, ConcurrencyPromisePool } from '../utils/common' + +// 是否为图片的判断函数 +import { isImage } from '@/manage/utils/common' + +// 窗口管理器 +import windowManager from 'apis/app/window/windowManager' + +// 枚举类型声明 +import { IWindowList } from '#/types/enum' + +// Electron 相关 +import { ipcMain, IpcMainEvent } from 'electron' + +// 上传下载任务队列 +import UpDownTaskQueue, { commonTaskStatus } from '../datastore/upDownTaskQueue' + +// 路径处理库 +import path from 'path' + +// 取消下载任务的加载文件列表、刷新下载文件传输列表 +import { cancelDownloadLoadingFileList, refreshDownloadFileTransferList } from '@/manage/utils/static' +import { Undefinable } from '~/universal/types/manage' + +interface listDirResult { + permissions: string + isDir: boolean + owner: string + group: string + size: number + mtime: string + filename: string + key: string +} + +class SftpApi { + host: string + port: number + username: string + password: string + privateKey: string + passphrase: string + fileMode: string + dirMode: string + logger: ManageLogger + ctx: SSHClient + config: { + host: string + port: number + username: string + password: string + privateKey: string + passphrase: string + } + + constructor ( + host: string, + port: Undefinable, + username: Undefinable, + password: Undefinable, + privateKey: Undefinable, + passphrase: Undefinable, + fileMode: Undefinable, + dirMode: Undefinable, + logger: ManageLogger + ) { + this.host = host + this.port = Number(port) || 22 + this.username = username || '' + this.password = password || '' + this.privateKey = privateKey || '' + this.passphrase = passphrase || '' + this.fileMode = fileMode || '0664' + this.dirMode = dirMode || '0775' + this.logger = logger + this.ctx = SSHClient.instance + this.config = { + host: this.host, + port: this.port, + username: this.username, + password: this.password, + privateKey: this.privateKey, + passphrase: this.passphrase + } + } + + logParam = (error:any, method: string) => + this.logger.error(formatError(error, { class: 'SftpApi', method })) + + transFormPermission = (permissionsStr: string) => { + const permissions = permissionsStr.length === 10 ? permissionsStr.slice(1) : permissionsStr + let result = '' + for (let i = 0; i < 3; i++) { + const chunk = permissions.slice(i * 3, i * 3 + 3) + let value = 0 + + if (chunk[0] === 'r') value += 4 + if (chunk[1] === 'w') value += 2 + if (chunk[2] === 'x') value += 1 + + result += value + } + + return `0${result}` + } + + formatFolder (item: listDirResult, urlPrefix: string, isWebPath = false) { + const key = item.key + let url: string + if (isWebPath) { + url = urlPrefix + } else { + if (this.username && this.password) { + url = `sfpt://${this.username}:${this.password}@${urlPrefix}${item.filename}` + } else { + url = `${urlPrefix}${item.filename}` + } + } + return { + ...item, + key, + fileName: item.filename, + fileSize: 0, + Key: key, + formatedTime: '', + isDir: true, + checked: false, + isImage: false, + match: false, + url + } + } + + formatFile (item: listDirResult, urlPrefix: string, isWebPath = false) { + const key = item.key + return { + ...item, + key, + fileName: item.filename, + fileSize: item.size, + Key: key, + formatedTime: new Date(item.mtime).toLocaleString(), + isDir: false, + checked: false, + match: false, + isImage: isImage(item.filename), + url: isWebPath ? urlPrefix : `${urlPrefix}${item.filename}` + } + } + + isRequestSuccess = (code: number | null) => code === 0 + + connectClient = async () => { + try { + await this.ctx.connect(this.config) + if (!this.ctx.isConnected) { + throw new Error('SSH 未连接') + } + } catch (error) { + this.logParam(error, 'connectClient') + } + } + + async getBucketListRecursively (configMap: IStringKeyMap): Promise { + const window = windowManager.get(IWindowList.SETTING_WINDOW)! + const { prefix, customUrl, cancelToken } = configMap + const urlPrefix = customUrl || `${this.host}:${this.port}` + const cancelTask = [false] + ipcMain.on(cancelDownloadLoadingFileList, (_evt: IpcMainEvent, token: string) => { + if (token === cancelToken) { + cancelTask[0] = true + ipcMain.removeAllListeners(cancelDownloadLoadingFileList) + } + }) + let res = {} as any + const result = { + fullList: [], + success: false, + finished: false + } + try { + await this.connectClient() + res = await this.ctx.execCommand(`cd ${prefix} && ls -la --time-style=long-iso`) + this.ctx.close() + if (this.isRequestSuccess(res.code)) { + const formatedLSRes = this.formatLSResult(res.stdout, prefix) + if (formatedLSRes.length) { + formatedLSRes.forEach((item: listDirResult) => { + if (!item.isDir) { + result.fullList.push(this.formatFile(item, urlPrefix)) + } + }) + } + result.success = true + } + } catch (error) { + this.logParam(error, 'getBucketListRecursively') + } + result.finished = true + window.webContents.send(refreshDownloadFileTransferList, result) + ipcMain.removeAllListeners(cancelDownloadLoadingFileList) + } + + formatLSResult (res: string, cwd: string): listDirResult[] { + const result = [] as listDirResult[] + const resArray = res.trim().split('\n') + resArray.slice(resArray[0].startsWith('total') ? 1 : 0).forEach((item: string) => { + const [permissions, , owner, group, size, date, time, ...name] = item.trim().split(/\s+/) + const filename = name.join(' ') + if (filename === '.' || filename === '..') { + return + } + const isDir = permissions.startsWith('d') + const mtime = `${date} ${time}` + const key = path.join(cwd, filename).replace(/\\/g, '/').replace(/^\/+/, '') + result.push({ + permissions, + isDir, + owner, + group, + size: Number(size) || 0, + mtime, + filename, + key + }) + }) + return result + } + + async getBucketListBackstage (configMap: IStringKeyMap): Promise { + const window = windowManager.get(IWindowList.SETTING_WINDOW)! + const { prefix, customUrl, cancelToken, baseDir } = configMap + let urlPrefix = customUrl || `${this.host}:${this.port}` + urlPrefix = urlPrefix.replace(/\/+$/, '') + let webPath = configMap.webPath || '' + if (webPath && customUrl && webPath !== '/') { + webPath = webPath.replace(/^\/+|\/+$/, '') + } + const cancelTask = [false] + ipcMain.on('cancelLoadingFileList', (_evt: IpcMainEvent, token: string) => { + if (token === cancelToken) { + cancelTask[0] = true + ipcMain.removeAllListeners('cancelLoadingFileList') + } + }) + let res = {} as any + const result = { + fullList: [], + success: false, + finished: false + } + try { + await this.connectClient() + res = await this.ctx.execCommand(`cd ${prefix} && ls -la --time-style=long-iso`) + this.ctx.close() + if (this.isRequestSuccess(res.code)) { + const formatedLSRes = this.formatLSResult(res.stdout, prefix) + console.log(formatedLSRes) + if (formatedLSRes.length) { + formatedLSRes.forEach((item: listDirResult) => { + const relativePath = path.relative(baseDir, item.key) + const relative = webPath && urlPrefix + `/${path.join(webPath, relativePath)}`.replace(/\\/g, '/').replace(/\/+/g, '/') + if (item.isDir) { + result.fullList.push(this.formatFolder(item, webPath ? relative : urlPrefix, !!webPath)) + } else { + result.fullList.push(this.formatFile(item, webPath ? relative : urlPrefix, !!webPath)) + } + }) + } + } else { + result.finished = true + window.webContents.send('refreshFileTransferList', result) + ipcMain.removeAllListeners('cancelLoadingFileList') + return + } + } catch (error) { + this.logParam(error, 'getBucketListBackstage') + result.finished = true + window.webContents.send('refreshFileTransferList', result) + ipcMain.removeAllListeners('cancelLoadingFileList') + return + } + result.success = true + result.finished = true + window.webContents.send('refreshFileTransferList', result) + ipcMain.removeAllListeners('cancelLoadingFileList') + } + + async renameBucketFile (configMap: IStringKeyMap): Promise { + const { oldKey, newKey } = configMap + let result = false + try { + await this.connectClient() + const res = await this.ctx.execCommand(`mv -f ${oldKey} ${newKey}`) + this.ctx.close() + result = this.isRequestSuccess(res.code) + } catch (error) { + this.logParam(error, 'renameBucketFile') + } + return result + } + + async deleteBucketFile (configMap: IStringKeyMap): Promise { + const { key } = configMap + let result = false + try { + await this.connectClient() + const res = await this.ctx.execCommand(`rm -f ${key}`) + this.ctx.close() + result = this.isRequestSuccess(res.code) + } catch (error) { + this.logParam(error, 'deleteBucketFile') + } + return result + } + + async deleteBucketFolder (configMap: IStringKeyMap): Promise { + const { key } = configMap + let result = false + try { + await this.connectClient() + const res = await this.ctx.execCommand(`rm -rf ${key}`) + this.ctx.close() + result = this.isRequestSuccess(res.code) + } catch (error) { + this.logParam(error, 'deleteBucketFolder') + } + return result + } + + async uploadBucketFile (configMap: IStringKeyMap): Promise { + const { fileArray } = configMap + const instance = UpDownTaskQueue.getInstance() + for (const item of fileArray) { + const { alias, bucketName, region, key, filePath, fileName } = item + const id = `${alias}-${bucketName}-${key}-${filePath}` + if (instance.getUploadTask(id)) { + continue + } + instance.addUploadTask({ + id, + progress: 0, + status: commonTaskStatus.queuing, + sourceFileName: fileName, + sourceFilePath: filePath, + targetFilePath: key, + targetFileBucket: bucketName, + targetFileRegion: region, + noProgress: true + }) + instance.updateUploadTask({ + id, + progress: 0, + status: commonTaskStatus.failed, + finishTime: new Date().toLocaleString() + }) + } + return true + } + + async createBucketFolder (configMap: IStringKeyMap): Promise { + const { key } = configMap + let result = false + try { + await this.connectClient() + const res = await this.ctx.execCommand(`mkdir -p ${key}`) + this.ctx.close() + result = this.isRequestSuccess(res.code) + } catch (error) { + this.logParam(error, 'createBucketFolder') + } + return result + } + + async downloadBucketFile (configMap: IStringKeyMap): Promise { + const { downloadPath, fileArray, maxDownloadFileCount } = configMap + const instance = UpDownTaskQueue.getInstance() + const promises = [] as any + for (const item of fileArray) { + const { alias, bucketName, region, key, fileName } = item + const savedFilePath = path.join(downloadPath, fileName) + const id = `${alias}-${bucketName}-${region}-${key}` + if (instance.getDownloadTask(id)) { + continue + } + instance.addDownloadTask({ + id, + progress: 0, + status: commonTaskStatus.queuing, + sourceFileName: fileName, + targetFilePath: savedFilePath + }) + } + const pool = new ConcurrencyPromisePool(maxDownloadFileCount) + pool.all(promises).catch((error) => { + this.logParam(error, 'downloadBucketFile') + }) + return true + } +} + +export default SftpApi diff --git a/src/main/manage/manageApi.ts b/src/main/manage/manageApi.ts index aec68ac..bcacf38 100644 --- a/src/main/manage/manageApi.ts +++ b/src/main/manage/manageApi.ts @@ -68,6 +68,8 @@ export class ManageApi extends EventEmitter implements ManageApiType { return new API.SmmsApi(this.currentPicBedConfig.token, this.logger) case 's3plist': return new API.S3plistApi(this.currentPicBedConfig.accessKeyId, this.currentPicBedConfig.secretAccessKey, this.currentPicBedConfig.endpoint, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.s3ForcePathStyle, this.currentPicBedConfig.proxy, this.logger) + case 'sftp': + return new API.SftpApi(this.currentPicBedConfig.host, this.currentPicBedConfig.port, this.currentPicBedConfig.username, this.currentPicBedConfig.password, this.currentPicBedConfig.privateKey, this.currentPicBedConfig.passphrase, this.currentPicBedConfig.fileMode, this.currentPicBedConfig.dirMode, this.logger) case 'tcyun': return new API.TcyunApi(this.currentPicBedConfig.secretId, this.currentPicBedConfig.secretKey, this.logger) case 'upyun': @@ -174,6 +176,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 'smms': case 'webdavplist': case 'local': + case 'sftp': return [{ Name: name, Location: name, @@ -313,6 +316,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any return await client.getBucketListRecursively(param!) @@ -357,6 +361,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any return await client.getBucketListBackstage(param!) @@ -428,6 +433,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any const res = await client.deleteBucketFile(param!) @@ -454,6 +460,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any return await client.deleteBucketFolder(param!) @@ -478,6 +485,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any return await client.renameBucketFile(param!) @@ -505,6 +513,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any const res = await client.downloadBucketFile(param!) @@ -538,6 +547,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any return await client.createBucketFolder(param!) @@ -565,6 +575,7 @@ export class ManageApi extends EventEmitter implements ManageApiType { case 's3plist': case 'webdavplist': case 'local': + case 'sftp': try { client = this.createClient() as any return await client.uploadBucketFile(param!) diff --git a/src/main/utils/sshClient.ts b/src/main/utils/sshClient.ts index 7f4a1b5..e1dad08 100644 --- a/src/main/utils/sshClient.ts +++ b/src/main/utils/sshClient.ts @@ -1,4 +1,4 @@ -import { NodeSSH, Config } from 'node-ssh-no-cpu-features' +import { NodeSSH, Config, SSHExecCommandResponse } from 'node-ssh-no-cpu-features' import { ISftpPlistConfig } from 'piclist/dist/types' class SSHClient { @@ -55,6 +55,31 @@ class SSHClient { return execResult.code === 0 } + async execCommand (script: string): Promise { + const execResult = await SSHClient.client.execCommand(script) + return execResult || { code: 1, stdout: '', stderr: '' } + } + + async getFile (local: string, remote: string): Promise { + if (!this._isConnected) { + throw new Error('SSH 未连接') + } + try { + remote = this.changeWinStylePathToUnix(remote) + local = this.changeWinStylePathToUnix(local) + console.log(`remote: ${remote}, local: ${local}`) + await SSHClient.client.getFile(local, remote) + return true + } catch (err: any) { + console.log(err) + return false + } + } + + get isConnected (): boolean { + return SSHClient.client.isConnected() + } + public close (): void { SSHClient.client.dispose() this._isConnected = false diff --git a/src/renderer/manage/pages/bucketPage.vue b/src/renderer/manage/pages/bucketPage.vue index c310052..8eb2685 100644 --- a/src/renderer/manage/pages/bucketPage.vue +++ b/src/renderer/manage/pages/bucketPage.vue @@ -497,7 +497,7 @@ https://www.baidu.com/img/bd_logo1.png" shadow="hover" > + (-1) const customDomainList = ref([] as any[]) const currentCustomDomain = ref('') const isShowCustomDomainSelectList = computed(() => ['tcyun', 'aliyun', 'qiniu', 'github'].includes(currentPicBedName.value)) -const isShowCustomDomainInput = computed(() => ['aliyun', 'qiniu', 'tcyun', 's3plist', 'webdavplist', 'local'].includes(currentPicBedName.value)) +const isShowCustomDomainInput = computed(() => ['aliyun', 'qiniu', 'tcyun', 's3plist', 'webdavplist', 'local', 'sftp'].includes(currentPicBedName.value)) const isAutoCustomDomain = computed(() => manageStore.config.picBed[configMap.alias].isAutoCustomUrl === undefined ? true : manageStore.config.picBed[configMap.alias].isAutoCustomUrl) // 文件预览相关 const isShowMarkDownDialog = ref(false) @@ -1589,7 +1596,7 @@ const isShowVideoFileDialog = ref(false) const videoFileUrl = ref('') const videoPlayerHeaders = ref({}) // 重命名相关 -const isShowRenameFileIcon = computed(() => ['tcyun', 'aliyun', 'qiniu', 'upyun', 's3plist', 'webdavplist', 'local'].includes(currentPicBedName.value)) +const isShowRenameFileIcon = computed(() => ['tcyun', 'aliyun', 'qiniu', 'upyun', 's3plist', 'webdavplist', 'local', 'sftp'].includes(currentPicBedName.value)) const isShowBatchRenameDialog = ref(false) const batchRenameMatch = ref('') const batchRenameReplace = ref('') @@ -1607,9 +1614,9 @@ const isAutoRefresh = computed(() => manageStore.config.settings.isAutoRefresh ? const isIgnoreCase = computed(() => manageStore.config.settings.isIgnoreCase ?? false) // 新建文件夹相关 -const isShowCreateNewFolder = computed(() => ['aliyun', 'github', 'local', 'qiniu', 'tcyun', 's3plist', 'upyun', 'webdavplist'].includes(currentPicBedName.value)) +const isShowCreateNewFolder = computed(() => ['aliyun', 'github', 'local', 'qiniu', 'tcyun', 's3plist', 'upyun', 'webdavplist', 'sftp'].includes(currentPicBedName.value)) -const isShowPresignedUrl = computed(() => ['aliyun', 'github', 'qiniu', 's3plist', 'tcyun', 'webdavplist'].includes(currentPicBedName.value)) +const isShowPresignedUrl = computed(() => ['aliyun', 'github', 'qiniu', 's3plist', 'tcyun', 'webdavplist', 'sftp'].includes(currentPicBedName.value)) // 上传相关函数 @@ -2000,7 +2007,7 @@ async function handleChangeCustomUrl () { isShowLoadingPage.value = true await resetParam(true) isShowLoadingPage.value = false - } else if (['aliyun', 'tcyun', 'qiniu', 's3plist', 'webdavplist'].includes(currentPicBedName.value)) { + } else if (['aliyun', 'tcyun', 'qiniu', 's3plist', 'webdavplist', 'local', 'sftp'].includes(currentPicBedName.value)) { const currentConfigs = await getConfig('picBed') const currentConfig = currentConfigs[configMap.alias] const currentTransformedConfig = JSON.parse(currentConfig.transformedConfig ?? '{}') @@ -2105,7 +2112,7 @@ async function initCustomDomainList () { currentCustomDomain.value = endpoint } handleChangeCustomUrl() - } else if (currentPicBedName.value === 'local') { + } else if (currentPicBedName.value === 'local' || currentPicBedName.value === 'sftp') { const currentConfigs = await getConfig('picBed') const currentConfig = currentConfigs[configMap.alias] const currentTransformedConfig = JSON.parse(currentConfig.transformedConfig ?? '{}') @@ -2799,7 +2806,8 @@ async function getBucketFileListBackStage () { const fileTransferStore = useFileTransferStore() fileTransferStore.resetFileTransferList() if (currentPicBedName.value === 'webdavplist' || - currentPicBedName.value === 'local') { + currentPicBedName.value === 'local' || + currentPicBedName.value === 'sftp') { param.baseDir = configMap.baseDir param.webPath = configMap.webPath } diff --git a/src/renderer/manage/pages/logIn.vue b/src/renderer/manage/pages/logIn.vue index 8e1e9ce..2ee4d8d 100644 --- a/src/renderer/manage/pages/logIn.vue +++ b/src/renderer/manage/pages/logIn.vue @@ -730,7 +730,14 @@ async function transUpToManage (config: IUploaderConfigListItem, picBedName: str ...commonConfig, baseDir: config.path, webPath: config.webpath || '', - customUrl: config.customUrl || '' + customUrl: config.customUrl || '', + transformedConfig: JSON.stringify({ + local: { + customUrl: config.customUrl || '', + baseDir: config.path, + webPath: config.webpath || '' + } + }) }) delete resultMap.paging break @@ -749,7 +756,22 @@ async function transUpToManage (config: IUploaderConfigListItem, picBedName: str webPath: config.webPath || '', customUrl: config.customUrl || '', fileMode: config.fileMode || '0664', - dirMode: config.dirMode || '0775' + dirMode: config.dirMode || '0775', + transformedConfig: JSON.stringify({ + sftp: { + host: config.host, + port: config.port || 22, + username: config.username, + password: config.password, + privateKey: config.privateKey, + passphrase: config.passphrase, + baseDir: config.uploadPath || '/', + webPath: config.webPath || '', + customUrl: config.customUrl || '', + fileMode: config.fileMode || '0664', + dirMode: config.dirMode || '0775' + } + }) }) delete resultMap.paging break diff --git a/src/renderer/manage/store/bucketFileDb.ts b/src/renderer/manage/store/bucketFileDb.ts index e0aa277..64fb24a 100644 --- a/src/renderer/manage/store/bucketFileDb.ts +++ b/src/renderer/manage/store/bucketFileDb.ts @@ -27,19 +27,19 @@ export class FileCacheDb extends Dexie { qiniu: Table smms: Table s3plist: Table - sftpplist: Table + sftp: Table upyun: Table webdavplist: Table constructor () { super('bucketFileDb') - const tableNames = ['aliyun', 'github', 'imgur', 'local', 'qiniu', 's3plist', 'sftpplist', 'smms', 'tcyun', 'upyun', 'webdavplist'] + const tableNames = ['aliyun', 'github', 'imgur', 'local', 'qiniu', 's3plist', 'sftp', 'smms', 'tcyun', 'upyun', 'webdavplist'] const tableNamesMap = tableNames.reduce((acc, cur) => { acc[cur] = '&key, value' return acc }, {} as IStringKeyMap) - this.version(4).stores(tableNamesMap) + this.version(5).stores(tableNamesMap) this.aliyun = this.table('aliyun') this.github = this.table('github') this.imgur = this.table('imgur') @@ -47,7 +47,7 @@ export class FileCacheDb extends Dexie { this.qiniu = this.table('qiniu') this.tcyun = this.table('tcyun') this.s3plist = this.table('s3plist') - this.sftpplist = this.table('sftpplist') + this.sftp = this.table('sftp') this.smms = this.table('smms') this.upyun = this.table('upyun') this.webdavplist = this.table('webdavplist')