feat: 添加导出连接功能
This commit is contained in:
Generated
+63
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ImportedConnectionData, 'id'> {}
|
||||
|
||||
// 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<ExportedConnectionData[]> => {
|
||||
const getPlaintextConnectionsData = async (): Promise<PlaintextExportConnectionData[]> => {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
|
||||
@@ -85,32 +130,66 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
||||
});
|
||||
|
||||
|
||||
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<ExportedConnectionData[]> =>
|
||||
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<Buffer> => {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
}
|
||||
+7
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
});
|
||||
|
||||
|
||||
console.log('WebSocket 服务器初始化完成 (重构版)。');
|
||||
console.log('WebSocket 服务器初始化完成。');
|
||||
return wss;
|
||||
};
|
||||
|
||||
|
||||
@@ -684,6 +684,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Data Management Section (including Export) -->
|
||||
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden">
|
||||
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">
|
||||
{{ t('settings.category.dataManagement', '数据管理') }}
|
||||
</h2>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Export Connections Section -->
|
||||
<div class="settings-section-content">
|
||||
<h3 class="text-base font-semibold text-foreground mb-3">{{ t('settings.exportConnections.title', '导出连接数据') }}</h3>
|
||||
<p class="text-sm text-text-secondary mb-2">
|
||||
{{ t('settings.exportConnections.description', '将所有连接配置(包括密码和密钥等敏感信息)导出为一个加密的 ZIP 文件。') }}
|
||||
</p>
|
||||
<p class="text-sm text-text-secondary mb-4">
|
||||
<span class="font-semibold text-warning">{{ t('settings.exportConnections.decryptKeyInfo', '解压密码为您的 data/.env 文件中的 ENCRYPTION_KEY。请妥善保管此文件。') }}</span>
|
||||
</p>
|
||||
<form @submit.prevent="handleExportConnections" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="submit" :disabled="exportConnectionsLoading"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium inline-flex items-center">
|
||||
<svg v-if="exportConnectionsLoading" class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ exportConnectionsLoading ? t('common.loading') : t('settings.exportConnections.buttonText', '开始导出') }}
|
||||
</button>
|
||||
<p v-if="exportConnectionsMessage" :class="['text-sm', exportConnectionsSuccess ? 'text-success' : 'text-error']">{{ exportConnectionsMessage }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End Data Management Section -->
|
||||
|
||||
<!-- Appearance Section: Only show if settings data is loaded -->
|
||||
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden">
|
||||
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.category.appearance') }}</h2>
|
||||
@@ -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<UpdateCaptchaSettingsDto>({ // 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)
|
||||
@@ -1194,6 +1232,62 @@ const handleUpdateTerminalScrollbackLimit = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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);
|
||||
|
||||
Reference in New Issue
Block a user