This commit is contained in:
Baobhan Sith
2025-04-26 15:20:37 +08:00
parent 93b8863fdd
commit e269f40754
80 changed files with 868 additions and 1528 deletions
@@ -1,6 +1,6 @@
import * as appearanceRepository from '../repositories/appearance.repository';
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
import * as terminalThemeRepository from '../repositories/terminal-theme.repository'; // 需要验证 activeTerminalThemeId
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
/**
* 获取外观设置
@@ -67,4 +67,4 @@ export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise<
return appearanceRepository.updateAppearanceSettings(settingsDto);
};
// 注意:背景图片上传/处理逻辑需要根据最终决定(URL vs 上传)来添加。
@@ -48,6 +48,3 @@ export class AuditLogService {
return this.repository.getLogs(limit, offset, actionType, startDate, endDate, searchTerm);
}
}
// Optional: Export a singleton instance if needed throughout the backend
// export const auditLogService = new AuditLogService();
@@ -1,6 +1,5 @@
import axios from 'axios';
import { settingsService } from './settings.service';
import { CaptchaSettings, CaptchaProvider } from '../types/settings.types';
// CAPTCHA 验证 API 端点
const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify';
@@ -95,7 +94,6 @@ export class CaptchaService {
const params = new URLSearchParams();
params.append('secret', secretKey);
params.append('response', token);
// params.append('remoteip', userIpAddress); // 如果需要传递用户 IP
const response = await axios.post(RECAPTCHA_VERIFY_URL, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
@@ -11,9 +11,6 @@ export const addCommandHistory = async (command: string): Promise<number> => {
if (!command || command.trim().length === 0) {
throw new Error('命令不能为空');
}
// 可以在此添加去重逻辑,如果不想记录重复的命令
// const existing = await CommandHistoryRepository.findCommand(command); // 如果需要更复杂的去重逻辑
// if (existing) { ... }
// 调用 upsertCommand 来处理插入或更新时间戳
return CommandHistoryRepository.upsertCommand(command.trim());
@@ -1,15 +1,14 @@
import * as ConnectionRepository from '../repositories/connection.repository';
import { encrypt, decrypt } from '../utils/crypto';
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService
import { AuditLogService } from '../services/audit.service';
import {
ConnectionBase,
ConnectionWithTags,
CreateConnectionInput,
UpdateConnectionInput,
FullConnectionData // Import FullConnectionData if needed internally or by repo
FullConnectionData
} from '../types/connection.types'; // 从集中类型文件导入
// Re-export types if they need to be available via this service module
export type { ConnectionBase, ConnectionWithTags, CreateConnectionInput, UpdateConnectionInput };
@@ -33,9 +32,8 @@ export const getConnectionById = async (id: number): Promise<ConnectionWithTags
* 创建新连接
*/
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
// 1. Validate input (basic validation, more complex validation can be added)
// Removed name validation: if (!input.name || !input.host || !input.username || !input.auth_method) {
if (!input.host || !input.username || !input.auth_method) { // Validate required fields except name
// 1. 验证输入
if (!input.host || !input.username || !input.auth_method) {
throw new Error('缺少必要的连接信息 (host, username, auth_method)。');
}
if (input.auth_method === 'password' && !input.password) {
@@ -44,9 +42,8 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
if (input.auth_method === 'key' && !input.private_key) {
throw new Error('密钥认证方式需要提供 private_key。');
}
// Add more validation as needed (port range, proxy existence etc.)
// 2. Encrypt credentials
// 2. 加密凭证
let encryptedPassword = null;
let encryptedPrivateKey = null;
let encryptedPassphrase = null;
@@ -60,11 +57,11 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
}
}
// 3. Prepare data for repository
// 3. 准备仓库数据
const connectionData = {
name: input.name || '', // Use empty string '' if name is empty or undefined
name: input.name || '', // 如果 name 为空或 undefined,则使用空字符串 ''
host: input.host,
port: input.port ?? 22, // Default port
port: input.port ?? 22, // 默认端口
username: input.username,
auth_method: input.auth_method,
encrypted_password: encryptedPassword,
@@ -73,26 +70,25 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
proxy_id: input.proxy_id ?? null,
};
// 4. Create connection record in repository
// 4. 在仓库中创建连接记录
const newConnectionId = await ConnectionRepository.createConnection(connectionData);
// 5. Handle tags
// 5. 处理标签
const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? [];
if (tagIds.length > 0) {
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
}
// 6. Log audit action
// Fetch the created connection to get necessary details for logging
// 6. 记录审计操作
const newConnection = await getConnectionById(newConnectionId);
if (!newConnection) {
// This should ideally not happen if creation was successful
// 如果创建成功,这理论上不应该发生
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
throw new Error('创建连接后无法检索到该连接。');
}
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host });
// 7. Return the newly created connection with tags
// 7. 返回新创建的带标签的连接
return newConnection;
};
@@ -100,27 +96,27 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
* 更新连接信息
*/
export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise<ConnectionWithTags | null> => {
// 1. Fetch current connection data (including encrypted fields) to compare
// 1. 获取当前连接数据(包括加密字段)以进行比较
const currentFullConnection = await ConnectionRepository.findFullConnectionById(id);
if (!currentFullConnection) {
return null; // Connection not found
return null; // 未找到连接
}
// 2. Prepare data for update
// 2. 准备更新数据
const dataToUpdate: Partial<ConnectionRepository.FullConnectionData> = {};
let needsCredentialUpdate = false;
let newAuthMethod = input.auth_method || currentFullConnection.auth_method;
// Update non-credential fields
if (input.name !== undefined) dataToUpdate.name = input.name || ''; // Use empty string '' if name is empty string or null/undefined
// 更新非凭证字段
if (input.name !== undefined) dataToUpdate.name = input.name || ''; // 如果 name 是空字符串或 null/undefined,则使用空字符串 ''
if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username;
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // Allows setting to null
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // 允许设置为 null
// Handle auth method change or credential update
// 处理认证方法更改或凭证更新
if (input.auth_method && input.auth_method !== currentFullConnection.auth_method) {
// Auth method changed
// 认证方法已更改
dataToUpdate.auth_method = input.auth_method;
needsCredentialUpdate = true;
if (input.auth_method === 'password') {
@@ -128,16 +124,16 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
dataToUpdate.encrypted_password = encrypt(input.password);
dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null;
} else { // key
} else { // 密钥
if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。');
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
// Only encrypt if passphrase is a non-empty string
// 仅当密码短语为非空字符串时才加密
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
dataToUpdate.encrypted_password = null;
}
} else {
// Auth method did not change, check if credentials for the current method were provided
// Only encrypt and update if a non-empty string is provided
// 认证方法未更改,检查是否提供了当前方法的凭证
// 仅当提供了非空字符串时才加密和更新
if (newAuthMethod === 'password' && input.password && input.password.trim() !== '') {
dataToUpdate.encrypted_password = encrypt(input.password);
needsCredentialUpdate = true;
@@ -145,51 +141,51 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
let passphraseChanged = false;
if (input.private_key && input.private_key.trim() !== '') {
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
// Passphrase must be updated (or cleared) if private key is updated
// Encrypt only if non-empty, otherwise set to null
// 如果私钥更新,则必须更新(或清除)密码短语
// 仅当非空时加密,否则设置为 null
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true;
passphraseChanged = true; // Mark passphrase as handled if key changed
passphraseChanged = true; // 如果密钥更改,则将密码短语标记为已处理
}
// Handle case where only passphrase is changed (and key wasn't)
// Check if input.passphrase is defined (could be empty string to clear)
// 处理仅更改密码短语(且密钥未更改)的情况
// 检查 input.passphrase 是否已定义(可能是空字符串以清除)
if (!passphraseChanged && input.passphrase !== undefined) {
// Encrypt only if non-empty, otherwise set to null
// 仅当非空时加密,否则设置为 null
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true; // Consider this a credential update
needsCredentialUpdate = true; // 将此视为凭证更新
}
}
}
// 3. Update connection record if there are changes
// 3. 如果有更改,则更新连接记录
const hasNonTagChanges = Object.keys(dataToUpdate).length > 0;
let updatedFieldsForAudit: string[] = []; // Track fields for audit log
let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段
if (hasNonTagChanges) {
updatedFieldsForAudit = Object.keys(dataToUpdate); // Get fields before update call
updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
if (!updated) {
// Should not happen if findFullConnectionById succeeded, but good practice
// 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践
throw new Error('更新连接记录失败。');
}
}
// 4. Handle tags update if tag_ids were provided
// 4. 如果提供了 tag_ids,则处理标签更新
if (input.tag_ids !== undefined) {
const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0);
await ConnectionRepository.updateConnectionTags(id, validTagIds);
}
// Add 'tag_ids' to audit log if they were updated
// 如果 tag_ids 已更新,则将其添加到审计日志
if (input.tag_ids !== undefined) {
updatedFieldsForAudit.push('tag_ids');
}
// 5. Log audit action if any changes were made
// 5. 如果进行了任何更改,则记录审计操作
if (hasNonTagChanges || input.tag_ids !== undefined) {
auditLogService.logAction('CONNECTION_UPDATED', { connectionId: id, updatedFields: updatedFieldsForAudit });
}
// 6. Fetch and return the updated connection
// 6. 获取并返回更新后的连接
return getConnectionById(id);
};
@@ -200,11 +196,11 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
export const deleteConnection = async (id: number): Promise<boolean> => {
const deleted = await ConnectionRepository.deleteConnection(id);
if (deleted) {
// Log audit action after successful deletion
// 删除成功后记录审计操作
auditLogService.logAction('CONNECTION_DELETED', { connectionId: id });
}
return deleted;
};
// Note: testConnection, importConnections, exportConnections logic
// will be moved to SshService and ImportExportService respectively.
// 注意:testConnectionimportConnectionsexportConnections 逻辑
// 将分别移至 SshService ImportExportService
@@ -1,7 +1,5 @@
import { exec } from 'child_process';
import { promisify } from 'util';
// import { Service } from 'typedi'; // Removed typedi import
// import { logger } from '../utils/logger'; // Removed logger import
const execAsync = promisify(exec);
@@ -60,11 +58,10 @@ export class DockerService {
try {
// 尝试执行一个简单的 docker 命令,如 docker version
await execAsync('docker version', { timeout: 2000 }); // 5秒超时
console.log('[DockerService] Docker is available.'); // Use console.log
this.isDockerAvailableCache = true;
return true;
} catch (error: any) {
console.warn('[DockerService] Docker check failed. Docker might not be installed or running.', { error: error.message }); // Use console.warn
this.isDockerAvailableCache = false;
return false;
}
@@ -1,13 +1,10 @@
// packages/backend/src/services/import-export.service.ts
import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
// Import the instance getter and helpers
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { Database } from 'sqlite3'; // Import Database type
// Remove top-level db instance
// --- Interface definitions remain the same ---
interface ImportedConnectionData {
name: string;
host: string;
@@ -36,7 +33,6 @@ export interface ImportResult {
failureCount: number;
errors: { connectionName?: string; message: string }[];
}
// --- End Interface definitions ---
/**
@@ -44,9 +40,8 @@ export interface ImportResult {
*/
export const exportConnections = async (): Promise<ExportedConnectionData[]> => {
try {
const db = await getDbInstance(); // Get DB instance
const db = await getDbInstance();
// Define a more specific type for the row structure
type ExportRow = ConnectionRepository.FullConnectionData & {
proxy_db_id: number | null;
proxy_name: string | null;
@@ -60,7 +55,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
proxy_encrypted_passphrase?: string | null;
};
// Fetch connections joined with proxies using await allDb
const connectionsWithProxies = await allDb<ExportRow>(db,
`SELECT
c.*,
@@ -75,22 +70,22 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
ORDER BY c.name ASC`
);
// Fetch all tag associations using await allDb
const tagRows = await allDb<{ connection_id: number, tag_id: number }>(db,
'SELECT connection_id, tag_id FROM connection_tags'
);
// Create a map for easy tag lookup
const tagsMap: { [connId: number]: number[] } = {};
tagRows.forEach(row => {
if (!tagsMap[row.connection_id]) tagsMap[row.connection_id] = [];
tagsMap[row.connection_id].push(row.tag_id);
});
// Format data for export
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
const connection: ExportedConnectionData = {
name: row.name ?? 'Unnamed', // Provide default if name is null
name: row.name ?? 'Unnamed',
host: row.host,
port: row.port,
username: row.username,
@@ -104,12 +99,12 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
if (row.proxy_db_id) {
connection.proxy = {
name: row.proxy_name ?? 'Unnamed Proxy', // Provide default
type: row.proxy_type ?? 'SOCKS5', // Provide default or handle error
host: row.proxy_host ?? '', // Provide default or handle error
port: row.proxy_port ?? 0, // Provide default or handle error
name: row.proxy_name ?? 'Unnamed Proxy',
type: row.proxy_type ?? 'SOCKS5',
host: row.proxy_host ?? '',
port: row.proxy_port ?? 0,
username: row.proxy_username,
auth_method: row.proxy_auth_method ?? 'none', // Provide default
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,
@@ -122,7 +117,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
} catch (err: any) {
console.error('Service: 导出连接时出错:', err.message);
throw new Error(`导出连接失败: ${err.message}`); // Re-throw for controller
throw new Error(`导出连接失败: ${err.message}`);
}
};
@@ -147,26 +142,25 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
let successCount = 0;
let failureCount = 0;
const errors: { connectionName?: string; message: string }[] = [];
const db = await getDbInstance(); // Get DB instance once for the transaction
const db = await getDbInstance();
try {
await runDb(db, 'BEGIN TRANSACTION'); // Start transaction using await runDb
await runDb(db, 'BEGIN TRANSACTION');
const connectionsToInsert: Array<Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }> = [];
const proxyCache: { [key: string]: number } = {}; // Cache for created/found proxy IDs
const proxyCache: { [key: string]: number } = {};
// --- Pass 1: Validate data and prepare for insertion ---
for (const connData of importedData) {
try {
// Basic validation
if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) {
throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。');
}
// ... (add other validation as before) ...
let proxyIdToUse: number | null = null;
// Handle proxy (find or create) - uses async repository functions
if (connData.proxy) {
const proxyData = connData.proxy;
if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port) {
@@ -194,11 +188,10 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
proxyIdToUse = await ProxyRepository.createProxy(newProxyData);
console.log(`Service: 导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`);
}
if (proxyIdToUse) proxyCache[cacheKey] = proxyIdToUse; // Cache the ID
if (proxyIdToUse) proxyCache[cacheKey] = proxyIdToUse;
}
}
// Prepare connection data for bulk insert (add tag_ids here)
connectionsToInsert.push({
name: connData.name,
host: connData.host,
@@ -209,7 +202,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
encrypted_private_key: connData.encrypted_private_key || null,
encrypted_passphrase: connData.encrypted_passphrase || null,
proxy_id: proxyIdToUse,
tag_ids: connData.tag_ids || [] // Include tag_ids
tag_ids: connData.tag_ids || []
});
} catch (connError: any) {
@@ -217,25 +210,22 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
errors.push({ connectionName: connData.name || '未知连接', message: connError.message });
console.warn(`Service: 处理导入连接 "${connData.name || '未知'}" 时出错: ${connError.message}`);
}
} // End for loop
// --- Pass 2: Bulk insert connections ---
}
let insertedResults: { connectionId: number, originalData: any }[] = [];
if (connectionsToInsert.length > 0) {
// Pass the transaction-aware db instance
insertedResults = await ConnectionRepository.bulkInsertConnections(db, connectionsToInsert);
successCount = insertedResults.length;
}
// --- Pass 3: Associate tags ---
const insertTagSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`; // Use INSERT OR IGNORE
const insertTagSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`;
for (const result of insertedResults) {
const originalTagIds = result.originalData?.tag_ids;
if (Array.isArray(originalTagIds) && originalTagIds.length > 0) {
const validTagIds = originalTagIds.filter((id: any) => typeof id === 'number' && id > 0);
if (validTagIds.length > 0) {
const tagPromises = validTagIds.map(tagId =>
runDb(db, insertTagSql, [result.connectionId, tagId]).catch(tagError => { // Use await runDb
runDb(db, insertTagSql, [result.connectionId, tagId]).catch(tagError => {
console.warn(`Service: 导入连接 ${result.originalData.name}: 关联标签 ID ${tagId} 失败: ${tagError.message}`);
})
);
@@ -245,20 +235,19 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
}
// Commit transaction using await runDb
await runDb(db, 'COMMIT');
console.log(`Service: 导入事务提交。成功: ${successCount}, 失败: ${failureCount}`);
return { successCount, failureCount, errors };
} catch (error: any) {
// Rollback transaction on any error during the process
console.error('Service: 导入事务处理出错,正在回滚:', error);
try {
await runDb(db, 'ROLLBACK'); // Use await runDb
await runDb(db, 'ROLLBACK');
} catch (rollbackErr: any) {
console.error("Service: 回滚事务失败:", rollbackErr);
}
// Adjust failure count and return error summary
failureCount = importedData.length;
successCount = 0;
errors.push({ message: `事务处理失败: ${error.message}` });
@@ -1,12 +1,9 @@
// packages/backend/src/services/ip-blacklist.service.ts
// Import new async helpers and the instance getter
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { settingsService } from './settings.service';
import { NotificationService } from './notification.service'; // 导入 NotificationService
import * as sqlite3 from 'sqlite3'; // Keep for RunResult type if needed
import { NotificationService } from './notification.service';
// Remove top-level db instance
// const db = getDb();
const notificationService = new NotificationService(); // 实例化 NotificationService
// 黑名单相关设置的 Key
@@ -28,7 +25,7 @@ interface IpBlacklistEntry {
blocked_until: number | null;
}
// Define the expected row structure from the database if it matches IpBlacklistEntry
type DbIpBlacklistRow = IpBlacklistEntry;
export class IpBlacklistService {
@@ -95,18 +92,16 @@ export class IpBlacklistService {
const entry = await this.getEntry(ip);
if (entry) {
// Update existing record
const newAttempts = entry.attempts + 1;
let blockedUntil = entry.blocked_until;
let shouldNotify = false;
if (newAttempts >= maxAttempts && !entry.blocked_until) { // Only block and notify if not already blocked
if (newAttempts >= maxAttempts && !entry.blocked_until) {
blockedUntil = now + banDuration;
shouldNotify = true;
console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`);
} else if (newAttempts >= maxAttempts && entry.blocked_until) {
console.log(`[IP Blacklist] IP ${ip} 再次登录失败,当前已处于封禁状态。`);
// Optionally extend ban duration here if needed
}
await runDb(db,
@@ -115,7 +110,6 @@ export class IpBlacklistService {
);
if (shouldNotify && blockedUntil) {
// Trigger notification after successful DB update
notificationService.sendNotification('IP_BLOCKED', {
ip: ip,
attempts: newAttempts,
@@ -142,7 +136,6 @@ export class IpBlacklistService {
);
if (shouldNotify && blockedUntil) {
// Trigger notification after successful DB insert
notificationService.sendNotification('IP_BLOCKED', {
ip: ip,
attempts: attempts,
@@ -153,7 +146,6 @@ export class IpBlacklistService {
}
} catch (error: any) {
console.error(`[IP Blacklist] 记录 IP ${ip} 失败尝试时出错:`, error.message);
// Avoid throwing error here to prevent login process failure due to blacklist issues
}
}
@@ -168,7 +160,6 @@ export class IpBlacklistService {
console.log(`[IP Blacklist] 已重置 IP ${ip} 的失败尝试记录。`);
} catch (error: any) {
console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error.message);
// Avoid throwing error here
}
}
@@ -189,9 +180,7 @@ export class IpBlacklistService {
return { entries, total };
} catch (error: any) {
console.error('[IP Blacklist] 获取黑名单列表时出错:', error.message);
// Return empty list on error? Or re-throw?
// throw new Error('获取黑名单列表失败');
return { entries: [], total: 0 }; // Return empty on error
return { entries: [], total: 0 };
}
}
@@ -213,7 +202,7 @@ export class IpBlacklistService {
}
} catch (error: any) {
console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, error.message);
throw new Error(`从黑名单删除 IP ${ip} 时出错`); // Re-throw error
throw new Error(`从黑名单删除 IP ${ip} 时出错`);
}
}
}
@@ -8,22 +8,20 @@ import {
EmailConfig,
TelegramConfig,
NotificationChannelConfig,
NotificationChannelType // Import the missing type
NotificationChannelType
} from '../types/notification.types';
import * as nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter
import i18next, { defaultLng, supportedLngs } from '../i18n'; // Import i18next instance and config
import { settingsService } from './settings.service'; // Import settings service
// Removed logger import
import Mail from 'nodemailer/lib/mailer';
import i18next, { defaultLng, supportedLngs } from '../i18n';
import { settingsService } from './settings.service';
// Define translation keys for test notifications for clarity
const testSubjectKey = 'testNotification.subject';
const testEmailBodyKey = 'testNotification.email.body';
const testEmailBodyHtmlKey = 'testNotification.email.bodyHtml'; // Separate key for HTML version
const testEmailBodyHtmlKey = 'testNotification.email.bodyHtml';
const testWebhookDetailsKey = 'testNotification.webhook.detailsMessage';
const testTelegramDetailsKey = 'testNotification.telegram.detailsMessage';
const testTelegramBodyTemplateKey = 'testNotification.telegram.bodyTemplate'; // Key for the template itself
const testTelegramBodyTemplateKey = 'testNotification.telegram.bodyTemplate';
export class NotificationService {
private repository: NotificationSettingsRepository;
@@ -41,14 +39,10 @@ export class NotificationService {
}
async createSetting(settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
// Add validation if needed
return this.repository.create(settingData);
}
async updateSetting(id: number, settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> {
// Add validation if needed
// Ensure password is not overwritten if not provided explicitly? Or handle in controller/route.
// For now, we assume the full config (including potentially sensitive fields) is passed for updates if needed.
return this.repository.update(id, settingData);
}
@@ -56,9 +50,7 @@ export class NotificationService {
return this.repository.delete(id);
}
// --- Test Notification Methods ---
// Generic test method dispatcher
async testSetting(channelType: NotificationChannelType, config: NotificationChannelConfig): Promise<{ success: boolean; message: string }> {
switch (channelType) {
case 'email':
@@ -68,114 +60,91 @@ export class NotificationService {
case 'telegram':
return this._testTelegramSetting(config as TelegramConfig);
default:
console.warn(`[Notification Test] Unsupported channel type for testing: ${channelType}`);
console.warn(`[通知测试] 不支持的测试渠道类型: ${channelType}`);
return { success: false, message: `不支持测试此渠道类型 (${channelType})` };
}
}
// Specific test method for Email
private async _testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> {
console.log('[Notification Test - Email] Starting test...'); // Added log
console.log('[通知测试 - 邮件] 开始测试...');
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
console.error('[Notification Test - Email] Missing required config.'); // Added log
console.error('[通知测试 - 邮件] 缺少必要的配置。');
return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 主机, 端口, 发件人)。' };
}
// --- Fetch User Language ---
let userLang = defaultLng;
try {
const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting;
}
console.log(`[Notification Test - Email] Using language: ${userLang}`); // Added log
console.log(`[通知测试 - 邮件] 使用语言: ${userLang}`);
} catch (error) {
console.error(`[Notification Test - Email] Error fetching language setting, using default (${defaultLng}):`, error);
console.error(`[通知测试 - 邮件] 获取语言设置时出错,使用默认 (${defaultLng}):`, error);
}
// --- End Fetch User Language ---
// Let TypeScript infer the options type for SMTP
const transporterOptions = {
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure ?? true, // Default to true (TLS)
secure: config.smtpSecure ?? true,
auth: (config.smtpUser || config.smtpPass) ? {
user: config.smtpUser,
pass: config.smtpPass, // Ensure password is included if user is present
pass: config.smtpPass,
} : undefined,
// Consider adding TLS options if needed, e.g., ignore self-signed certs
// tls: {
// rejectUnauthorized: false // Use with caution!
// }
};
const transporter = nodemailer.createTransport(transporterOptions);
// Translate event display name first
const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' }); // Hardcoding event for test email
const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' });
const mailOptions: Mail.Options = {
from: config.from,
to: config.to, // Use the 'to' from config for testing
// Use i18next for subject and body, using fetched user language
to: config.to,
subject: i18next.t(testSubjectKey, { lng: userLang, defaultValue: 'Nexus Terminal Test Notification ({eventDisplay})', eventDisplay: eventDisplayName }),
text: i18next.t(testEmailBodyKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `This is a test email from Nexus Terminal for event '{{eventDisplay}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}`, eventDisplay: eventDisplayName }),
html: i18next.t(testEmailBodyHtmlKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `<p>This is a test email from <b>Nexus Terminal</b> for event '{{eventDisplay}}'.</p><p>If you received this, your SMTP configuration is working.</p><p>Timestamp: {{timestamp}}</p>`, eventDisplay: eventDisplayName }),
};
try {
console.log(`[Notification Test - Email] Attempting to send test email via ${config.smtpHost}:${config.smtpPort} to ${config.to}`); // Updated log prefix
console.log(`[通知测试 - 邮件] 尝试通过 ${config.smtpHost}:${config.smtpPort} 发送测试邮件至 ${config.to}`);
const info = await transporter.sendMail(mailOptions);
console.log(`[Notification Test - Email] Test email sent successfully: ${info.messageId}`); // Updated log prefix
// Verify connection if possible (optional)
// await transporter.verify();
// console.log('[Notification Test - Email] SMTP Connection verified.');
console.log(`[通知测试 - 邮件] 测试邮件发送成功: ${info.messageId}`);
return { success: true, message: '测试邮件发送成功!请检查收件箱。' };
} catch (error: any) {
console.error(`[Notification Test - Email] Error sending test email:`, error); // Updated log prefix
console.error(`[通知测试 - 邮件] 发送测试邮件时出错:`, error);
return { success: false, message: `测试邮件发送失败: ${error.message || '未知错误'}` };
}
}
// Specific test method for Webhook
private async _testWebhookSetting(config: WebhookConfig): Promise<{ success: boolean; message: string }> {
console.log('[Notification Test - Webhook] Starting test...'); // Added log
console.log('[通知测试 - Webhook] 开始测试...');
if (!config.url) {
console.error('[Notification Test - Webhook] Missing URL.'); // Added log
console.error('[通知测试 - Webhook] 缺少 URL');
return { success: false, message: '测试 Webhook 失败:缺少 URL。' };
}
// --- Fetch User Language ---
let userLang = defaultLng;
try {
const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting;
}
console.log(`[Notification Test - Webhook] Using language: ${userLang}`); // Added log
console.log(`[通知测试 - Webhook] 使用语言: ${userLang}`);
} catch (error) {
console.error(`[Notification Test - Webhook] Error fetching language setting, using default (${defaultLng}):`, error);
console.error(`[通知测试 - Webhook] 获取语言设置时出错,使用默认 (${defaultLng}):`, error);
}
// --- End Fetch User Language ---
// Use a valid event type for the test payload
const testPayload: NotificationPayload = {
event: 'SETTINGS_UPDATED', // Use a valid event type
event: 'SETTINGS_UPDATED',
timestamp: Date.now(),
// Use i18next for the details message, using fetched user language
details: { message: i18next.t(testWebhookDetailsKey, { lng: userLang, defaultValue: 'This is a test notification from Nexus Terminal (Webhook).' }) }
};
// Log the translated message safely
const translatedWebhookMessage = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details is not an object with message property';
console.log(`[Notification Test - Webhook] Test payload created. Translated details.message:`, translatedWebhookMessage); // Added log with type check
const translatedWebhookMessage = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details 不是带有 message 属性的对象';
console.log(`[通知测试 - Webhook] 测试负载已创建。翻译后的 details.message:`, translatedWebhookMessage);
// Use the same rendering logic as actual sending
// Translate event display name
const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event });
// Default body for webhook test, using single braces
const defaultBody = JSON.stringify(testPayload, null, 2);
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`; // Updated default template text
// Pass eventDisplayName to renderTemplate
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`;
const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, testPayload, defaultBody, eventDisplayName);
const requestConfig: AxiosRequestConfig = {
@@ -186,130 +155,107 @@ export class NotificationService {
...(config.headers || {}),
},
data: requestBody,
timeout: 15000, // Slightly longer timeout for testing
timeout: 15000,
};
try {
console.log(`[Notification Test - Webhook] Sending test Webhook to ${config.url}`); // Updated log prefix
console.log(`[通知测试 - Webhook] 发送测试 Webhook ${config.url}`);
const response = await axios(requestConfig);
console.log(`[Notification Test - Webhook] Test Webhook sent successfully to ${config.url}. Status: ${response.status}`); // Updated log prefix
console.log(`[通知测试 - Webhook] 测试 Webhook 成功发送到 ${config.url}。状态: ${response.status}`);
return { success: true, message: `测试 Webhook 发送成功 (状态码: ${response.status})。` };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data || error.message || '未知错误';
console.error(`[Notification Test - Webhook] Error sending test Webhook to ${config.url}:`, errorMessage); // Updated log prefix
console.error(`[通知测试 - Webhook] 发送测试 Webhook ${config.url} 时出错:`, errorMessage);
return { success: false, message: `测试 Webhook 发送失败: ${errorMessage}` };
}
}
// Specific test method for Telegram
private async _testTelegramSetting(config: TelegramConfig): Promise<{ success: boolean; message: string }> {
console.log('[Notification Test - Telegram] Starting test...');
console.log('[通知测试 - Telegram] 开始测试...');
if (!config.botToken || !config.chatId) {
console.error('[Notification Test - Telegram] Missing botToken or chatId.');
console.error('[通知测试 - Telegram] 缺少 botToken chatId');
return { success: false, message: '测试 Telegram 失败:缺少机器人 Token 或聊天 ID。' };
}
// --- Fetch User Language ---
let userLang = defaultLng;
try {
const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting;
}
console.log(`[Notification Test - Telegram] Using language: ${userLang}`); // Added log
console.log(`[通知测试 - Telegram] 使用语言: ${userLang}`);
} catch (error) {
console.error(`[Notification Test - Telegram] Error fetching language setting, using default (${defaultLng}):`, error);
console.error(`[通知测试 - Telegram] 获取语言设置时出错,使用默认 (${defaultLng}):`, error);
}
// --- End Fetch User Language ---
// Use a valid event type for the test payload
// Declare payload first, details will be added after translation
const testPayload: NotificationPayload = {
event: 'SETTINGS_UPDATED',
timestamp: Date.now(),
details: undefined // Initialize details as undefined
details: undefined
};
// --- Translation Start ---
// Log options before calling t() for details message
const detailsOptions = { lng: userLang, defaultValue: 'Fallback: This is a test notification from Nexus Terminal (Telegram).' }; // Use userLang
const keyWithNamespace = `notifications:${testTelegramDetailsKey}`; // Explicitly add namespace
// console.log(`[Notification Test - Telegram] Calling i18next.t for key '${keyWithNamespace}' with options:`, detailsOptions);
const translatedDetailsMessage = i18next.t(keyWithNamespace, detailsOptions); // Use key with namespace
// console.log(`[Notification Test - Telegram] Result from i18next.t for key '${keyWithNamespace}':`, translatedDetailsMessage);
// --- Translation End ---
const detailsOptions = { lng: userLang, defaultValue: 'Fallback: This is a test notification from Nexus Terminal (Telegram).' };
const keyWithNamespace = `notifications:${testTelegramDetailsKey}`;
const translatedDetailsMessage = i18next.t(keyWithNamespace, detailsOptions);
// Assign the translated details to the existing payload object
testPayload.details = { message: translatedDetailsMessage };
// Log the translated message safely
const messageFromPayload = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details is not an object with message property';
console.log(`[Notification Test - Telegram] Test payload created. Final details.message in payload:`, messageFromPayload); // Updated log description
console.log(`[Notification Test - Telegram] Test payload created. Final details.message in payload:`, messageFromPayload);
// Use the same rendering logic as actual sending
// Get the default template from i18n, fallback to a hardcoded default if key not found
// Also explicitly specify namespace and use userLang for the template key
const templateKeyWithNamespace = `notifications:${testTelegramBodyTemplateKey}`;
const defaultMessageTemplateFromI18n = i18next.t(templateKeyWithNamespace, {
lng: userLang, // Use userLang
defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\`` // Added Fallback prefix
lng: userLang,
defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\``
});
console.log(`[Notification Test - Telegram] Default template from i18n (using lang '${userLang}', key '${templateKeyWithNamespace}'):`, defaultMessageTemplateFromI18n); // Updated log
console.log(`[通知测试 - Telegram] 来自 i18n 的默认模板 (使用语言 '${userLang}', '${templateKeyWithNamespace}'):`, defaultMessageTemplateFromI18n);
// Determine which template to use (user's or default from i18n)
const templateToUse = config.messageTemplate || defaultMessageTemplateFromI18n;
console.log(`[Notification Test - Telegram] Template to render:`, templateToUse); // Added log
console.log(`[通知测试 - Telegram] 要渲染的模板:`, templateToUse);
// Translate event display name
const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event });
// Render the template, passing eventDisplayName
const messageText = this._renderTemplate(templateToUse, testPayload, '', eventDisplayName);
console.log(`[Notification Test - Telegram] Rendered message text:`, messageText);
console.log(`[通知测试 - Telegram] 渲染的消息文本:`, messageText);
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
try {
console.log(`[Notification Test - Telegram] Sending test Telegram message to chat ID ${config.chatId}`); // Updated log prefix
console.log(`[通知测试 - Telegram] 发送测试 Telegram 消息到聊天 ID ${config.chatId}`);
const response = await axios.post(telegramApiUrl, {
chat_id: config.chatId,
text: messageText,
parse_mode: 'Markdown' // Add parse_mode for testing consistency
}, { timeout: 15000 }); // Slightly longer timeout for testing
parse_mode: 'Markdown'
}, { timeout: 15000 });
if (response.data?.ok) {
console.log(`[Notification Test - Telegram] Test Telegram message sent successfully.`); // Updated log prefix
console.log(`[通知测试 - Telegram] 测试 Telegram 消息发送成功。`);
return { success: true, message: '测试 Telegram 消息发送成功!' };
} else {
console.error(`[Notification Test - Telegram] Telegram API returned error:`, response.data?.description); // Updated log prefix
console.error(`[通知测试 - Telegram] Telegram API 返回错误:`, response.data?.description);
return { success: false, message: `测试 Telegram 发送失败: ${response.data?.description || 'API 返回失败'}` };
}
} catch (error: any) {
const errorMessage = error.response?.data?.description || error.response?.data || error.message || '未知错误';
console.error(`[Notification Test - Telegram] Error sending test Telegram message:`, errorMessage); // Updated log prefix
console.error(`[通知测试 - Telegram] 发送测试 Telegram 消息时出错:`, errorMessage);
return { success: false, message: `测试 Telegram 发送失败: ${errorMessage}` };
}
}
// --- Core Notification Sending Logic ---
async sendNotification(event: NotificationEvent, details?: Record<string, any> | string): Promise<void> {
console.log(`[Notification] Event triggered: ${event}`, details || '');
console.log(`[通知] 事件触发: ${event}`, details || '');
// 1. Get user's preferred language (or default)
let userLang = defaultLng;
try {
// Assuming settingsService is available or needs instantiation if not singleton
const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting;
}
} catch (error) {
console.error(`[Notification] Error fetching language setting for event ${event}:`, error);
// Proceed with default language
console.error(`[通知] 获取事件 ${event} 的语言设置时出错:`, error);
}
console.log(`[Notification] Using language '${userLang}' for event ${event}`);
console.log(`[通知] 事件 ${event} 使用语言 '${userLang}'`);
const payload: NotificationPayload = {
event,
@@ -319,224 +265,186 @@ export class NotificationService {
try {
const applicableSettings = await this.repository.getEnabledByEvent(event);
console.log(`[Notification] Found ${applicableSettings.length} applicable setting(s) for event ${event}`);
console.log(`[通知] 找到 ${applicableSettings.length} 个适用于事件 ${event} 的设置`);
if (applicableSettings.length === 0) {
return; // No enabled settings for this event
return; // 此事件没有启用的设置
}
const sendPromises = applicableSettings.map(setting => {
switch (setting.channel_type) {
case 'webhook':
return this._sendWebhook(setting, payload, userLang); // Pass userLang
return this._sendWebhook(setting, payload, userLang);
case 'email':
return this._sendEmail(setting, payload, userLang); // Pass userLang
return this._sendEmail(setting, payload, userLang);
case 'telegram':
return this._sendTelegram(setting, payload, userLang); // Pass userLang
return this._sendTelegram(setting, payload, userLang);
default:
console.warn(`[Notification] Unknown channel type: ${setting.channel_type} for setting ID ${setting.id}`);
return Promise.resolve(); // Don't fail all if one is unknown
console.warn(`[通知] 未知渠道类型: ${setting.channel_type} (设置 ID: ${setting.id})`);
return Promise.resolve(); // 如果有一个未知,不要让所有都失败
}
});
// Wait for all notifications to be attempted
await Promise.allSettled(sendPromises);
console.log(`[Notification] Finished attempting notifications for event ${event}`);
console.log(`[通知] 完成尝试发送事件 ${event} 的通知`);
} catch (error) {
console.error(`[Notification] Error fetching or processing settings for event ${event}:`, error);
// Decide if this error itself should trigger a notification (e.g., SERVER_ERROR)
// Be careful to avoid infinite loops
console.error(`[通知] 获取或处理事件 ${event} 的设置时出错:`, error);
}
}
// --- Private Sending Helpers ---
// Updated to accept eventDisplayName
private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string, eventDisplayName?: string): string {
if (!template) return defaultText;
let rendered = template;
// Replace single-brace placeholders
rendered = rendered.replace(/\{event\}/g, payload.event); // Keep original event code if needed
rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event); // Use translated name, fallback to original code
rendered = rendered.replace(/\{event\}/g, payload.event);
rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event);
rendered = rendered.replace(/\{timestamp\}/g, new Date(payload.timestamp).toISOString());
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
rendered = rendered.replace(/\{details\}/g, detailsString);
return rendered;
}
// Updated to accept userLang
private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
const config = setting.config as WebhookConfig;
if (!config.url) {
console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`);
console.error(`[通知] Webhook 设置 ID ${setting.id} 缺少 URL`);
return;
}
// Translate event display name
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event });
// Translate payload details if they match a known key structure
const translatedDetails = this._translatePayloadDetails(payload.details, userLang);
const translatedPayload = { ...payload, details: translatedDetails }; // Keep original payload structure for details translation
const translatedPayload = { ...payload, details: translatedDetails };
const defaultBody = JSON.stringify(translatedPayload, null, 2); // Default body still uses the potentially translated details
// Note: Webhook body templates might need adjustments if they expect specific structures
// Use default template text if user hasn't provided one
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`; // Updated placeholder
// Pass eventDisplayName to renderTemplate
const defaultBody = JSON.stringify(translatedPayload, null, 2);
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`;
const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, translatedPayload, defaultBody, eventDisplayName);
const requestConfig: AxiosRequestConfig = {
method: config.method || 'POST',
url: config.url,
headers: {
'Content-Type': 'application/json', // Default, can be overridden by config.headers
'Content-Type': 'application/json',
...(config.headers || {}),
},
data: requestBody,
timeout: 10000, // Add a timeout (e.g., 10 seconds)
timeout: 10000,
};
try {
console.log(`[Notification] Sending Webhook to ${config.url} for event ${payload.event}`);
console.log(`[通知] 发送 Webhook ${config.url} (事件: ${payload.event})`);
const response = await axios(requestConfig);
console.log(`[Notification] Webhook sent successfully to ${config.url}. Status: ${response.status}`);
console.log(`[通知] Webhook 成功发送到 ${config.url}。状态: ${response.status}`);
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data || error.message;
console.error(`[Notification] Error sending Webhook to ${config.url} for setting ID ${setting.id}:`, errorMessage);
console.error(`[通知] 发送 Webhook ${config.url} (设置 ID: ${setting.id}) 时出错:`, errorMessage);
}
}
// Updated to accept userLang
private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
const config = setting.config as EmailConfig;
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
console.error(`[Notification] Email setting ID ${setting.id} is missing required SMTP configuration (to, smtpHost, smtpPort, from).`);
console.error(`[通知] 邮件设置 ID ${setting.id} 缺少必要的 SMTP 配置 (to, smtpHost, smtpPort, from)`);
return;
} // <-- Add missing closing brace here
}
// Let TypeScript infer the options type for SMTP
const transporterOptions = {
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure ?? true, // Default to true (TLS)
secure: config.smtpSecure ?? true,
auth: (config.smtpUser || config.smtpPass) ? {
user: config.smtpUser,
pass: config.smtpPass, // Ensure password is included if user is present
pass: config.smtpPass,
} : undefined,
// tls: { rejectUnauthorized: false } // Add if needed for self-signed certs, USE WITH CAUTION
};
const transporter = nodemailer.createTransport(transporterOptions);
// Translate subject and body using i18next
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
const i18nOptions: Record<string, any> = { lng: userLang };
if (payload.details && typeof payload.details === 'object') {
Object.assign(i18nOptions, payload.details); // Merge details if it's an object
Object.assign(i18nOptions, payload.details);
} else if (payload.details !== undefined) {
i18nOptions.details = payload.details; // Pass non-object details directly if needed
i18nOptions.details = payload.details;
}
// Translate event display name first
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event });
// Try to translate the event itself for the subject, fallback to event name
const defaultSubjectKey = `event.${payload.event}`; // This key might not exist, rely on template or default below
const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`; // Use eventDisplay in fallback
const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName }); // Pass eventDisplay for interpolation in fallback
const defaultSubjectKey = `event.${payload.event}`;
const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`;
const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName });
// Use default subject template from i18n if user hasn't provided one
const defaultSubjectTemplateKey = 'testNotification.subject'; // Reuse test subject key structure
const defaultSubjectTemplateKey = 'testNotification.subject';
const defaultSubjectTemplate = i18next.t(defaultSubjectTemplateKey, { lng: userLang, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName });
// Render the subject template, passing the translated event display name
const subject = this._renderTemplate(config.subjectTemplate || defaultSubjectTemplate, payload, subjectText, eventDisplayName);
// Translate the main body content based on event type if a key exists
const bodyKey = `eventBody.${payload.event}`;
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
// Use eventDisplay in the default body text
const defaultBodyText = `Event: ${eventDisplayName}\nTimestamp: ${new Date(payload.timestamp).toISOString()}\nDetails:\n${detailsString}`;
// Pass eventDisplay for interpolation if the translation key uses it
const body = i18next.t(bodyKey, { ...i18nOptions, defaultValue: defaultBodyText, eventDisplay: eventDisplayName });
// Note: Email body templates are not implemented. Using translated/default text.
// If templates were implemented, we'd use _renderTemplate here too.
const mailOptions: Mail.Options = {
from: config.from,
to: config.to,
subject: subject,
text: body,
// html: `<p>${body.replace(/\n/g, '<br>')}</p>` // Simple HTML version
};
try {
console.log(`[Notification] Sending Email via ${config.smtpHost}:${config.smtpPort} to ${config.to} for event ${payload.event}`);
console.log(`[通知] 通过 ${config.smtpHost}:${config.smtpPort} 发送邮件至 ${config.to} (事件: ${payload.event})`);
const info = await transporter.sendMail(mailOptions);
console.log(`[Notification] Email sent successfully to ${config.to} for setting ID ${setting.id}. Message ID: ${info.messageId}`);
console.log(`[通知] 邮件成功发送至 ${config.to} (设置 ID: ${setting.id})。消息 ID: ${info.messageId}`);
} catch (error: any) {
console.error(`[Notification] Error sending email for setting ID ${setting.id} via ${config.smtpHost}:`, error);
console.error(`[通知] 通过 ${config.smtpHost} 发送邮件 (设置 ID: ${setting.id}) 时出错:`, error);
}
}
// Updated to accept userLang
private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
const config = setting.config as TelegramConfig;
if (!config.botToken || !config.chatId) {
console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`);
console.error(`[通知] Telegram 设置 ID ${setting.id} 缺少 botToken chatId`);
return;
}
// Translate message using i18next
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
const i18nOptions: Record<string, any> = { lng: userLang };
if (payload.details && typeof payload.details === 'object') {
Object.assign(i18nOptions, payload.details); // Merge details if it's an object
Object.assign(i18nOptions, payload.details);
} else if (payload.details !== undefined) {
i18nOptions.details = payload.details; // Pass non-object details directly if needed
i18nOptions.details = payload.details;
}
// Translate event display name first
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event });
const messageKey = `eventBody.${payload.event}`; // Use same key as email body for consistency
const messageKey = `eventBody.${payload.event}`;
const detailsStr = payload.details ? `\nDetails: \`\`\`\n${typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details, null, 2)}\n\`\`\`` : '';
// Use eventDisplay in the default message template fallback
const defaultMessageTemplateFallback = `*Nexus Terminal Notification*\n\nEvent: \`{eventDisplay}\`\nTimestamp: {timestamp}${detailsStr}`;
// Pass eventDisplay for interpolation if the translation key uses it
const translatedBody = i18next.t(messageKey, { ...i18nOptions, defaultValue: defaultMessageTemplateFallback, eventDisplay: eventDisplayName });
// Get the default template from i18n (using the test key structure)
const defaultTemplateKey = `notifications:${testTelegramBodyTemplateKey}`;
const defaultMessageTemplateFromI18n = i18next.t(defaultTemplateKey, { lng: userLang, defaultValue: translatedBody, eventDisplay: eventDisplayName });
// Allow template override, use default template from i18n if user input is empty
// Pass eventDisplayName to renderTemplate
const messageText = this._renderTemplate(config.messageTemplate || defaultMessageTemplateFromI18n, payload, translatedBody, eventDisplayName);
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
try {
console.log(`[Notification] Sending Telegram message to chat ID ${config.chatId} for event ${payload.event}`);
console.log(`[通知] 发送 Telegram 消息到聊天 ID ${config.chatId} (事件: ${payload.event})`);
const response = await axios.post(telegramApiUrl, {
chat_id: config.chatId,
text: messageText,
parse_mode: 'Markdown', // Keep Markdown for actual sending, user is responsible for valid syntax
}, { timeout: 10000 }); // Add timeout
console.log(`[Notification] Telegram message sent successfully. Response OK:`, response.data?.ok);
parse_mode: 'Markdown',
}, { timeout: 10000 });
console.log(`[通知] Telegram 消息发送成功。响应 OK:`, response.data?.ok);
} catch (error: any) {
const errorMessage = error.response?.data?.description || error.response?.data || error.message;
console.error(`[Notification] Error sending Telegram message for setting ID ${setting.id}:`, errorMessage);
console.error(`[通知] 发送 Telegram 消息 (设置 ID: ${setting.id}) 时出错:`, errorMessage);
}
}
// Helper to attempt translation of known payload structures
private _translatePayloadDetails(details: any, lng: string): any {
if (!details || typeof details !== 'object') {
return details; // Return as is if not an object or null/undefined
return details;
}
// Example: Translate connection test results
if (details.testResult === 'success' && details.connectionName) {
return {
...details,
@@ -550,21 +458,14 @@ export class NotificationService {
};
}
// Example: Translate settings update messages (can be expanded)
if (details.updatedKeys && Array.isArray(details.updatedKeys)) {
if (details.updatedKeys.includes('ipWhitelist')) {
return { ...details, message: i18next.t('settings.ipWhitelistUpdated', { lng, defaultValue: 'IP Whitelist updated successfully.' }) };
}
// Generic settings update
return { ...details, message: i18next.t('settings.updated', { lng, defaultValue: 'Settings updated successfully.' }) };
}
// Add more translation logic for other event details structures here...
return details; // Return original details if no specific translation logic matched
return details;
}
}
// Optional: Export a singleton instance if needed throughout the backend
// export const notificationService = new NotificationService();
@@ -13,13 +13,11 @@ import type {
VerifyAuthenticationResponseOpts,
RegistrationResponseJSON,
AuthenticationResponseJSON,
// AuthenticatorDevice is not typically needed here
} from '@simplewebauthn/server'; // Import types directly from the package
} from '@simplewebauthn/server';
import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository';
import { settingsService } from './settings.service'; // Import the exported object
// 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置
// TODO: 从 SettingsService 或环境变量获取这些值
const rpName = 'Nexus Terminal';
// 重要: rpID 应该是你的网站域名 (不包含协议和端口)
// 对于本地开发,通常是 'localhost'
@@ -29,13 +27,11 @@ const expectedOrigin = process.env.FRONTEND_URL || 'http://localhost:5173'; //
export class PasskeyService {
private passkeyRepository: PasskeyRepository;
// No need to instantiate settingsService if it's an object export
// private settingsService: typeof settingsService; // Use typeof for the object type
constructor() {
this.passkeyRepository = new PasskeyRepository();
// this.settingsService = settingsService; // Assign the imported object if needed
// TODO: Load rpID, rpName, expectedOrigin using settingsService.getSetting()
}
/**
@@ -43,33 +39,24 @@ export class PasskeyService {
*/
async generateRegistrationOptions(userName: string = 'nexus-user') { // WebAuthn 需要一个用户名
// 暂时不获取已存在的凭证,允许同一用户注册多个设备
// const existingCredentials = await this.passkeyRepository.getAllPasskeys();
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: Buffer.from(userName), // userID should be a Buffer/Uint8Array
userName: userName,
// 不建议排除已存在的凭证,除非有特定原因
// excludeCredentials: existingCredentials.map(cred => ({
// id: cred.credential_id, // 需要是 Base64URL 格式,存储时确保是这个格式
// type: 'public-key',
// transports: cred.transports ? JSON.parse(cred.transports) : undefined,
// })),
authenticatorSelection: {
// authenticatorAttachment: 'platform', // 倾向于平台认证器 (如 Windows Hello, Touch ID)
userVerification: 'preferred', // 倾向于需要用户验证 (PIN, 生物识别)
residentKey: 'preferred', // 倾向于创建可发现凭证 (存储在认证器上)
},
// 可选:增加超时时间
timeout: 60000, // 60 秒
// attestation: 'none', // Temporarily remove to resolve TS error, 'none' is often default
};
const registrationOptions = await generateRegistrationOptions(options);
// TODO: 需要将生成的 challenge 临时存储起来 (例如在 session 或 内存缓存中),以便后续验证
// 这里暂时返回 challenge,让 Controller 处理存储
return registrationOptions;
}
@@ -105,15 +92,10 @@ export class PasskeyService {
if (verification.verified && verification.registrationInfo) {
// Use type assertion to bypass strict type checking for registrationInfo properties
const registrationInfo = verification.registrationInfo as any;
const { credentialPublicKey, credentialID, counter } = registrationInfo;
// Optional: Access other potential properties if needed
// const { credentialDeviceType, credentialBackedUp } = registrationInfo;
// 将公钥和 ID 转换为 Base64URL 字符串存储 (如果它们还不是)
// @simplewebauthn/server 返回的是 Buffer,需要转换
const credentialIdBase64Url = Buffer.from(credentialID).toString('base64url');
const publicKeyBase64Url = Buffer.from(credentialPublicKey).toString('base64url');
@@ -140,16 +122,11 @@ export class PasskeyService {
* 生成 Passkey 认证选项 (挑战)
*/
async generateAuthenticationOptions(): Promise<ReturnType<typeof generateAuthenticationOptions>> {
// 可选:可以只允许已注册的凭证进行认证
// const allowedCredentials = (await this.passkeyRepository.getAllPasskeys()).map(cred => ({
// id: cred.credential_id, // 确保是 Base64URL 格式
// type: 'public-key',
// transports: cred.transports ? JSON.parse(cred.transports) : undefined,
// }));
const options: GenerateAuthenticationOptionsOpts = {
rpID,
// allowCredentials: allowedCredentials, // 如果只想允许已注册的凭证
userVerification: 'preferred', // 倾向于需要用户验证
timeout: 60000, // 60 秒
};
@@ -178,45 +155,31 @@ export class PasskeyService {
throw new Error(`未找到 Credential ID 为 ${credentialIdBase64Url} 的认证器`);
}
// 将存储的公钥从 Base64URL 转回 Buffer
// const authenticatorPublicKeyBuffer = Buffer.from(authenticator.public_key, 'base64url'); // Moved lookup after verification
// Prepare the verification options object - authenticator is looked up internally by the library
// based on the response's credential ID, or requires allowCredentials
const verificationOptions: VerifyAuthenticationResponseOpts = {
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: expectedOrigin,
expectedRPID: rpID,
// We need to provide a way for the library to get the authenticator details.
// Option 1: Provide `allowCredentials` (if known beforehand)
// Option 2: Let the library handle it (requires authenticator to be discoverable/resident key)
// Option 3 (Most robust): Provide the authenticator directly after fetching it.
// The library likely uses the credential ID from the response to find the authenticator,
// especially with discoverable credentials, or requires `allowCredentials`.
// Re-adding the authenticator property based on the new error message,
// ensuring the structure matches what the library likely expects.
authenticator: {
credentialID: Buffer.from(authenticator.credential_id, 'base64url'),
credentialPublicKey: Buffer.from(authenticator.public_key, 'base64url'),
counter: authenticator.counter,
transports: authenticator.transports ? JSON.parse(authenticator.transports) : undefined,
},
requireUserVerification: true, // simplewebauthn defaults this to true now
} as any; // Use type assertion to bypass strict property check for 'authenticator'
requireUserVerification: true,
} as any;
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse(verificationOptions);
} catch (error: any) {
// If verification fails, log the error but potentially re-throw a more generic one
console.error('Passkey 认证验证时发生异常:', error);
const err = error as Error;
// Check if the error is due to the authenticator not being found (already handled)
if (!err.message.includes(credentialIdBase64Url)) {
throw new Error(`Passkey authentication verification failed: ${err.message || err}`);
}
// If error is related to authenticator not found, rethrow the original specific error
throw error;
}
+28 -36
View File
@@ -1,23 +1,20 @@
import * as ProxyRepository from '../repositories/proxy.repository';
import { encrypt, decrypt } from '../utils/crypto'; // Assuming crypto utils are needed
import { encrypt, decrypt } from '../utils/crypto';
// Re-export or define types (ideally from a shared types file)
export interface ProxyData extends ProxyRepository.ProxyData {}
// Input type for creating a proxy
export interface CreateProxyInput {
name: string;
type: 'SOCKS5' | 'HTTP';
host: string;
port: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key'; // Optional, defaults to 'none'
password?: string | null; // Plain text password
private_key?: string | null; // Plain text private key
passphrase?: string | null; // Plain text passphrase
auth_method?: 'none' | 'password' | 'key';
password?: string | null;
private_key?: string | null;
passphrase?: string | null;
}
// Input type for updating a proxy
export interface UpdateProxyInput {
name?: string;
type?: 'SOCKS5' | 'HTTP';
@@ -25,9 +22,9 @@ export interface UpdateProxyInput {
port?: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key';
password?: string | null; // Use undefined for no change, null/empty to clear
private_key?: string | null; // Use undefined for no change, null/empty to clear
passphrase?: string | null; // Use undefined for no change, null/empty to clear
password?: string | null;
private_key?: string | null;
passphrase?: string | null;
}
@@ -35,8 +32,6 @@ export interface UpdateProxyInput {
* 获取所有代理
*/
export const getAllProxies = async (): Promise<ProxyData[]> => {
// Repository returns data with encrypted fields, which is fine for listing generally
// If decryption is needed for display, it should happen closer to the presentation layer or selectively
return ProxyRepository.findAllProxies();
};
@@ -44,7 +39,6 @@ export const getAllProxies = async (): Promise<ProxyData[]> => {
* 根据 ID 获取单个代理
*/
export const getProxyById = async (id: number): Promise<ProxyData | null> => {
// Repository returns data with encrypted fields
return ProxyRepository.findProxyById(id);
};
@@ -52,7 +46,7 @@ export const getProxyById = async (id: number): Promise<ProxyData | null> => {
* 创建新代理
*/
export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> => {
// 1. Validate input
// 1. 验证输入
if (!input.name || !input.type || !input.host || !input.port) {
throw new Error('缺少必要的代理信息 (name, type, host, port)。');
}
@@ -62,14 +56,13 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
if (input.auth_method === 'key' && !input.private_key) {
throw new Error('代理密钥认证方式需要提供 private_key。');
}
// Add more validation (port range, type check etc.)
// 2. Encrypt credentials if provided
// 2. 如果提供,则加密凭证
const encryptedPassword = input.password ? encrypt(input.password) : null;
const encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null;
const encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null;
// 3. Prepare data for repository
// 3. 准备仓库数据
const proxyData: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'> = {
name: input.name,
type: input.type,
@@ -82,10 +75,10 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
encrypted_passphrase: encryptedPassphrase,
};
// 4. Create proxy record
// 4. 创建代理记录
const newProxyId = await ProxyRepository.createProxy(proxyData);
// 5. Fetch and return the newly created proxy
// 5. 获取并返回新创建的代理
const newProxy = await getProxyById(newProxyId);
if (!newProxy) {
throw new Error('创建代理后无法检索到该代理。');
@@ -97,62 +90,62 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
* 更新代理信息
*/
export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<ProxyData | null> => {
// 1. Fetch current proxy data to compare if needed (e.g., for auth method change logic)
// 1. 获取当前代理数据以进行比较(例如,用于认证方法更改逻辑)
const currentProxy = await ProxyRepository.findProxyById(id);
if (!currentProxy) {
return null; // Proxy not found
return null; // 未找到代理
}
// 2. Prepare data for update
// 2. 准备更新数据
const dataToUpdate: Partial<Omit<ProxyData, 'id' | 'created_at'>> = {};
let needsCredentialUpdate = false;
const newAuthMethod = input.auth_method || currentProxy.auth_method;
// Update standard fields
// 更新标准字段
if (input.name !== undefined) dataToUpdate.name = input.name;
if (input.type !== undefined) dataToUpdate.type = input.type;
if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username; // Allows clearing
if (input.username !== undefined) dataToUpdate.username = input.username; // 允许清除
// Handle auth method change or credential update
// 处理认证方法更改或凭证更新
if (input.auth_method && input.auth_method !== currentProxy.auth_method) {
dataToUpdate.auth_method = input.auth_method;
needsCredentialUpdate = true;
// Encrypt new credentials based on the *new* auth_method
// 根据 *新* 认证方法加密新凭证
if (input.auth_method === 'password') {
if (input.password === undefined) throw new Error('切换到密码认证时需要提供 password。');
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
dataToUpdate.encrypted_private_key = null; // Clear old key info
dataToUpdate.encrypted_private_key = null; // 清除旧密钥信息
dataToUpdate.encrypted_passphrase = null;
} else if (input.auth_method === 'key') {
if (input.private_key === undefined) throw new Error('切换到密钥认证时需要提供 private_key。');
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
dataToUpdate.encrypted_password = null; // Clear old password info
} else { // 'none'
dataToUpdate.encrypted_password = null; // 清除旧密码信息
} else { // ''
dataToUpdate.encrypted_password = null;
dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null;
}
} else {
// Auth method unchanged, update credentials if provided for the current method
// 认证方法未更改,如果为当前方法提供了凭证,则更新凭证
if (newAuthMethod === 'password' && input.password !== undefined) {
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
needsCredentialUpdate = true;
} else if (newAuthMethod === 'key') {
if (input.private_key !== undefined) {
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; // Update passphrase together
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; // 一起更新密码短语
needsCredentialUpdate = true;
} else if (input.passphrase !== undefined) { // Only passphrase updated
} else if (input.passphrase !== undefined) { // 仅更新密码短语
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true;
}
}
}
// 3. Update proxy record if there are changes
// 3. 如果有更改,则更新代理记录
const hasChanges = Object.keys(dataToUpdate).length > 0;
if (hasChanges) {
const updated = await ProxyRepository.updateProxy(id, dataToUpdate);
@@ -161,7 +154,7 @@ export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<
}
}
// 4. Fetch and return the updated proxy
// 4. 获取并返回更新后的代理
return getProxyById(id);
};
@@ -169,6 +162,5 @@ export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<
* 删除代理
*/
export const deleteProxy = async (id: number): Promise<boolean> => {
// Repository handles setting foreign keys to NULL in connections table
return ProxyRepository.deleteProxy(id);
};
@@ -3,16 +3,16 @@ import {
Setting,
getSidebarConfig as getSidebarConfigFromRepo,
setSidebarConfig as setSidebarConfigInRepo,
getCaptchaConfig as getCaptchaConfigFromRepo, // <-- Import CAPTCHA repo getter
setCaptchaConfig as setCaptchaConfigInRepo, // <-- Import CAPTCHA repo setter
getCaptchaConfig as getCaptchaConfigFromRepo,
setCaptchaConfig as setCaptchaConfigInRepo,
} from '../repositories/settings.repository';
import {
SidebarConfig,
PaneName,
UpdateSidebarConfigDto,
CaptchaSettings, // <-- Import CAPTCHA types
UpdateCaptchaSettingsDto, // <-- Import CAPTCHA types
CaptchaProvider, // <-- Import CAPTCHA types
CaptchaSettings,
UpdateCaptchaSettingsDto,
CaptchaProvider,
} from '../types/settings.types';
// +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++
@@ -128,8 +128,6 @@ export const settingsService = {
Object.values(config.shortcuts).every((sc: any) => typeof sc === 'object' && sc !== null && (sc.shortcut === undefined || typeof sc.shortcut === 'string'))
) {
console.log('[Service] Fetched and validated full focus switcher config:', JSON.stringify(config));
// TODO: 可能需要进一步验证 sequence 中的 id 是否仍然有效 (存在于某个地方定义的可用 ID 列表)
// TODO: 可能需要进一步验证 shortcuts 中的 key 是否是有效的 ID
return config as FocusSwitcherFullConfig;
} else {
console.warn('[Service] Invalid full focus switcher config format found in settings. Returning default.');
+1 -11
View File
@@ -3,7 +3,7 @@ import { SocksClient, SocksClientOptions } from 'socks';
import http from 'http';
import net from 'net';
import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.repository'; // 引入 ProxyRepository
import * as ProxyRepository from '../repositories/proxy.repository';
import { decrypt } from '../utils/crypto';
const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒)
@@ -123,7 +123,6 @@ export const establishSshConnection = (
console.log(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 成功。`);
sshClient.removeListener('error', errorHandler); // 成功后移除错误监听器
// --- 新增:更新 last_connected_at ---
try {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connDetails.id, currentTimeSeconds);
@@ -132,7 +131,6 @@ export const establishSshConnection = (
// 更新失败不应阻止连接成功,但需要记录错误
console.error(`SshService: 更新连接 ${connDetails.id} 的 last_connected_at 失败:`, updateError);
}
// --- 结束新增 ---
resolve(sshClient); // 返回 Client 实例
};
@@ -354,11 +352,3 @@ export const testUnsavedConnection = async (connectionConfig: {
}
};
// --- 移除旧的函数 ---
// - connectAndOpenShell
// - sendInput
// - resizeTerminal
// - cleanupConnection
// - activeSessions Map
// - AuthenticatedWebSocket interface (如果仅在此文件使用)
@@ -1,9 +1,9 @@
import { Client } from 'ssh2';
import { WebSocket } from 'ws';
import { ClientState } from '../websocket'; // 导入统一的 ClientState
import { settingsService } from './settings.service'; // +++ 导入 settingsService +++
import { ClientState } from '../websocket';
import { settingsService } from './settings.service';
// 定义服务器状态的数据结构 (与前端 StatusMonitor.vue 匹配)
interface ServerStatus {
cpuPercent?: number;
memPercent?: number;
@@ -24,7 +24,7 @@ interface ServerStatus {
timestamp: number; // 状态获取时间戳
}
// Interface for parsed network stats
interface NetworkStats {
[interfaceName: string]: {
rx_bytes: number;
@@ -32,7 +32,7 @@ interface NetworkStats {
}
}
// const DEFAULT_POLLING_INTERVAL = 3000; // --- 移除常量,将从 settingsService 获取 ---
// 用于存储上一次的网络统计信息以计算速率
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
@@ -46,16 +46,14 @@ export class StatusMonitorService {
/**
* 启动指定会话的状态轮询
* @param sessionId 会话 ID
* @param interval 轮询间隔 (毫秒),可选,默认为 DEFAULT_POLLING_INTERVAL // --- 参数移除 ---
* @param interval 轮询间隔 (毫秒),可选,默认为 DEFAULT_POLLING_INTERVAL
*/
async startStatusPolling(sessionId: string): Promise<void> { // --- 改为 async, 移除 interval 参数 ---
async startStatusPolling(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient) {
//console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`);
return;
}
if (state.statusIntervalId) {
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
return;
}
@@ -70,7 +68,6 @@ export class StatusMonitorService {
intervalMs = 3000; // 出错时回退到 3 秒
}
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${intervalMs}ms`);
// 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间
state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId);
@@ -130,35 +127,31 @@ export class StatusMonitorService {
const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release');
const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m);
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
} catch (err) { }
// --- CPU Model (Try /proc/cpuinfo first, fallback to lscpu) ---
try {
let cpuModelOutput = '';
try {
// Try /proc/cpuinfo first, common on many systems including Alpine
cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1");
status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim();
} catch (procErr) {
// console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model from /proc/cpuinfo, trying lscpu...`, procErr); // --- 移除 console.warn ---
// Fallback to lscpu if /proc/cpuinfo fails
try {
cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim();
} catch (lscpuErr) {
// console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model from lscpu as well:`, lscpuErr); // --- 移除 console.warn ---
}
}
// If still no model found after both attempts
if (!status.cpuModel) {
status.cpuModel = 'Unknown';
}
} catch (err) { // Catch any unexpected error during the process
// console.warn(`[StatusMonitor ${sessionId}] Error getting CPU model:`, err); // --- 移除 console.warn ---
} catch (err) {
status.cpuModel = 'Unknown';
}
// --- Memory and Swap ---
try {
const freeOutput = await this.executeSshCommand(sshClient, 'free -m');
const lines = freeOutput.split('\n');
@@ -186,17 +179,15 @@ export class StatusMonitorService {
}
}
} else { status.swapTotal = 0; status.swapUsed = 0; status.swapPercent = 0; }
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
} catch (err) { /* 静默处理 */ }
// --- Disk Usage (Root Partition, POSIX format for compatibility) ---
try {
// 使用 df -kP / 获取 POSIX 标准格式输出,更稳定
const dfOutput = await this.executeSshCommand(sshClient, "df -kP /");
const lines = dfOutput.split('\n');
if (lines.length >= 2) {
const parts = lines[1].split(/\s+/); // 解析第二行 (数据行)
// POSIX 格式: Filesystem 1024-blocks Used Available Capacity Mounted on
// parts[1]=Total(KB), parts[2]=Used(KB), parts[4]=Capacity(%)
const parts = lines[1].split(/\s+/);
if (parts.length >= 5) {
const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
@@ -207,9 +198,8 @@ export class StatusMonitorService {
}
}
}
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
} catch (err) { /* 静默处理 */ }
// --- CPU Usage (Simplified from top) ---
try {
const topOutput = await this.executeSshCommand(sshClient, "top -bn1 | grep '%Cpu(s)' | head -n 1");
const idleMatch = topOutput.match(/(\d+\.?\d*)\s+id/); // Adjusted regex for float
@@ -217,16 +207,15 @@ export class StatusMonitorService {
const idlePercent = parseFloat(idleMatch[1]);
status.cpuPercent = parseFloat((100 - idlePercent).toFixed(1));
}
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
} catch (err) { /* 静默处理 */ } //
// --- Load Average ---
try {
const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime');
const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/);
if (match) status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])];
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
} catch (err) { /* 静默处理 */ }
// --- Network Rates ---
try {
const currentStats = await this.parseProcNetDev(sshClient);
if (currentStats) {
@@ -238,18 +227,18 @@ export class StatusMonitorService {
const currentTx = currentStats[defaultInterface].tx_bytes;
const prevStats = previousNetStats.get(sessionId);
if (prevStats && prevStats.timestamp < timestamp) { // Ensure time has passed
if (prevStats && prevStats.timestamp < timestamp) {
const timeDiffSeconds = (timestamp - prevStats.timestamp) / 1000;
if (timeDiffSeconds > 0.1) { // Avoid division by zero or tiny intervals
if (timeDiffSeconds > 0.1) {
status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds));
status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds));
} else { status.netRxRate = 0; status.netTxRate = 0; } // Rate is 0 if interval too small
} else { status.netRxRate = 0; status.netTxRate = 0; } // First run or no time diff
} else { status.netRxRate = 0; status.netTxRate = 0; }
} else { status.netRxRate = 0; status.netTxRate = 0; }
previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp });
} else { /* 静默处理 */ } // --- 移除 console.warn ---
} else { /* 静默处理 */ }
}
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
} catch (err) { /* 静默处理 */ }
} catch (error) {
console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error);
@@ -270,7 +259,7 @@ export class StatusMonitorService {
output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
} catch (error) {
// 如果命令失败,记录警告并返回 null
// console.warn("[StatusMonitor] Failed to execute 'cat /proc/net/dev':", error); // --- 移除 console.warn ---
return null;
}
// 如果命令成功,继续解析
@@ -279,18 +268,16 @@ export class StatusMonitorService {
const stats: NetworkStats = {};
for (const line of lines) {
const parts = line.trim().split(/:\s+|\s+/);
if (parts.length < 17) continue; // Need at least interface name + 16 stats
if (parts.length < 17) continue;
const interfaceName = parts[0];
const rx_bytes = parseInt(parts[1], 10);
const tx_bytes = parseInt(parts[9], 10); // TX bytes is the 10th field (index 9)
const tx_bytes = parseInt(parts[9], 10);
if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) {
stats[interfaceName] = { rx_bytes, tx_bytes };
}
}
return Object.keys(stats).length > 0 ? stats : null;
} catch (parseError) {
// 如果解析失败,记录错误并返回 null
// console.error("[StatusMonitor] Error parsing /proc/net/dev output:", parseError); // --- 移除 console.error ---
return null;
}
}
@@ -307,11 +294,10 @@ export class StatusMonitorService {
const interfaceName = output.trim();
if (interfaceName) return interfaceName;
// 如果 ip route 没返回有效接口名,也尝试 fallback
// console.warn("[StatusMonitor] 'ip route' did not return a valid interface name. Falling back..."); // --- 移除 console.warn ---
} catch (error) {
// console.warn("[StatusMonitor] Failed to get default interface using 'ip route', falling back:", error); // --- 移除 console.warn ---
// Fallback: 尝试查找第一个非 lo 接口
try {
const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
const lines = netDevOutput.split('\n').slice(2);
@@ -322,13 +308,12 @@ export class StatusMonitorService {
}
}
} catch (fallbackError) {
// console.error("[StatusMonitor] Failed to fallback to /proc/net/dev for interface:", fallbackError); // --- 移除 console.error ---
}
// Ensure null is returned if both primary and fallback fail within the outer catch
return null;
}
// This part should ideally not be reached if the first try succeeded or the catch block returned.
// Adding a final return null for safety and to satisfy TS if logic paths are complex.
return null;
}
@@ -347,16 +332,10 @@ export class StatusMonitorService {
return reject(new Error(`执行命令 '${command}' 失败: ${err.message}`));
}
stream.on('close', (code: number, signal?: string) => {
// Don't reject on non-zero exit code, as some commands might return non-zero normally
// if (code !== 0) {
// //console.warn(`[StatusMonitor] Command '${command}' exited with code ${code}`);
// }
resolve(output.trim());
}).on('data', (data: Buffer) => {
output += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => {
// --- 移除 console.warn ---
// console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`);
});
});
});
+8 -10
View File
@@ -21,16 +21,15 @@ export const getTagById = async (id: number): Promise<TagData | null> => {
* 创建新标签
*/
export const createTag = async (name: string): Promise<TagData> => {
// 1. Validate input
if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空。');
}
const trimmedName = name.trim();
// 2. Create tag record
try {
const newTagId = await TagRepository.createTag(trimmedName);
// 3. Fetch and return the newly created tag
const newTag = await getTagById(newTagId);
if (!newTag) {
throw new Error('创建标签后无法检索到该标签。');
@@ -40,7 +39,7 @@ export const createTag = async (name: string): Promise<TagData> => {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`创建标签失败:标签名称 "${trimmedName}" 已存在。`);
}
throw error; // Re-throw other errors
throw error;
}
};
@@ -48,25 +47,25 @@ export const createTag = async (name: string): Promise<TagData> => {
* 更新标签名称
*/
export const updateTag = async (id: number, name: string): Promise<TagData | null> => {
// 1. Validate input
if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空。');
}
const trimmedName = name.trim();
// 2. Update tag record
try {
const updated = await TagRepository.updateTag(id, trimmedName);
if (!updated) {
return null; // Tag not found or not updated
return null;
}
// 3. Fetch and return the updated tag
return getTagById(id);
} catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`更新标签失败:标签名称 "${trimmedName}" 已存在。`);
}
throw error; // Re-throw other errors
throw error;
}
};
@@ -74,6 +73,5 @@ export const updateTag = async (id: number, name: string): Promise<TagData | nul
* 删除标签
*/
export const deleteTag = async (id: number): Promise<boolean> => {
// Repository handles cascading deletes in connection_tags
return TagRepository.deleteTag(id);
};
@@ -1,6 +1,5 @@
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
// import { validate } from 'class-validator'; // 移除导入
import type { ITheme } from 'xterm';
/**
@@ -85,4 +84,3 @@ export const importTheme = async (themeData: ITheme, name: string): Promise<Term
return createNewTheme(dto);
};
// 注意:导出功能通常在 Controller 层处理,根据 ID 获取主题数据后,设置响应头并发送 JSON 文件。