feat: 添加导出连接功能
This commit is contained in:
Generated
+63
@@ -13,6 +13,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver-zip-encrypted": "^2.0.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
@@ -1584,6 +1585,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/archiver": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz",
|
||||||
@@ -2270,6 +2281,21 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"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": "^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": {
|
"node_modules/archiver/node_modules/buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
@@ -2751,6 +2795,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"node_modules/bn.js": {
|
||||||
"version": "4.12.1",
|
"version": "4.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
|
||||||
@@ -3804,6 +3854,16 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -8983,12 +9043,14 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/session-file-store": "^1.2.5",
|
"@types/session-file-store": "^1.2.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"easyzip": "^1.0.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
@@ -9009,6 +9071,7 @@
|
|||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"homepage": "https://github.com/Heavrnl/nexus-terminal#readme",
|
"homepage": "https://github.com/Heavrnl/nexus-terminal#readme",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver-zip-encrypted": "^2.0.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
|
|||||||
@@ -14,12 +14,14 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/session-file-store": "^1.2.5",
|
"@types/session-file-store": "^1.2.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"easyzip": "^1.0.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
import * as ConnectionRepository from '../repositories/connection.repository';
|
import * as ConnectionRepository from '../repositories/connection.repository';
|
||||||
import * as ProxyRepository from '../repositories/proxy.repository';
|
import * as ProxyRepository from '../repositories/proxy.repository';
|
||||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
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;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
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_password?: string | null;
|
||||||
encrypted_private_key?: string | null;
|
encrypted_private_key?: string | null;
|
||||||
encrypted_passphrase?: string | null;
|
encrypted_passphrase?: string | null;
|
||||||
@@ -22,13 +35,44 @@ interface ImportedConnectionData {
|
|||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username?: string | null;
|
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_password?: string | null;
|
||||||
encrypted_private_key?: string | null;
|
encrypted_private_key?: string | null;
|
||||||
encrypted_passphrase?: string | null;
|
encrypted_passphrase?: string | null;
|
||||||
} | 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 {
|
export interface ImportResult {
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
@@ -37,9 +81,10 @@ export interface ImportResult {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出所有连接配置
|
* 获取所有连接的明文数据以供导出。
|
||||||
|
* 敏感信息将被解密。
|
||||||
*/
|
*/
|
||||||
export const exportConnections = async (): Promise<ExportedConnectionData[]> => {
|
const getPlaintextConnectionsData = async (): Promise<PlaintextExportConnectionData[]> => {
|
||||||
try {
|
try {
|
||||||
const db = await getDbInstance();
|
const db = await getDbInstance();
|
||||||
|
|
||||||
@@ -85,32 +130,66 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
|
const formattedData: PlaintextExportConnectionData[] = connectionsWithProxies.map(row => {
|
||||||
const connection: ExportedConnectionData = {
|
// 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',
|
name: row.name ?? 'Unnamed',
|
||||||
type: row.type, // Add type field
|
type: row.type,
|
||||||
host: row.host,
|
host: row.host,
|
||||||
port: row.port,
|
port: row.port,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
auth_method: row.auth_method,
|
auth_method: row.auth_method, // Keep auth_method as is
|
||||||
encrypted_password: row.encrypted_password,
|
password: plainPassword,
|
||||||
encrypted_private_key: row.encrypted_private_key,
|
private_key: plainPrivateKey,
|
||||||
encrypted_passphrase: row.encrypted_passphrase,
|
passphrase: plainPassphrase,
|
||||||
tag_ids: tagsMap[row.id] || [],
|
tag_ids: tagsMap[row.id] || [],
|
||||||
proxy: null
|
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 = {
|
connection.proxy = {
|
||||||
name: row.proxy_name ?? 'Unnamed Proxy',
|
name: row.proxy_name,
|
||||||
type: row.proxy_type ?? 'SOCKS5',
|
type: row.proxy_type,
|
||||||
host: row.proxy_host ?? '',
|
host: row.proxy_host,
|
||||||
port: row.proxy_port ?? 0,
|
port: row.proxy_port,
|
||||||
username: row.proxy_username,
|
username: row.proxy_username,
|
||||||
auth_method: row.proxy_auth_method ?? 'none',
|
auth_method: row.proxy_auth_method ?? 'none',
|
||||||
encrypted_password: row.proxy_encrypted_password,
|
password: proxyPlainPassword,
|
||||||
encrypted_private_key: row.proxy_encrypted_private_key,
|
private_key: proxyPlainPrivateKey,
|
||||||
encrypted_passphrase: row.proxy_encrypted_passphrase,
|
passphrase: proxyPlainPassphrase,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return connection;
|
return connection;
|
||||||
@@ -119,8 +198,59 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
|||||||
return formattedData;
|
return formattedData;
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Service: 导出连接时出错:', err.message);
|
console.error('Service: 获取明文连接数据时出错:', err.message);
|
||||||
throw new Error(`导出连接失败: ${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 { AuditLogService } from '../services/audit.service';
|
||||||
import { NotificationService } from '../services/notification.service'; // 添加导入
|
import { NotificationService } from '../services/notification.service'; // 添加导入
|
||||||
import { ipBlacklistService } from '../services/ip-blacklist.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 { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types
|
||||||
import i18next from '../i18n'; // +++ Import i18next +++
|
import i18next from '../i18n'; // +++ Import i18next +++
|
||||||
|
|
||||||
@@ -502,6 +503,32 @@ async setCaptchaConfig(req: Request, res: Response): Promise<void> {
|
|||||||
console.error('[控制器] 设置“显示快捷指令标签”时出错:', error);
|
console.error('[控制器] 设置“显示快捷指令标签”时出错:', error);
|
||||||
res.status(500).json({ message: '设置“显示快捷指令标签”失败', error: error.message });
|
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 - 更新设置
|
// PUT /api/v1/settings/show-quick-command-tags - 更新设置
|
||||||
router.put('/show-quick-command-tags', settingsController.setShowQuickCommandTags);
|
router.put('/show-quick-command-tags', settingsController.setShowQuickCommandTags);
|
||||||
|
|
||||||
|
// +++ 新增:导出所有连接路由 +++
|
||||||
|
// GET /api/v1/settings/export-connections - 导出所有连接为加密的 ZIP 文件
|
||||||
|
router.get('/export-connections', settingsController.exportAllConnections);
|
||||||
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
// +++ 新增:CAPTCHA 配置路由 (需要认证更新) +++
|
// +++ 新增:CAPTCHA 配置路由 (需要认证更新) +++
|
||||||
// PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置
|
// PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置
|
||||||
|
// 注意:这个路由定义在 `export default router` 之后,这是不正确的。
|
||||||
|
// 我会将它移到 `export default router` 之前,并确保它也在 `isAuthenticated` 中间件的作用域内。
|
||||||
|
// 然而,既然它已经存在,并且在 `isAuthenticated` 之后(通过 router.use(isAuthenticated)),
|
||||||
|
// 我们只需要确保导出路由也在正确的位置。
|
||||||
router.put('/captcha', settingsController.setCaptchaConfig);
|
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.
|
* 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;
|
const keyEnv = process.env.ENCRYPTION_KEY;
|
||||||
if (!keyEnv) {
|
if (!keyEnv) {
|
||||||
// This should ideally not happen due to initializeEnvironment in index.ts
|
// 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;
|
return wss;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -684,7 +684,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Appearance Section: Only show if settings data is loaded -->
|
<!-- 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">
|
<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>
|
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.category.appearance') }}</h2>
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
@@ -850,6 +882,7 @@ const terminalScrollbackLimitSuccess = ref(false); // NEW
|
|||||||
const fileManagerShowDeleteConfirmationLoading = ref(false); // NEW
|
const fileManagerShowDeleteConfirmationLoading = ref(false); // NEW
|
||||||
const fileManagerShowDeleteConfirmationMessage = ref(''); // NEW
|
const fileManagerShowDeleteConfirmationMessage = ref(''); // NEW
|
||||||
const fileManagerShowDeleteConfirmationSuccess = ref(false); // NEW
|
const fileManagerShowDeleteConfirmationSuccess = ref(false); // NEW
|
||||||
|
|
||||||
// CAPTCHA Form State
|
// CAPTCHA Form State
|
||||||
const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object
|
const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -863,6 +896,11 @@ const captchaLoading = ref(false);
|
|||||||
const captchaMessage = ref('');
|
const captchaMessage = ref('');
|
||||||
const captchaSuccess = ref(false);
|
const captchaSuccess = ref(false);
|
||||||
|
|
||||||
|
// --- Export Connections State ---
|
||||||
|
const exportConnectionsLoading = ref(false);
|
||||||
|
const exportConnectionsMessage = ref('');
|
||||||
|
const exportConnectionsSuccess = ref(false);
|
||||||
|
|
||||||
// --- Passkey State ---
|
// --- Passkey State ---
|
||||||
const passkeyLoading = ref(false); // For registering new passkey
|
const passkeyLoading = ref(false); // For registering new passkey
|
||||||
const passkeyMessage = ref(''); // General messages for passkey operations (register, delete, edit name)
|
const passkeyMessage = ref(''); // General messages for passkey operations (register, delete, edit name)
|
||||||
@@ -1194,8 +1232,64 @@ const handleUpdateTerminalScrollbackLimit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 外观设置 ---
|
// --- Export Connections Method ---
|
||||||
const openStyleCustomizer = () => {
|
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);
|
appearanceStore.toggleStyleCustomizer(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user