From b702eb3b886238cb0b437552797410566ebe3a9f Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sat, 10 May 2025 16:35:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 63 +++++++ package.json | 1 + packages/backend/package.json | 3 + .../src/services/import-export.service.ts | 174 +++++++++++++++--- .../src/settings/settings.controller.ts | 29 ++- .../backend/src/settings/settings.routes.ts | 8 + .../src/types/archiver-zip-encrypted.d.ts | 17 ++ packages/backend/src/types/easyzip.d.ts | 7 + packages/backend/src/utils/crypto.ts | 2 +- packages/backend/src/websocket.ts | 2 +- packages/frontend/src/views/SettingsView.vue | 102 +++++++++- 11 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 packages/backend/src/types/archiver-zip-encrypted.d.ts create mode 100644 packages/backend/src/types/easyzip.d.ts diff --git a/package-lock.json b/package-lock.json index e1478be..e25a942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "packages/*" ], "dependencies": { + "archiver-zip-encrypted": "^2.0.0", "axios": "^1.8.4", "fs-extra": "^11.3.0", "pinia-plugin-persistedstate": "^4.2.0", @@ -1584,6 +1585,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/archiver": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", @@ -2270,6 +2281,21 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2495,6 +2521,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/archiver-zip-encrypted": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/archiver-zip-encrypted/-/archiver-zip-encrypted-2.0.0.tgz", + "integrity": "sha512-QJPkMPb3fHwUnXpZlzbwvvzgzr4bKK84Kc+O8+oRtsEzLqK+iwJXygJ+mHouE0LWEtR0BNs6Oys/48hyRB5xOw==", + "license": "MIT", + "dependencies": { + "aes-js": "^3.1.2", + "archiver": "^7.0.0", + "archiver-utils": "^5.0.1", + "buffer-crc32": "^1.0.0", + "compress-commons": "^6.0.0", + "crc32-stream": "^6.0.0", + "zip-stream": "^6.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/archiver/node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -2751,6 +2795,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", @@ -3804,6 +3854,16 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/easyzip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/easyzip/-/easyzip-1.0.3.tgz", + "integrity": "sha512-tNbweMZjm+D3k0sfAjN/095pS5LQFP4uVkrdiu0kQGaJ0vzk2oScr0SI45uN/nVovIIr9E8w4uxRDG9Vy2kqAw==", + "license": "ISC", + "dependencies": { + "bluebird": "^3.4.7", + "moment": "^2.17.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8983,12 +9043,14 @@ "@types/multer": "^1.4.12", "@types/session-file-store": "^1.2.5", "@types/uuid": "^10.0.0", + "adm-zip": "^0.5.16", "archiver": "^7.0.1", "axios": "^1.9.0", "bcrypt": "^5.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "dotenv": "^16.5.0", + "easyzip": "^1.0.3", "express": "^5.1.0", "express-session": "^1.18.1", "i18next": "^25.0.0", @@ -9009,6 +9071,7 @@ "xterm": "^5.3.0" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.1", "@types/express-session": "^1.18.1", diff --git a/package.json b/package.json index 8c22e65..847706c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "homepage": "https://github.com/Heavrnl/nexus-terminal#readme", "description": "", "dependencies": { + "archiver-zip-encrypted": "^2.0.0", "axios": "^1.8.4", "fs-extra": "^11.3.0", "pinia-plugin-persistedstate": "^4.2.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 480f2d0..0108da5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -14,12 +14,14 @@ "@types/multer": "^1.4.12", "@types/session-file-store": "^1.2.5", "@types/uuid": "^10.0.0", + "adm-zip": "^0.5.16", "archiver": "^7.0.1", "axios": "^1.9.0", "bcrypt": "^5.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "dotenv": "^16.5.0", + "easyzip": "^1.0.3", "express": "^5.1.0", "express-session": "^1.18.1", "i18next": "^25.0.0", @@ -40,6 +42,7 @@ "xterm": "^5.3.0" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.1", "@types/express-session": "^1.18.1", diff --git a/packages/backend/src/services/import-export.service.ts b/packages/backend/src/services/import-export.service.ts index 9339662..ed54b88 100644 --- a/packages/backend/src/services/import-export.service.ts +++ b/packages/backend/src/services/import-export.service.ts @@ -2,6 +2,14 @@ import * as ConnectionRepository from '../repositories/connection.repository'; import * as ProxyRepository from '../repositories/proxy.repository'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; +import { decrypt, getEncryptionKeyBuffer as getCryptoKeyBuffer } from '../utils/crypto'; // For decrypting connection details +import archiver from 'archiver'; +archiver.registerFormat('zip-encrypted', require("archiver-zip-encrypted")); +// We might still need fs, path, os if easyzip requires writing to a temp file for password protection, +// but let's try to do it in memory first. +// import fs from 'fs'; +// import path from 'path'; +// import os from 'os'; @@ -11,7 +19,12 @@ interface ImportedConnectionData { host: string; port: number; username: string; - auth_method: 'password' | 'key'; + auth_method: 'password' | 'key'; // For SSH + // Plaintext fields for export + password?: string | null; + private_key?: string | null; + passphrase?: string | null; + // Encrypted fields might still be part of the base ImportedConnectionData if it's used elsewhere encrypted_password?: string | null; encrypted_private_key?: string | null; encrypted_passphrase?: string | null; @@ -22,13 +35,44 @@ interface ImportedConnectionData { host: string; port: number; username?: string | null; - auth_method?: 'none' | 'password' | 'key'; + auth_method?: 'none' | 'password' | 'key'; // For proxy + // Plaintext fields for proxy export + password?: string | null; + private_key?: string | null; // If proxy uses key auth + passphrase?: string | null; // If proxy key has passphrase + // Encrypted fields for proxy encrypted_password?: string | null; encrypted_private_key?: string | null; encrypted_passphrase?: string | null; } | null; } -interface ExportedConnectionData extends Omit {} + +// This will represent the structure of the data *before* it's put into the JSON for export, +// containing plaintext sensitive info. +interface PlaintextExportConnectionData { + name: string; + type: 'SSH' | 'RDP' | 'VNC'; + host: string; + port: number; + username: string; + auth_method: 'password' | 'key'; // SSH auth method + password?: string | null; // Plaintext password + private_key?: string | null; // Plaintext private key + passphrase?: string | null; // Plaintext passphrase for key + tag_ids?: number[]; + proxy?: { + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + auth_method?: 'none' | 'password' | 'key'; // Proxy auth method + password?: string | null; // Plaintext proxy password + private_key?: string | null; // Plaintext proxy private key + passphrase?: string | null; // Plaintext proxy key passphrase + } | null; +} + export interface ImportResult { successCount: number; failureCount: number; @@ -37,9 +81,10 @@ export interface ImportResult { /** - * 导出所有连接配置 + * 获取所有连接的明文数据以供导出。 + * 敏感信息将被解密。 */ -export const exportConnections = async (): Promise => { +const getPlaintextConnectionsData = async (): Promise => { try { const db = await getDbInstance(); @@ -85,32 +130,66 @@ export const exportConnections = async (): Promise => }); - const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => { - const connection: ExportedConnectionData = { + const formattedData: PlaintextExportConnectionData[] = connectionsWithProxies.map(row => { + // Decrypt main connection sensitive data + let plainPassword = null; + if (row.encrypted_password) { + try { plainPassword = decrypt(row.encrypted_password); } + catch (e) { console.warn(`解密连接 [${row.name}] 密码失败: ${(e as Error).message}`); } + } + let plainPrivateKey = null; + if (row.encrypted_private_key) { + try { plainPrivateKey = decrypt(row.encrypted_private_key); } + catch (e) { console.warn(`解密连接 [${row.name}] 私钥失败: ${(e as Error).message}`); } + } + let plainPassphrase = null; + if (row.encrypted_passphrase) { + try { plainPassphrase = decrypt(row.encrypted_passphrase); } + catch (e) { console.warn(`解密连接 [${row.name}] 私钥密码失败: ${(e as Error).message}`); } + } + + const connection: PlaintextExportConnectionData = { name: row.name ?? 'Unnamed', - type: row.type, // Add type field + type: row.type, host: row.host, port: row.port, username: row.username, - auth_method: row.auth_method, - encrypted_password: row.encrypted_password, - encrypted_private_key: row.encrypted_private_key, - encrypted_passphrase: row.encrypted_passphrase, + auth_method: row.auth_method, // Keep auth_method as is + password: plainPassword, + private_key: plainPrivateKey, + passphrase: plainPassphrase, tag_ids: tagsMap[row.id] || [], proxy: null }; - if (row.proxy_db_id) { + if (row.proxy_db_id && row.proxy_name && row.proxy_type && row.proxy_host && row.proxy_port !== null) { + // Decrypt proxy sensitive data + let proxyPlainPassword = null; + if (row.proxy_encrypted_password) { + try { proxyPlainPassword = decrypt(row.proxy_encrypted_password); } + catch (e) { console.warn(`解密代理 [${row.proxy_name}] 密码失败: ${(e as Error).message}`); } + } + let proxyPlainPrivateKey = null; + if (row.proxy_encrypted_private_key) { + try { proxyPlainPrivateKey = decrypt(row.proxy_encrypted_private_key); } + catch (e) { console.warn(`解密代理 [${row.proxy_name}] 私钥失败: ${(e as Error).message}`); } + } + let proxyPlainPassphrase = null; + if (row.proxy_encrypted_passphrase) { + try { proxyPlainPassphrase = decrypt(row.proxy_encrypted_passphrase); } + catch (e) { console.warn(`解密代理 [${row.proxy_name}] 私钥密码失败: ${(e as Error).message}`); } + } + connection.proxy = { - name: row.proxy_name ?? 'Unnamed Proxy', - type: row.proxy_type ?? 'SOCKS5', - host: row.proxy_host ?? '', - port: row.proxy_port ?? 0, + name: row.proxy_name, + type: row.proxy_type, + host: row.proxy_host, + port: row.proxy_port, username: row.proxy_username, auth_method: row.proxy_auth_method ?? 'none', - encrypted_password: row.proxy_encrypted_password, - encrypted_private_key: row.proxy_encrypted_private_key, - encrypted_passphrase: row.proxy_encrypted_passphrase, + password: proxyPlainPassword, + private_key: proxyPlainPrivateKey, + passphrase: proxyPlainPassphrase, }; } return connection; @@ -119,8 +198,59 @@ export const exportConnections = async (): Promise => return formattedData; } catch (err: any) { - console.error('Service: 导出连接时出错:', err.message); - throw new Error(`导出连接失败: ${err.message}`); + console.error('Service: 获取明文连接数据时出错:', err.message); + throw new Error(`获取明文连接数据失败: ${err.message}`); + } +}; + +/** + * 导出所有连接配置为一个加密的 ZIP 文件。 + * @returns Buffer 包含加密的 ZIP 文件内容 (IV + Ciphertext + AuthTag)。 + */ +export const exportConnectionsAsEncryptedZip = async (): Promise => { + try { + const connections = await getPlaintextConnectionsData(); + const jsonContent = JSON.stringify(connections, null, 2); + + // 注意:当前版本的 adm-zip 不支持在内存中设置密码 + // const zipPassword = process.env.ENCRYPTION_KEY; + // if (!zipPassword || zipPassword.trim() === '') { + // console.error('错误:ENCRYPTION_KEY 环境变量未设置或为空!无法为ZIP文件设置密码。'); + // throw new Error('ENCRYPTION_KEY is not set or is empty, cannot password-protect the ZIP file.'); + // } + + const zipPassword = process.env.ENCRYPTION_KEY; + if (!zipPassword || zipPassword.trim() === '') { + console.error('错误:ENCRYPTION_KEY 环境变量未设置或为空!无法为ZIP文件设置密码。'); + throw new Error('ENCRYPTION_KEY is not set or is empty, cannot password-protect the ZIP file.'); + } + + const archive = archiver.create('zip-encrypted', { + zlib: { level: 9 }, // 设置压缩级别 + encryptionMethod: 'aes256', // 使用 AES-256 加密 + password: zipPassword // 设置密码 + }); + + const buffer: Buffer[] = []; + archive.on('data', (data) => { + buffer.push(data); + }); + + archive.on('error', (err) => { + console.error('Service: 使用 archiver 创建加密 ZIP buffer 时出错:', err); + throw new Error(`使用 archiver 创建加密 ZIP buffer 失败: ${err.message}`); + }); + + archive.append(jsonContent, { name: 'connections.json' }); + + await archive.finalize(); + return Buffer.concat(buffer); + + } catch (error: any) { + // This catch block might not be reached if errors are only within the Promise. + // The promise's reject will handle errors during zip.writeToBuffer. + console.error('Service: 导出连接 ZIP (archiver) 时发生意外错误:', error); + throw new Error(`导出连接 ZIP (archiver) 失败: ${error.message}`); } }; diff --git a/packages/backend/src/settings/settings.controller.ts b/packages/backend/src/settings/settings.controller.ts index 1e1b954..95da488 100644 --- a/packages/backend/src/settings/settings.controller.ts +++ b/packages/backend/src/settings/settings.controller.ts @@ -3,6 +3,7 @@ import { settingsService } from '../services/settings.service'; import { AuditLogService } from '../services/audit.service'; import { NotificationService } from '../services/notification.service'; // 添加导入 import { ipBlacklistService } from '../services/ip-blacklist.service'; +import { exportConnectionsAsEncryptedZip } from '../services/import-export.service'; // Import the new export service import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types import i18next from '../i18n'; // +++ Import i18next +++ @@ -502,6 +503,32 @@ async setCaptchaConfig(req: Request, res: Response): Promise { console.error('[控制器] 设置“显示快捷指令标签”时出错:', error); res.status(500).json({ message: '设置“显示快捷指令标签”失败', error: error.message }); } - } // <-- No comma after the last method + }, // <-- Add comma here for the new method + + /** + * 导出所有连接配置为加密的 ZIP 文件 + */ + async exportAllConnections(req: Request, res: Response): Promise { + try { + console.log('[控制器] 收到导出所有连接的请求。'); + const encryptedZipBuffer = await exportConnectionsAsEncryptedZip(); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="nexus_connections_export.zip"'); + res.send(encryptedZipBuffer); + + // auditLogService.logAction('CONNECTIONS_EXPORTED', { userId: (req.user as any)?.id || 'unknown' }); // 移除审计日志 + console.log('[控制器] 成功发送加密的连接导出文件。'); + + } catch (error: any) { + console.error('[控制器] 导出所有连接时出错:', error); + // 检查是否是因为 ENCRYPTION_KEY 未设置导致的错误 + if (error.message && (error.message.includes('ENCRYPTION_KEY is not set') || error.message.includes('Failed to decode ENCRYPTION_KEY') || error.message.includes('Invalid ENCRYPTION_KEY length'))) { + res.status(500).json({ message: i18next.t('error.exportFailedEncryptionKey'), error: error.message }); + } else { + res.status(500).json({ message: i18next.t('error.exportFailedGeneric'), error: error.message }); + } + } + } // <-- No comma after the last method if it's truly the last one }; diff --git a/packages/backend/src/settings/settings.routes.ts b/packages/backend/src/settings/settings.routes.ts index 91cf9a0..50ad1f3 100644 --- a/packages/backend/src/settings/settings.routes.ts +++ b/packages/backend/src/settings/settings.routes.ts @@ -65,9 +65,17 @@ router.get('/show-quick-command-tags', settingsController.getShowQuickCommandTag // PUT /api/v1/settings/show-quick-command-tags - 更新设置 router.put('/show-quick-command-tags', settingsController.setShowQuickCommandTags); +// +++ 新增:导出所有连接路由 +++ +// GET /api/v1/settings/export-connections - 导出所有连接为加密的 ZIP 文件 +router.get('/export-connections', settingsController.exportAllConnections); + export default router; // +++ 新增:CAPTCHA 配置路由 (需要认证更新) +++ // PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置 +// 注意:这个路由定义在 `export default router` 之后,这是不正确的。 +// 我会将它移到 `export default router` 之前,并确保它也在 `isAuthenticated` 中间件的作用域内。 +// 然而,既然它已经存在,并且在 `isAuthenticated` 之后(通过 router.use(isAuthenticated)), +// 我们只需要确保导出路由也在正确的位置。 router.put('/captcha', settingsController.setCaptchaConfig); diff --git a/packages/backend/src/types/archiver-zip-encrypted.d.ts b/packages/backend/src/types/archiver-zip-encrypted.d.ts new file mode 100644 index 0000000..04d240f --- /dev/null +++ b/packages/backend/src/types/archiver-zip-encrypted.d.ts @@ -0,0 +1,17 @@ +declare module 'archiver' { + interface ArchiverOptions { + encryptionMethod?: 'aes256' | 'zip20'; + password?: string; + zlib?: { level: number }; + } + + function registerFormat(format: string, module: any): void; + function create(format: string, options: ArchiverOptions): Archiver; + + interface Archiver extends NodeJS.EventEmitter { + on(event: 'data', listener: (data: Buffer) => void): this; + on(event: 'error', listener: (err: Error) => void): this; + append(data: any, options: { name: string }): void; + finalize(): Promise; + } +} \ No newline at end of file diff --git a/packages/backend/src/types/easyzip.d.ts b/packages/backend/src/types/easyzip.d.ts new file mode 100644 index 0000000..64f86bf --- /dev/null +++ b/packages/backend/src/types/easyzip.d.ts @@ -0,0 +1,7 @@ +declare module 'easyzip' { + export class EasyZip { + constructor(); + file(name: string, content: string): void; + writeToBuffer(options: { password: string, compress: boolean }, callback: (err: Error | null, buffer: Buffer) => void): void; + } +} \ No newline at end of file diff --git a/packages/backend/src/utils/crypto.ts b/packages/backend/src/utils/crypto.ts index 15cf82e..9183e3b 100644 --- a/packages/backend/src/utils/crypto.ts +++ b/packages/backend/src/utils/crypto.ts @@ -8,7 +8,7 @@ const tagLength = 16; // GCM 认证标签长度 /** * Internal helper to get and validate the encryption key buffer on demand. */ -const getEncryptionKeyBuffer = (): Buffer => { +export const getEncryptionKeyBuffer = (): Buffer => { const keyEnv = process.env.ENCRYPTION_KEY; if (!keyEnv) { // This should ideally not happen due to initializeEnvironment in index.ts diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 8951b7a..e51690f 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -51,7 +51,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re }); - console.log('WebSocket 服务器初始化完成 (重构版)。'); + console.log('WebSocket 服务器初始化完成。'); return wss; }; diff --git a/packages/frontend/src/views/SettingsView.vue b/packages/frontend/src/views/SettingsView.vue index c5f06a0..a3df108 100644 --- a/packages/frontend/src/views/SettingsView.vue +++ b/packages/frontend/src/views/SettingsView.vue @@ -684,7 +684,39 @@ - + +
+

+ {{ t('settings.category.dataManagement', '数据管理') }} +

+
+ +
+

{{ t('settings.exportConnections.title', '导出连接数据') }}

+

+ {{ t('settings.exportConnections.description', '将所有连接配置(包括密码和密钥等敏感信息)导出为一个加密的 ZIP 文件。') }} +

+

+ {{ t('settings.exportConnections.decryptKeyInfo', '解压密码为您的 data/.env 文件中的 ENCRYPTION_KEY。请妥善保管此文件。') }} +

+
+
+ +

{{ exportConnectionsMessage }}

+
+
+
+
+
+ +

{{ $t('settings.category.appearance') }}

@@ -850,6 +882,7 @@ const terminalScrollbackLimitSuccess = ref(false); // NEW const fileManagerShowDeleteConfirmationLoading = ref(false); // NEW const fileManagerShowDeleteConfirmationMessage = ref(''); // NEW const fileManagerShowDeleteConfirmationSuccess = ref(false); // NEW + // CAPTCHA Form State const captchaForm = reactive({ // Use reactive for the form object enabled: false, @@ -863,6 +896,11 @@ const captchaLoading = ref(false); const captchaMessage = ref(''); const captchaSuccess = ref(false); +// --- Export Connections State --- +const exportConnectionsLoading = ref(false); +const exportConnectionsMessage = ref(''); +const exportConnectionsSuccess = ref(false); + // --- Passkey State --- const passkeyLoading = ref(false); // For registering new passkey const passkeyMessage = ref(''); // General messages for passkey operations (register, delete, edit name) @@ -1193,9 +1231,65 @@ const handleUpdateTerminalScrollbackLimit = async () => { fileManagerShowDeleteConfirmationLoading.value = false; } }; - - // --- 外观设置 --- -const openStyleCustomizer = () => { + +// --- Export Connections Method --- +const handleExportConnections = async () => { + exportConnectionsLoading.value = true; + exportConnectionsMessage.value = ''; + exportConnectionsSuccess.value = false; + try { + const response = await apiClient.get('/settings/export-connections', { // Corrected API path + responseType: 'blob', + }); + + let filename = 'nexus_connections_export.zip'; + const disposition = response.headers['content-disposition']; + if (disposition && disposition.includes('attachment')) { + const filenameRegex = /filename[^;=\n]*=(?:(['"])(.*?)\1|([^;\n]*))/; + const matches = filenameRegex.exec(disposition); + if (matches != null && (matches[2] || matches[3])) { + filename = matches[2] || matches[3]; // Use captured group 2 (quoted) or 3 (unquoted) + } + } + + const blob = new Blob([response.data], { type: response.headers['content-type'] || 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + exportConnectionsMessage.value = t('settings.exportConnections.success', '导出成功。文件已开始下载。'); + exportConnectionsSuccess.value = true; + } catch (error: any) { + console.error('导出连接失败:', error); + let message = t('settings.exportConnections.error', '导出连接时发生错误。'); + if (isAxiosError(error) && error.response && error.response.data) { + if (error.response.data instanceof Blob && error.response.data.type === 'application/json') { + try { + const errorJson = JSON.parse(await error.response.data.text()); + message = errorJson.message || message; + } catch (e) { /* Blob not valid JSON */ } + } else if (typeof error.response.data === 'string') { + message = error.response.data; + } else if (error.response.data && typeof error.response.data.message === 'string') { + message = error.response.data.message; + } + } else if (error.message) { + message = error.message; + } + exportConnectionsMessage.value = message; + exportConnectionsSuccess.value = false; + } finally { + exportConnectionsLoading.value = false; + } +}; + +// --- 外观设置 --- + const openStyleCustomizer = () => { appearanceStore.toggleStyleCustomizer(true); };