From c026a42d061b2dce85808f419a58ecc40f3dd193 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 15 Apr 2025 18:59:56 +0800 Subject: [PATCH] update --- package-lock.json | 1 + packages/backend/package.json | 1 + .../backend/src/audit/audit.controller.ts | 68 +++ packages/backend/src/audit/audit.routes.ts | 14 + packages/backend/src/auth/auth.controller.ts | 83 +++- packages/backend/src/auth/auth.routes.ts | 10 +- .../src/auth/ipBlacklistCheck.middleware.ts | 37 ++ .../src/connections/connections.controller.ts | 17 + packages/backend/src/index.ts | 10 + packages/backend/src/migrations.ts | 161 +++++++ .../notifications/notification.controller.ts | 150 +++++++ .../src/notifications/notification.routes.ts | 20 + .../backend/src/proxies/proxies.controller.ts | 9 + .../src/repositories/audit.repository.ts | 112 +++++ .../src/repositories/connection.repository.ts | 1 - .../repositories/notification.repository.ts | 151 +++++++ .../backend/src/services/audit.service.ts | 51 +++ .../src/services/ip-blacklist.service.ts | 223 ++++++++++ .../src/services/notification.service.ts | 257 +++++++++++ packages/backend/src/services/sftp.service.ts | 59 ++- .../src/settings/settings.controller.ts | 48 +++ .../backend/src/settings/settings.routes.ts | 8 + packages/backend/src/tags/tags.controller.ts | 9 + packages/backend/src/types/audit.types.ts | 69 +++ .../backend/src/types/notification.types.ts | 77 ++++ packages/backend/src/websocket.ts | 210 +++++---- packages/data/nexus-terminal.db | Bin 49152 -> 110592 bytes packages/frontend/src/App.vue | 2 + .../frontend/src/components/FileManager.vue | 152 ++++++- .../components/NotificationSettingForm.vue | 398 ++++++++++++++++++ .../src/components/NotificationSettings.vue | 135 ++++++ .../src/composables/useSftpActions.ts | 10 +- packages/frontend/src/locales/en.json | 129 +++++- packages/frontend/src/locales/zh.json | 132 +++++- packages/frontend/src/router/index.ts | 12 + packages/frontend/src/stores/audit.store.ts | 58 +++ packages/frontend/src/stores/auth.store.ts | 70 ++- .../src/stores/notifications.store.ts | 116 +++++ packages/frontend/src/types/server.types.ts | 90 ++++ packages/frontend/src/views/AuditLogView.vue | 168 ++++++++ packages/frontend/src/views/LoginView.vue | 30 +- .../frontend/src/views/NotificationsView.vue | 17 + packages/frontend/src/views/SettingsView.vue | 273 +++++++++++- 43 files changed, 3479 insertions(+), 169 deletions(-) create mode 100644 packages/backend/src/audit/audit.controller.ts create mode 100644 packages/backend/src/audit/audit.routes.ts create mode 100644 packages/backend/src/auth/ipBlacklistCheck.middleware.ts create mode 100644 packages/backend/src/notifications/notification.controller.ts create mode 100644 packages/backend/src/notifications/notification.routes.ts create mode 100644 packages/backend/src/repositories/audit.repository.ts create mode 100644 packages/backend/src/repositories/notification.repository.ts create mode 100644 packages/backend/src/services/audit.service.ts create mode 100644 packages/backend/src/services/ip-blacklist.service.ts create mode 100644 packages/backend/src/services/notification.service.ts create mode 100644 packages/backend/src/types/audit.types.ts create mode 100644 packages/backend/src/types/notification.types.ts create mode 100644 packages/frontend/src/components/NotificationSettingForm.vue create mode 100644 packages/frontend/src/components/NotificationSettings.vue create mode 100644 packages/frontend/src/stores/audit.store.ts create mode 100644 packages/frontend/src/stores/notifications.store.ts create mode 100644 packages/frontend/src/views/AuditLogView.vue create mode 100644 packages/frontend/src/views/NotificationsView.vue diff --git a/package-lock.json b/package-lock.json index 4115cbb..256e091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6143,6 +6143,7 @@ "@simplewebauthn/server": "^13.1.1", "@types/multer": "^1.4.12", "@types/uuid": "^10.0.0", + "axios": "^1.8.4", "bcrypt": "^5.1.1", "connect-sqlite3": "^0.9.15", "express": "^5.1.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 8234763..ad27db6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,6 +12,7 @@ "@simplewebauthn/server": "^13.1.1", "@types/multer": "^1.4.12", "@types/uuid": "^10.0.0", + "axios": "^1.8.4", "bcrypt": "^5.1.1", "connect-sqlite3": "^0.9.15", "express": "^5.1.0", diff --git a/packages/backend/src/audit/audit.controller.ts b/packages/backend/src/audit/audit.controller.ts new file mode 100644 index 0000000..f33e588 --- /dev/null +++ b/packages/backend/src/audit/audit.controller.ts @@ -0,0 +1,68 @@ +import { Request, Response } from 'express'; +import { AuditLogService } from '../services/audit.service'; +import { AuditLogActionType } from '../types/audit.types'; + +const auditLogService = new AuditLogService(); + +export class AuditController { + /** + * 获取审计日志列表 (GET /api/v1/audit-logs) + * 支持分页和过滤查询参数: limit, offset, actionType, startDate, endDate + */ + async getAuditLogs(req: Request, res: Response): Promise { + try { + // 解析查询参数 + const limit = parseInt(req.query.limit as string || '50', 10); + const offset = parseInt(req.query.offset as string || '0', 10); + const actionType = req.query.actionType as AuditLogActionType | undefined; + const startDate = req.query.startDate ? parseInt(req.query.startDate as string, 10) : undefined; + const endDate = req.query.endDate ? parseInt(req.query.endDate as string, 10) : undefined; + + // 输入验证 (基本) + if (isNaN(limit) || limit <= 0) { + res.status(400).json({ message: '无效的 limit 参数' }); + return; + } + if (isNaN(offset) || offset < 0) { + res.status(400).json({ message: '无效的 offset 参数' }); + return; + } + if (startDate && isNaN(startDate)) { + res.status(400).json({ message: '无效的 startDate 参数' }); + return; + } + if (endDate && isNaN(endDate)) { + res.status(400).json({ message: '无效的 endDate 参数' }); + return; + } + // TODO: 可以添加对 actionType 是否有效的验证 + + const result = await auditLogService.getLogs(limit, offset, actionType, startDate, endDate); + + // 解析 details 字段从 JSON 字符串到对象(如果需要) + const logsWithParsedDetails = result.logs.map(log => { + let parsedDetails: any = null; + if (log.details) { + try { + parsedDetails = JSON.parse(log.details); + } catch (e) { + console.warn(`[Audit Log] Failed to parse details for log ID ${log.id}:`, e); + parsedDetails = { raw: log.details, parseError: true }; // 保留原始字符串并标记错误 + } + } + return { ...log, details: parsedDetails }; + }); + + + res.status(200).json({ + logs: logsWithParsedDetails, + total: result.total, + limit, + offset + }); + } catch (error: any) { + console.error('获取审计日志时出错:', error); + res.status(500).json({ message: '获取审计日志失败', error: error.message }); + } + } +} diff --git a/packages/backend/src/audit/audit.routes.ts b/packages/backend/src/audit/audit.routes.ts new file mode 100644 index 0000000..f020162 --- /dev/null +++ b/packages/backend/src/audit/audit.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { AuditController } from './audit.controller'; +import { isAuthenticated } from '../auth/auth.middleware'; // Use the correct auth middleware + +const router = Router(); +const auditController = new AuditController(); + +// Apply auth middleware to protect the audit log endpoint +router.use(isAuthenticated); + +// Define route for getting audit logs +router.get('/', auditController.getAuditLogs); + +export default router; diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 6a374a4..efc6d33 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -5,9 +5,14 @@ import sqlite3, { RunResult } from 'sqlite3'; import speakeasy from 'speakeasy'; import qrcode from 'qrcode'; import { PasskeyService } from '../services/passkey.service'; // 导入 PasskeyService +import { NotificationService } from '../services/notification.service'; // 导入 NotificationService +import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService +import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务 const db = getDb(); const passkeyService = new PasskeyService(); // 实例化 PasskeyService +const notificationService = new NotificationService(); // 实例化 NotificationService +const auditLogService = new AuditLogService(); // 实例化 AuditLogService // 用户数据结构占位符 (理想情况下应定义在共享的 types 文件中) interface User { @@ -26,6 +31,7 @@ declare module 'express-session' { tempTwoFactorSecret?: string; requiresTwoFactor?: boolean; currentChallenge?: string; // 用于存储 Passkey 操作的挑战 + rememberMe?: boolean; // 新增:临时存储“记住我”选项 } } @@ -34,7 +40,8 @@ declare module 'express-session' { * 处理用户登录请求 (POST /api/v1/auth/login) */ export const login = async (req: Request, res: Response): Promise => { - const { username, password } = req.body; + // 从请求体中解构 username, password 和可选的 rememberMe + const { username, password, rememberMe } = req.body; if (!username || !password) { res.status(400).json({ message: '用户名和密码不能为空。' }); @@ -55,6 +62,13 @@ export const login = async (req: Request, res: Response): Promise => { if (!user) { console.log(`登录尝试失败: 用户未找到 - ${username}`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录失败尝试 + ipBlacklistService.recordFailedAttempt(clientIp); + // 记录审计日志 (添加 IP) + auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp }); + // 发送登录失败通知 + notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp }); res.status(401).json({ message: '无效的凭据。' }); return; } @@ -63,6 +77,13 @@ export const login = async (req: Request, res: Response): Promise => { if (!isMatch) { console.log(`登录尝试失败: 密码错误 - ${username}`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录失败尝试 + ipBlacklistService.recordFailedAttempt(clientIp); + // 记录审计日志 (添加 IP) + auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); + // 发送登录失败通知 + notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); res.status(401).json({ message: '无效的凭据。' }); return; } @@ -73,13 +94,30 @@ export const login = async (req: Request, res: Response): Promise => { // 不设置完整 session,只标记需要 2FA req.session.userId = user.id; // 临时存储 userId 以便 2FA 验证 req.session.requiresTwoFactor = true; + req.session.rememberMe = rememberMe; // 临时存储 rememberMe 状态 res.status(200).json({ message: '需要进行两步验证。', requiresTwoFactor: true }); } else { // --- 认证成功 (未启用 2FA) --- console.log(`登录成功 (无 2FA): ${username}`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 重置失败尝试次数 + ipBlacklistService.resetAttempts(clientIp); + // 记录审计日志 (添加 IP) + auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp }); req.session.userId = user.id; req.session.username = user.username; req.session.requiresTwoFactor = false; // 明确标记不需要 2FA + + // 根据 rememberMe 设置 cookie maxAge + if (rememberMe) { + // 如果记住我,使用默认的 maxAge (在 index.ts 中设置,通常是 7 天) + // 如果需要强制覆盖为 7 天,取消下一行注释 + // req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7; + } else { + // 如果不记住我,设置为会话 cookie (浏览器关闭时过期) + req.session.cookie.maxAge = undefined; // 使用 undefined 表示会话 cookie + } + res.status(200).json({ message: '登录成功。', user: { id: user.id, username: user.username } @@ -183,15 +221,37 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise if (verified) { console.log(`用户 ${user.username} 2FA 验证成功。`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 重置失败尝试次数 + ipBlacklistService.resetAttempts(clientIp); + // 记录审计日志 (2FA 成功也算登录成功) (添加 IP) + auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true }); // 验证成功,建立完整会话 req.session.username = user.username; req.session.requiresTwoFactor = false; // 标记 2FA 已完成 + + // 根据之前存储在 session 中的 rememberMe 设置 cookie maxAge + if (req.session.rememberMe) { + // 如果记住我,使用默认的 maxAge + // req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7; + } else { + // 如果不记住我,设置为会话 cookie + req.session.cookie.maxAge = undefined; + } + // 清除临时的 rememberMe 状态 + delete req.session.rememberMe; + res.status(200).json({ message: '登录成功。', user: { id: user.id, username: user.username } }); } else { console.log(`用户 ${user.username} 2FA 验证失败: 验证码错误。`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录失败尝试 + ipBlacklistService.recordFailedAttempt(clientIp); + // 记录审计日志 (添加 IP) + auditLogService.logAction('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp }); res.status(401).json({ message: '验证码无效。' }); } @@ -268,9 +328,12 @@ export const changePassword = async (req: Request, res: Response): Promise } if (this.changes === 0) { console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`); - return rejectUpdate(new Error('未找到要更新的用户')); + return rejectUpdate(new Error('未找到要更新的用户')); } console.log(`用户 ${userId} 密码已成功修改。`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录审计日志 (添加 IP) + auditLogService.logAction('PASSWORD_CHANGED', { userId, ip: clientIp }); resolveUpdate(); }); stmt.finalize(); @@ -405,7 +468,13 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr name ); - if (verification.verified) { + // Check if verification was successful and registrationInfo is present + if (verification.verified && verification.registrationInfo) { + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录审计日志 (添加 IP) + // Use type assertion 'as any' to bypass persistent TS error for now + const regInfo: any = verification.registrationInfo; + auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp }); res.status(201).json({ message: 'Passkey 注册成功!', verified: true }); } else { console.error(`用户 ${userId} Passkey 注册验证失败:`, verification); @@ -463,9 +532,12 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise } if (this.changes === 0) { console.error(`激活 2FA 错误: 更新影响行数为 0 - 用户 ID ${userId}`); - return rejectUpdate(new Error('未找到要更新的用户')); + return rejectUpdate(new Error('未找到要更新的用户')); } console.log(`用户 ${userId} 已成功激活两步验证。`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录审计日志 (添加 IP) + auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp }); resolveUpdate(); }); stmt.finalize(); @@ -535,6 +607,9 @@ export const disable2FA = async (req: Request, res: Response): Promise => return rejectUpdate(new Error('未找到要更新的用户')); } console.log(`用户 ${userId} 已成功禁用两步验证。`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP + // 记录审计日志 (添加 IP) + auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp }); resolveUpdate(); }); stmt.finalize(); diff --git a/packages/backend/src/auth/auth.routes.ts b/packages/backend/src/auth/auth.routes.ts index 5b2db7c..a45ec6c 100644 --- a/packages/backend/src/auth/auth.routes.ts +++ b/packages/backend/src/auth/auth.routes.ts @@ -11,17 +11,19 @@ import { verifyPasskeyRegistration // 导入 Passkey 方法 } from './auth.controller'; import { isAuthenticated } from './auth.middleware'; +import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件 const router = Router(); -// POST /api/v1/auth/login - 用户登录接口 -router.post('/login', login); +// POST /api/v1/auth/login - 用户登录接口 (添加黑名单检查) +router.post('/login', ipBlacklistCheckMiddleware, login); // PUT /api/v1/auth/password - 修改密码接口 (需要认证) router.put('/password', isAuthenticated, changePassword); -// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session) -router.post('/login/2fa', verifyLogin2FA); +// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (添加黑名单检查) +// (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session) +router.post('/login/2fa', ipBlacklistCheckMiddleware, verifyLogin2FA); // --- 2FA 管理接口 (都需要认证) --- // POST /api/v1/auth/2fa/setup - 开始 2FA 设置,生成密钥和二维码 diff --git a/packages/backend/src/auth/ipBlacklistCheck.middleware.ts b/packages/backend/src/auth/ipBlacklistCheck.middleware.ts new file mode 100644 index 0000000..9e189be --- /dev/null +++ b/packages/backend/src/auth/ipBlacklistCheck.middleware.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction } from 'express'; +import { ipBlacklistService } from '../services/ip-blacklist.service'; + +/** + * IP 黑名单检查中间件 + * 在处理登录相关请求前,检查来源 IP 是否在黑名单中且处于封禁期。 + */ +export const ipBlacklistCheckMiddleware = async (req: Request, res: Response, next: NextFunction) => { + // 获取客户端 IP (与 auth.controller 一致) + const clientIp = req.ip || req.socket?.remoteAddress; + + if (!clientIp) { + // 如果无法获取 IP,为安全起见,阻止请求 + console.warn('[IP Blacklist Check] 无法获取请求 IP 地址,已拒绝访问。'); + res.status(403).json({ message: '禁止访问:无法识别来源 IP。' }); + return; // 显式返回 void + } + + try { + const isBlocked = await ipBlacklistService.isBlocked(clientIp); + if (isBlocked) { + console.warn(`[IP Blacklist Check] 已阻止来自被封禁 IP ${clientIp} 的访问。`); + // 可以返回更通用的错误信息,避免泄露封禁状态 + res.status(403).json({ message: '访问被拒绝。' }); + // 或者返回更具体的错误 + // res.status(429).json({ message: '尝试次数过多,请稍后再试。' }); + return; // 显式返回 void + } + // IP 未被封禁,继续处理请求 + next(); + } catch (error) { + console.error(`[IP Blacklist Check] 检查 IP ${clientIp} 时发生错误:`, error); + // 中间件执行出错,为安全起见,阻止请求 + res.status(500).json({ message: '服务器内部错误 (IP 黑名单检查失败)。' }); + return; // 显式返回 void + } +}; diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index a17fe5d..7506b29 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -3,6 +3,9 @@ import { Request, Response } from 'express'; import * as ConnectionService from '../services/connection.service'; import * as SshService from '../services/ssh.service'; // 引入 SshService import * as ImportExportService from '../services/import-export.service'; // 引入 ImportExportService +import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService + +const auditLogService = new AuditLogService(); // 实例化 AuditLogService // --- 移除所有不再需要的导入和变量 --- // import { Statement } from 'sqlite3'; @@ -33,6 +36,8 @@ export const createConnection = async (req: Request, res: Response): Promise await SshService.testConnection(connectionId); // 如果 SshService.testConnection 没有抛出错误,则表示成功 + // 记录审计日志 (可选,看是否需要记录测试操作) + // auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: true }); res.status(200).json({ success: true, message: '连接测试成功。' }); } catch (error: any) { + // 记录审计日志 (可选) + // auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: false, error: error.message }); console.error(`Controller: 测试连接 ${req.params.id} 时发生错误:`, error); // SshService 会抛出包含具体原因的 Error res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); @@ -182,6 +195,8 @@ export const exportConnections = async (req: Request, res: Response): Promise { diff --git a/packages/backend/src/migrations.ts b/packages/backend/src/migrations.ts index 18d06b9..71598ca 100644 --- a/packages/backend/src/migrations.ts +++ b/packages/backend/src/migrations.ts @@ -41,6 +41,99 @@ CREATE TABLE IF NOT EXISTS passkeys ( ); `; +const createNotificationSettingsTableSQL = ` +CREATE TABLE IF NOT EXISTS notification_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_type TEXT NOT NULL CHECK(channel_type IN ('webhook', 'email', 'telegram')), + name TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT false, + config TEXT NOT NULL DEFAULT '{}', -- JSON string for channel-specific config + enabled_events TEXT NOT NULL DEFAULT '[]', -- JSON array of event names + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +`; + +// --- 新增表结构定义 --- + +const createUsersTableSQL = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + hashed_password TEXT NOT NULL, + two_factor_secret TEXT NULL, -- 添加 2FA 密钥列,允许为空 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +`; + +const createProxiesTableSQL = ` +CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('SOCKS5', 'HTTP')), + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NULL, + auth_method TEXT NOT NULL DEFAULT 'none' CHECK(auth_method IN ('none', 'password', 'key')), + encrypted_password TEXT NULL, + encrypted_private_key TEXT NULL, + encrypted_passphrase TEXT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + UNIQUE(name, type, host, port) +); +`; + +const createConnectionsTableSQL = ` +CREATE TABLE IF NOT EXISTS connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), + encrypted_password TEXT NULL, + encrypted_private_key TEXT NULL, + encrypted_passphrase TEXT NULL, + proxy_id INTEGER NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + last_connected_at INTEGER NULL, + FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL +); +`; + +const createTagsTableSQL = ` +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +`; + +const createConnectionTagsTableSQL = ` +CREATE TABLE IF NOT EXISTS connection_tags ( + connection_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (connection_id, tag_id), + FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); +`; + +const createIpBlacklistTableSQL = ` +CREATE TABLE IF NOT EXISTS ip_blacklist ( + ip TEXT PRIMARY KEY NOT NULL, + attempts INTEGER NOT NULL DEFAULT 1, + last_attempt_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + blocked_until INTEGER NULL -- 封禁截止时间戳 (秒),NULL 表示未封禁或永久封禁 (根据逻辑决定) +); +`; +// --- 结束新增表结构定义 --- + + export const runMigrations = async (db: Database): Promise => { try { // 创建 settings 表 (如果不存在) @@ -96,6 +189,74 @@ export const runMigrations = async (db: Database): Promise => { }); }); + // 创建 notification_settings 表 (如果不存在) + await new Promise((resolve, reject) => { + db.run(createNotificationSettingsTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 notification_settings 表时出错: ${err.message}`)); + console.log('Notification_Settings 表已检查/创建。'); + resolve(); + }); + }); + + // --- 新增表创建逻辑 --- + + // 创建 users 表 + await new Promise((resolve, reject) => { + db.run(createUsersTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 users 表时出错: ${err.message}`)); + console.log('Users 表已检查/创建。'); + resolve(); + }); + }); + + // 创建 proxies 表 + await new Promise((resolve, reject) => { + db.run(createProxiesTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 proxies 表时出错: ${err.message}`)); + console.log('Proxies 表已检查/创建。'); + resolve(); + }); + }); + + // 创建 connections 表 (依赖 proxies) + await new Promise((resolve, reject) => { + db.run(createConnectionsTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 connections 表时出错: ${err.message}`)); + console.log('Connections 表已检查/创建。'); + resolve(); + }); + }); + + // 创建 tags 表 + await new Promise((resolve, reject) => { + db.run(createTagsTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 tags 表时出错: ${err.message}`)); + console.log('Tags 表已检查/创建。'); + resolve(); + }); + }); + + // 创建 connection_tags 表 (依赖 connections, tags) + await new Promise((resolve, reject) => { + db.run(createConnectionTagsTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 connection_tags 表时出错: ${err.message}`)); + console.log('Connection_Tags 表已检查/创建。'); + resolve(); + }); + }); + + // 创建 ip_blacklist 表 + await new Promise((resolve, reject) => { + db.run(createIpBlacklistTableSQL, (err: Error | null) => { + if (err) return reject(new Error(`创建 ip_blacklist 表时出错: ${err.message}`)); + console.log('Ip_Blacklist 表已检查/创建。'); + resolve(); + }); + }); + + // --- 结束新增表创建逻辑 --- + + console.log('所有数据库迁移已完成。'); } catch (error) { console.error('数据库迁移过程中出错:', error); diff --git a/packages/backend/src/notifications/notification.controller.ts b/packages/backend/src/notifications/notification.controller.ts new file mode 100644 index 0000000..f9570a5 --- /dev/null +++ b/packages/backend/src/notifications/notification.controller.ts @@ -0,0 +1,150 @@ +import { Request, Response } from 'express'; +import { NotificationService } from '../services/notification.service'; +import { NotificationSetting } from '../types/notification.types'; +import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService + +const auditLogService = new AuditLogService(); // 实例化 AuditLogService + +export class NotificationController { + private notificationService: NotificationService; + + constructor() { + this.notificationService = new NotificationService(); + } + + // GET /api/v1/notifications + getAll = async (req: Request, res: Response): Promise => { + try { + const settings = await this.notificationService.getAllSettings(); + res.status(200).json(settings); + } catch (error: any) { + console.error("Error fetching notification settings:", error); + res.status(500).json({ message: '获取通知设置失败', error: error.message }); + } + }; + + // POST /api/v1/notifications + create = async (req: Request, res: Response): Promise => { + const settingData: Omit = req.body; + + // Basic validation (more robust validation can be added) + if (!settingData.channel_type || !settingData.name || !settingData.config) { + res.status(400).json({ message: '缺少必要的通知设置字段 (channel_type, name, config)' }); + return; + } + + try { + const newSettingId = await this.notificationService.createSetting(settingData); + const newSetting = await this.notificationService.getSettingById(newSettingId); // Fetch the created setting to return it + // 记录审计日志 + if (newSetting) { + auditLogService.logAction('NOTIFICATION_SETTING_CREATED', { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type }); + } + res.status(201).json(newSetting); + } catch (error: any) { + console.error("Error creating notification setting:", error); + res.status(500).json({ message: '创建通知设置失败', error: error.message }); + } + }; + + // PUT /api/v1/notifications/:id + update = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + const settingData: Partial> = req.body; + + if (isNaN(id)) { + res.status(400).json({ message: '无效的通知设置 ID' }); + return; + } + if (Object.keys(settingData).length === 0) { + res.status(400).json({ message: '没有提供要更新的数据' }); + return; + } + + try { + const success = await this.notificationService.updateSetting(id, settingData); + if (success) { + const updatedSetting = await this.notificationService.getSettingById(id); + // 记录审计日志 + auditLogService.logAction('NOTIFICATION_SETTING_UPDATED', { settingId: id, updatedFields: Object.keys(settingData) }); + res.status(200).json(updatedSetting); + } else { + res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); + } + } catch (error: any) { + console.error(`Error updating notification setting ID ${id}:`, error); + res.status(500).json({ message: '更新通知设置失败', error: error.message }); + } + }; + + // DELETE /api/v1/notifications/:id + delete = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ message: '无效的通知设置 ID' }); + return; + } + + try { + const success = await this.notificationService.deleteSetting(id); + if (success) { + // 记录审计日志 + auditLogService.logAction('NOTIFICATION_SETTING_DELETED', { settingId: id }); + res.status(204).send(); // No Content + } else { + res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); + } + } catch (error: any) { + console.error(`Error deleting notification setting ID ${id}:`, error); + res.status(500).json({ message: '删除通知设置失败', error: error.message }); + } + }; + + // POST /api/v1/notifications/:id/test + testSetting = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + const { config } = req.body; // Expecting the config to test in the body + + if (isNaN(id)) { + res.status(400).json({ message: '无效的通知设置 ID' }); + return; + } + if (!config) { + res.status(400).json({ message: '缺少用于测试的配置信息' }); + return; + } + + try { + // Fetch the original setting to determine the channel type + const originalSetting = await this.notificationService.getSettingById(id); + if (!originalSetting) { + res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); + return; + } + + // Currently, only email testing is implemented + if (originalSetting.channel_type !== 'email') { + res.status(400).json({ message: `当前仅支持测试邮件通知渠道` }); + return; + } + + // Call the service method to send the test email using the provided config + const result = await this.notificationService.testEmailSetting(config); + + if (result.success) { + // 记录审计日志 (可选,根据需要决定是否记录测试操作) + // auditLogService.logAction('NOTIFICATION_SETTING_TESTED', { settingId: id, success: true }); + res.status(200).json({ message: result.message }); + } else { + // 记录审计日志 (可选) + // auditLogService.logAction('NOTIFICATION_SETTING_TESTED', { settingId: id, success: false, error: result.message }); + // Return 500 for test failure to indicate an issue with the config/sending + res.status(500).json({ message: result.message }); + } + } catch (error: any) { + console.error(`Error testing notification setting ID ${id}:`, error); + res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); + } + }; +} diff --git a/packages/backend/src/notifications/notification.routes.ts b/packages/backend/src/notifications/notification.routes.ts new file mode 100644 index 0000000..3c621fe --- /dev/null +++ b/packages/backend/src/notifications/notification.routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { NotificationController } from './notification.controller'; +import { isAuthenticated } from '../auth/auth.middleware'; // Corrected import name + +const router = Router(); +const notificationController = new NotificationController(); + +// Apply auth middleware to all notification routes +router.use(isAuthenticated); + +// Define routes for notification settings CRUD +router.get('/', notificationController.getAll); +router.post('/', notificationController.create); +router.put('/:id', notificationController.update); +router.delete('/:id', notificationController.delete); + +// Route for testing a notification setting (currently only email) +router.post('/:id/test', notificationController.testSetting); + +export default router; diff --git a/packages/backend/src/proxies/proxies.controller.ts b/packages/backend/src/proxies/proxies.controller.ts index 4e1b9a8..c89f31d 100644 --- a/packages/backend/src/proxies/proxies.controller.ts +++ b/packages/backend/src/proxies/proxies.controller.ts @@ -1,5 +1,8 @@ import { Request, Response } from 'express'; import * as ProxyService from '../services/proxy.service'; +import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService + +const auditLogService = new AuditLogService(); // 实例化 AuditLogService // Helper function to remove sensitive fields for response const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial | null => { @@ -54,6 +57,8 @@ export const createProxy = async (req: Request, res: Response) => { } const newProxy = await ProxyService.createProxy(req.body); + // 记录审计日志 + auditLogService.logAction('PROXY_CREATED', { proxyId: newProxy.id, name: newProxy.name, type: newProxy.type }); res.status(201).json({ message: '代理创建成功', proxy: sanitizeProxy(newProxy) // Return sanitized proxy @@ -92,6 +97,8 @@ export const updateProxy = async (req: Request, res: Response) => { const updatedProxy = await ProxyService.updateProxy(proxyId, req.body); if (updatedProxy) { + // 记录审计日志 + auditLogService.logAction('PROXY_UPDATED', { proxyId, updatedFields: Object.keys(req.body) }); res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) }); } else { res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` }); @@ -121,6 +128,8 @@ export const deleteProxy = async (req: Request, res: Response) => { const deleted = await ProxyService.deleteProxy(proxyId); if (deleted) { + // 记录审计日志 + auditLogService.logAction('PROXY_DELETED', { proxyId }); res.status(200).json({ message: `代理 ${id} 删除成功` }); } else { res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` }); diff --git a/packages/backend/src/repositories/audit.repository.ts b/packages/backend/src/repositories/audit.repository.ts new file mode 100644 index 0000000..7d19c52 --- /dev/null +++ b/packages/backend/src/repositories/audit.repository.ts @@ -0,0 +1,112 @@ +import { Database } from 'sqlite3'; +import { getDb } from '../database'; +import { AuditLogEntry, AuditLogActionType } from '../types/audit.types'; + +export class AuditLogRepository { + private db: Database; + + constructor() { + this.db = getDb(); + } + + /** + * 添加一条审计日志记录 + * @param actionType 操作类型 + * @param details 可选的详细信息 (对象或字符串) + */ + async addLog(actionType: AuditLogActionType, details?: Record | string | null): Promise { + const timestamp = Math.floor(Date.now() / 1000); // Unix timestamp in seconds + let detailsString: string | null = null; + + if (details) { + try { + detailsString = typeof details === 'string' ? details : JSON.stringify(details); + } catch (error) { + console.error(`[Audit Log] Failed to stringify details for action ${actionType}:`, error); + detailsString = JSON.stringify({ error: 'Failed to stringify details', originalDetails: details }); + } + } + + const sql = 'INSERT INTO audit_logs (timestamp, action_type, details) VALUES (?, ?, ?)'; + const params = [timestamp, actionType, detailsString]; + + return new Promise((resolve, reject) => { + this.db.run(sql, params, (err) => { + if (err) { + console.error(`[Audit Log] Error adding log entry for action ${actionType}: ${err.message}`); + // 不拒绝 Promise,记录日志失败不应阻止核心操作 + // 但可以在这里触发一个 SERVER_ERROR 通知或日志 + resolve(); // Or potentially reject if logging is critical + } else { + // console.log(`[Audit Log] Logged action: ${actionType}`); // Optional: verbose logging + resolve(); + } + }); + }); + } + + /** + * 获取审计日志列表 (支持分页和基本过滤) + * @param limit 每页数量 + * @param offset 偏移量 + * @param actionType 可选的操作类型过滤 + * @param startDate 可选的开始时间戳 (秒) + * @param endDate 可选的结束时间戳 (秒) + */ + async getLogs( + limit: number = 50, + offset: number = 0, + actionType?: AuditLogActionType, + startDate?: number, + endDate?: number + ): Promise<{ logs: AuditLogEntry[], total: number }> { + let baseSql = 'SELECT * FROM audit_logs'; + let countSql = 'SELECT COUNT(*) as total FROM audit_logs'; + const whereClauses: string[] = []; + const params: (string | number)[] = []; + const countParams: (string | number)[] = []; + + if (actionType) { + whereClauses.push('action_type = ?'); + params.push(actionType); + countParams.push(actionType); + } + if (startDate) { + whereClauses.push('timestamp >= ?'); + params.push(startDate); + countParams.push(startDate); + } + if (endDate) { + whereClauses.push('timestamp <= ?'); + params.push(endDate); + countParams.push(endDate); + } + + if (whereClauses.length > 0) { + const whereSql = ` WHERE ${whereClauses.join(' AND ')}`; + baseSql += whereSql; + countSql += whereSql; + } + + baseSql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return new Promise((resolve, reject) => { + // First get the total count + this.db.get(countSql, countParams, (err, row: { total: number }) => { + if (err) { + return reject(new Error(`Error counting audit logs: ${err.message}`)); + } + const total = row.total; + + // Then get the paginated logs + this.db.all(baseSql, params, (err, rows: AuditLogEntry[]) => { + if (err) { + return reject(new Error(`Error fetching audit logs: ${err.message}`)); + } + resolve({ logs: rows, total }); + }); + }); + }); + } +} diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts index 7752155..ef583be 100644 --- a/packages/backend/src/repositories/connection.repository.ts +++ b/packages/backend/src/repositories/connection.repository.ts @@ -104,7 +104,6 @@ export const findFullConnectionById = async (id: number): Promise => c.*, -- 选择 connections 表所有列 p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, - p.auth_method as proxy_auth_method, -- 包含代理的 auth_method p.encrypted_password as proxy_encrypted_password, p.encrypted_private_key as proxy_encrypted_private_key, -- 包含代理的 key p.encrypted_passphrase as proxy_encrypted_passphrase -- 包含代理的 passphrase diff --git a/packages/backend/src/repositories/notification.repository.ts b/packages/backend/src/repositories/notification.repository.ts new file mode 100644 index 0000000..39aa1fc --- /dev/null +++ b/packages/backend/src/repositories/notification.repository.ts @@ -0,0 +1,151 @@ +import { Database } from 'sqlite3'; +import { getDb } from '../database'; +import { NotificationSetting, RawNotificationSetting, NotificationChannelType, NotificationEvent, NotificationChannelConfig } from '../types/notification.types'; + +// Helper to parse raw data from DB +const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => { + try { + return { + ...raw, + enabled: Boolean(raw.enabled), + config: JSON.parse(raw.config || '{}'), + enabled_events: JSON.parse(raw.enabled_events || '[]'), + }; + } catch (error) { + console.error(`Error parsing notification setting ID ${raw.id}:`, error); + // Return a default/error state or re-throw, depending on desired handling + // For now, return partially parsed with defaults for JSON fields + // Cast to satisfy type checker, but this indicates a parsing error. + return { + ...raw, + enabled: Boolean(raw.enabled), + config: {} as NotificationChannelConfig, // Config is invalid due to parsing error + enabled_events: [], + }; + } +}; + +export class NotificationSettingsRepository { + private db: Database; + + constructor() { + this.db = getDb(); + } + + async getAll(): Promise { + return new Promise((resolve, reject) => { + this.db.all('SELECT * FROM notification_settings ORDER BY created_at ASC', (err, rows: RawNotificationSetting[]) => { + if (err) { + return reject(new Error(`Error fetching notification settings: ${err.message}`)); + } + resolve(rows.map(parseRawSetting)); + }); + }); + } + + async getById(id: number): Promise { + return new Promise((resolve, reject) => { + this.db.get('SELECT * FROM notification_settings WHERE id = ?', [id], (err, row: RawNotificationSetting) => { + if (err) { + return reject(new Error(`Error fetching notification setting by ID ${id}: ${err.message}`)); + } + resolve(row ? parseRawSetting(row) : null); + }); + }); + } + + async getEnabledByEvent(event: NotificationEvent): Promise { + return new Promise((resolve, reject) => { + // Note: This query is inefficient as it fetches all enabled settings and filters in code. + // For better performance with many settings, consider normalizing enabled_events + // or using JSON functions if the SQLite version supports them well. + this.db.all('SELECT * FROM notification_settings WHERE enabled = 1', (err, rows: RawNotificationSetting[]) => { + if (err) { + return reject(new Error(`Error fetching enabled notification settings: ${err.message}`)); + } + const parsedRows = rows.map(parseRawSetting); + const filteredRows = parsedRows.filter(setting => setting.enabled_events.includes(event)); + resolve(filteredRows); + }); + }); + } + + async create(setting: Omit): Promise { + const sql = ` + INSERT INTO notification_settings (channel_type, name, enabled, config, enabled_events) + VALUES (?, ?, ?, ?, ?) + `; + const params = [ + setting.channel_type, + setting.name, + setting.enabled ? 1 : 0, + JSON.stringify(setting.config || {}), + JSON.stringify(setting.enabled_events || []) + ]; + return new Promise((resolve, reject) => { + this.db.run(sql, params, function (err) { // Use function() to access this.lastID + if (err) { + return reject(new Error(`Error creating notification setting: ${err.message}`)); + } + resolve(this.lastID); + }); + }); + } + + async update(id: number, setting: Partial>): Promise { + // Build the SET part of the query dynamically + const fields: string[] = []; + const params: (string | number | null)[] = []; + + if (setting.channel_type !== undefined) { + fields.push('channel_type = ?'); + params.push(setting.channel_type); + } + if (setting.name !== undefined) { + fields.push('name = ?'); + params.push(setting.name); + } + if (setting.enabled !== undefined) { + fields.push('enabled = ?'); + params.push(setting.enabled ? 1 : 0); + } + if (setting.config !== undefined) { + fields.push('config = ?'); + params.push(JSON.stringify(setting.config || {})); + } + if (setting.enabled_events !== undefined) { + fields.push('enabled_events = ?'); + params.push(JSON.stringify(setting.enabled_events || [])); + } + + if (fields.length === 0) { + return Promise.resolve(true); // Nothing to update + } + + fields.push('updated_at = strftime(\'%s\', \'now\')'); // Always update timestamp + + const sql = `UPDATE notification_settings SET ${fields.join(', ')} WHERE id = ?`; + params.push(id); + + return new Promise((resolve, reject) => { + this.db.run(sql, params, function (err) { // Use function() to access this.changes + if (err) { + return reject(new Error(`Error updating notification setting ID ${id}: ${err.message}`)); + } + resolve(this.changes > 0); + }); + }); + } + + async delete(id: number): Promise { + const sql = 'DELETE FROM notification_settings WHERE id = ?'; + return new Promise((resolve, reject) => { + this.db.run(sql, [id], function (err) { // Use function() to access this.changes + if (err) { + return reject(new Error(`Error deleting notification setting ID ${id}: ${err.message}`)); + } + resolve(this.changes > 0); + }); + }); + } +} diff --git a/packages/backend/src/services/audit.service.ts b/packages/backend/src/services/audit.service.ts new file mode 100644 index 0000000..6581f85 --- /dev/null +++ b/packages/backend/src/services/audit.service.ts @@ -0,0 +1,51 @@ +import { AuditLogRepository } from '../repositories/audit.repository'; +import { AuditLogActionType, AuditLogEntry } from '../types/audit.types'; + +export class AuditLogService { + private repository: AuditLogRepository; + + constructor() { + this.repository = new AuditLogRepository(); + } + + /** + * 记录一条审计日志 + * @param actionType 操作类型 + * @param details 可选的详细信息 (对象或字符串) + */ + async logAction(actionType: AuditLogActionType, details?: Record | string | null): Promise { + // 在这里可以添加额外的逻辑,例如: + // - 检查是否需要记录此类型的日志 (基于配置) + // - 格式化 details + // - 异步执行,不阻塞主流程 + try { + // 使用 'await' 确保日志记录完成(如果需要保证顺序或处理错误) + // 或者不使用 'await' 让其在后台执行 + await this.repository.addLog(actionType, details); + } catch (error) { + // Repository 内部已经处理了错误打印,这里可以根据需要再次处理或忽略 + console.error(`[Audit Service] Failed to log action ${actionType}:`, error); + } + } + + /** + * 获取审计日志列表 + * @param limit 每页数量 + * @param offset 偏移量 + * @param actionType 可选的操作类型过滤 + * @param startDate 可选的开始时间戳 (秒) + * @param endDate 可选的结束时间戳 (秒) + */ + async getLogs( + limit: number = 50, + offset: number = 0, + actionType?: AuditLogActionType, + startDate?: number, + endDate?: number + ): Promise<{ logs: AuditLogEntry[], total: number }> { + return this.repository.getLogs(limit, offset, actionType, startDate, endDate); + } +} + +// Optional: Export a singleton instance if needed throughout the backend +// export const auditLogService = new AuditLogService(); diff --git a/packages/backend/src/services/ip-blacklist.service.ts b/packages/backend/src/services/ip-blacklist.service.ts new file mode 100644 index 0000000..06d2210 --- /dev/null +++ b/packages/backend/src/services/ip-blacklist.service.ts @@ -0,0 +1,223 @@ +import { getDb } from '../database'; +import { settingsService } from './settings.service'; // 用于获取配置 +import * as sqlite3 from 'sqlite3'; // 导入 sqlite3 类型 + +const db = getDb(); + +// 黑名单相关设置的 Key +const MAX_LOGIN_ATTEMPTS_KEY = 'maxLoginAttempts'; +const LOGIN_BAN_DURATION_KEY = 'loginBanDuration'; // 单位:秒 + +// 与 ipWhitelist.middleware.ts 保持一致 +const LOCAL_IPS = [ + '127.0.0.1', // IPv4 本地回环 + '::1', // IPv6 本地回环 + 'localhost' // 本地主机名 (虽然通常解析为上面两者,但也包含以防万一) +]; + +// 黑名单条目接口 +interface IpBlacklistEntry { + ip: string; + attempts: number; + last_attempt_at: number; + blocked_until: number | null; +} + +export class IpBlacklistService { + + /** + * 获取指定 IP 的黑名单记录 + * @param ip IP 地址 + * @returns 黑名单记录或 undefined + */ + private async getEntry(ip: string): Promise { + return new Promise((resolve, reject) => { + db.get('SELECT * FROM ip_blacklist WHERE ip = ?', [ip], (err, row: IpBlacklistEntry) => { + if (err) { + console.error(`[IP Blacklist] 查询 IP ${ip} 时出错:`, err.message); + return reject(new Error('数据库查询失败')); + } + resolve(row); + }); + }); + } + + /** + * 检查 IP 是否当前被封禁 + * @param ip IP 地址 + * @returns 如果被封禁则返回 true,否则返回 false + */ + async isBlocked(ip: string): Promise { + try { + const entry = await this.getEntry(ip); + if (!entry) { + return false; // 不在黑名单中 + } + // 检查封禁时间是否已过 + if (entry.blocked_until && entry.blocked_until > Math.floor(Date.now() / 1000)) { + console.log(`[IP Blacklist] IP ${ip} 当前被封禁,直到 ${new Date(entry.blocked_until * 1000).toISOString()}`); + return true; // 仍在封禁期内 + } + // 如果封禁时间已过或为 null,则不再封禁 + return false; + } catch (error) { + console.error(`[IP Blacklist] 检查 IP ${ip} 封禁状态时出错:`, error); + return false; // 出错时默认不封禁,避免锁死用户 + } + } + + /** + * 记录一次登录失败尝试 + * 如果达到阈值,则封禁该 IP + * @param ip IP 地址 + */ + async recordFailedAttempt(ip: string): Promise { + // 如果是本地 IP,则不记录失败尝试,直接返回 + if (LOCAL_IPS.includes(ip)) { + console.log(`[IP Blacklist] 检测到本地 IP ${ip} 登录失败,跳过黑名单处理。`); + return; + } + + const now = Math.floor(Date.now() / 1000); + try { + // 获取设置,并提供默认值处理 + const maxAttemptsStr = await settingsService.getSetting(MAX_LOGIN_ATTEMPTS_KEY); + const banDurationStr = await settingsService.getSetting(LOGIN_BAN_DURATION_KEY); + + // 解析设置值,如果无效或未设置,则使用默认值 + const maxAttempts = parseInt(maxAttemptsStr || '5', 10) || 5; + const banDuration = parseInt(banDurationStr || '300', 10) || 300; + + const entry = await this.getEntry(ip); + + if (entry) { + // 更新现有记录 + const newAttempts = entry.attempts + 1; + let blockedUntil = entry.blocked_until; + + // 检查是否达到封禁阈值 + if (newAttempts >= maxAttempts) { + blockedUntil = now + banDuration; + console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`); + } + + await new Promise((resolve, reject) => { + db.run( + 'UPDATE ip_blacklist SET attempts = ?, last_attempt_at = ?, blocked_until = ? WHERE ip = ?', + [newAttempts, now, blockedUntil, ip], + (err) => { + if (err) { + console.error(`[IP Blacklist] 更新 IP ${ip} 失败尝试次数时出错:`, err.message); + return reject(err); + } + resolve(); + } + ); + }); + } else { + // 插入新记录 + let blockedUntil: number | null = null; + if (1 >= maxAttempts) { // 首次尝试就达到阈值 (虽然不常见) + blockedUntil = now + banDuration; + console.warn(`[IP Blacklist] IP ${ip} 首次登录失败即达到阈值 ${maxAttempts},将被封禁 ${banDuration} 秒。`); + } + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO ip_blacklist (ip, attempts, last_attempt_at, blocked_until) VALUES (?, 1, ?, ?)', + [ip, now, blockedUntil], + (err) => { + if (err) { + console.error(`[IP Blacklist] 插入新 IP ${ip} 失败记录时出错:`, err.message); + return reject(err); + } + resolve(); + } + ); + }); + } + } catch (error) { + console.error(`[IP Blacklist] 记录 IP ${ip} 失败尝试时出错:`, error); + } + } + + /** + * 重置指定 IP 的失败尝试次数和封禁状态 (例如登录成功后调用) + * @param ip IP 地址 + */ + async resetAttempts(ip: string): Promise { + try { + await new Promise((resolve, reject) => { + // 直接删除记录,或者将 attempts 重置为 0 并清除 blocked_until + db.run('DELETE FROM ip_blacklist WHERE ip = ?', [ip], (err) => { + if (err) { + console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, err.message); + return reject(err); + } + console.log(`[IP Blacklist] 已重置 IP ${ip} 的失败尝试记录。`); + resolve(); + }); + }); + } catch (error) { + console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error); + } + } + + /** + * 获取所有黑名单记录 (用于管理界面) + * @param limit 每页数量 + * @param offset 偏移量 + */ + async getBlacklist(limit: number = 50, offset: number = 0): Promise<{ entries: IpBlacklistEntry[], total: number }> { + const entries = await new Promise((resolve, reject) => { + db.all('SELECT * FROM ip_blacklist ORDER BY last_attempt_at DESC LIMIT ? OFFSET ?', [limit, offset], (err, rows: IpBlacklistEntry[]) => { + if (err) { + console.error('[IP Blacklist] 获取黑名单列表时出错:', err.message); + return reject(new Error('数据库查询失败')); + } + resolve(rows); + }); + }); + + const total = await new Promise((resolve, reject) => { + db.get('SELECT COUNT(*) as count FROM ip_blacklist', (err, row: { count: number }) => { + if (err) { + console.error('[IP Blacklist] 获取黑名单总数时出错:', err.message); + return reject(0); // 出错时返回 0 + } + resolve(row.count); + }); + }); + + return { entries, total }; + } + + /** + * 从黑名单中删除一个 IP (解除封禁) + * @param ip IP 地址 + */ + async removeFromBlacklist(ip: string): Promise { + try { + await new Promise((resolve, reject) => { + // 将 this 类型改回 RunResult 以访问 changes 属性 + db.run('DELETE FROM ip_blacklist WHERE ip = ?', [ip], function(this: sqlite3.RunResult, err: Error | null) { + if (err) { + console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, err.message); + return reject(err); + } + if (this.changes === 0) { + console.warn(`[IP Blacklist] 尝试删除 IP ${ip},但该 IP 不在黑名单中。`); + } else { + console.log(`[IP Blacklist] 已从黑名单中删除 IP ${ip}。`); + } + resolve(); + }); + }); + } catch (error) { + console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, error); + throw error; // 重新抛出错误,以便上层处理 + } + } +} + +// 导出单例 +export const ipBlacklistService = new IpBlacklistService(); diff --git a/packages/backend/src/services/notification.service.ts b/packages/backend/src/services/notification.service.ts new file mode 100644 index 0000000..c574e73 --- /dev/null +++ b/packages/backend/src/services/notification.service.ts @@ -0,0 +1,257 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { NotificationSettingsRepository } from '../repositories/notification.repository'; +import { + NotificationSetting, + NotificationEvent, + NotificationPayload, + WebhookConfig, + EmailConfig, // Ensure EmailConfig is imported + TelegramConfig, + NotificationChannelConfig +} from '../types/notification.types'; +import * as nodemailer from 'nodemailer'; +import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter + +export class NotificationService { + private repository: NotificationSettingsRepository; + + constructor() { + this.repository = new NotificationSettingsRepository(); + } + + async getAllSettings(): Promise { + return this.repository.getAll(); + } + + async getSettingById(id: number): Promise { + return this.repository.getById(id); + } + + async createSetting(settingData: Omit): Promise { + // Add validation if needed + return this.repository.create(settingData); + } + + async updateSetting(id: number, settingData: Partial>): Promise { + // 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); + } + + async deleteSetting(id: number): Promise { + return this.repository.delete(id); + } + + // --- Test Notification Method --- + async testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> { + if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { + return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 服务器, 端口, 发件人)。' }; + } + + // Let TypeScript infer the options type for SMTP + const transporterOptions = { + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure ?? true, // Default to true (TLS) + auth: (config.smtpUser || config.smtpPass) ? { + user: config.smtpUser, + pass: config.smtpPass, // Ensure password is included if user is present + } : undefined, + // Consider adding TLS options if needed, e.g., ignore self-signed certs + // tls: { + // rejectUnauthorized: false // Use with caution! + // } + }; + + const transporter = nodemailer.createTransport(transporterOptions); + + const mailOptions: Mail.Options = { + from: config.from, + to: config.to, // Use the 'to' from config for testing + subject: '星枢终端 (Nexus Terminal) 测试邮件', + text: `这是一封来自星枢终端 (Nexus Terminal) 的测试邮件。\n\n如果收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间: ${new Date().toISOString()}`, + html: `

这是一封来自 星枢终端 (Nexus Terminal) 的测试邮件。

如果收到此邮件,表示您的 SMTP 配置工作正常。

时间: ${new Date().toISOString()}

`, + }; + + try { + console.log(`[Notification Test] Attempting to send test email via ${config.smtpHost}:${config.smtpPort} to ${config.to}`); + const info = await transporter.sendMail(mailOptions); + console.log(`[Notification Test] Test email sent successfully: ${info.messageId}`); + // Verify connection if possible (optional) + // await transporter.verify(); + // console.log('[Notification Test] SMTP Connection verified.'); + return { success: true, message: '测试邮件发送成功!请检查收件箱。' }; + } catch (error: any) { + console.error(`[Notification Test] Error sending test email:`, error); + return { success: false, message: `测试邮件发送失败: ${error.message || '未知错误'}` }; + } + } + + + // --- Core Notification Sending Logic --- + + async sendNotification(event: NotificationEvent, details?: Record | string): Promise { + console.log(`[Notification] Event triggered: ${event}`, details || ''); + const payload: NotificationPayload = { + event, + timestamp: Date.now(), + details: details || undefined, + }; + + try { + const applicableSettings = await this.repository.getEnabledByEvent(event); + console.log(`[Notification] Found ${applicableSettings.length} applicable setting(s) for event ${event}`); + + if (applicableSettings.length === 0) { + return; // No enabled settings for this event + } + + const sendPromises = applicableSettings.map(setting => { + switch (setting.channel_type) { + case 'webhook': + return this._sendWebhook(setting, payload); + case 'email': + return this._sendEmail(setting, payload); + case 'telegram': + return this._sendTelegram(setting, payload); + 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 + } + }); + + // Wait for all notifications to be attempted + await Promise.allSettled(sendPromises); + console.log(`[Notification] Finished attempting notifications for event ${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 + } + } + + // --- Private Sending Helpers --- + + private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string): string { + if (!template) return defaultText; + let rendered = template; + rendered = rendered.replace(/\{\{event\}\}/g, payload.event); + rendered = rendered.replace(/\{\{timestamp\}\}/g, new Date(payload.timestamp).toISOString()); + // Simple details replacement, might need more robust templating engine for complex objects + const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); + rendered = rendered.replace(/\{\{details\}\}/g, detailsString); + return rendered; + } + + + private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload): Promise { + const config = setting.config as WebhookConfig; + if (!config.url) { + console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`); + return; + } + + const defaultBody = JSON.stringify(payload, null, 2); + const requestBody = this._renderTemplate(config.bodyTemplate, payload, defaultBody); + + const requestConfig: AxiosRequestConfig = { + method: config.method || 'POST', + url: config.url, + headers: { + 'Content-Type': 'application/json', // Default, can be overridden by config.headers + ...(config.headers || {}), + }, + data: requestBody, + timeout: 10000, // Add a timeout (e.g., 10 seconds) + }; + + try { + console.log(`[Notification] Sending Webhook to ${config.url} for event ${payload.event}`); + const response = await axios(requestConfig); + console.log(`[Notification] Webhook sent successfully to ${config.url}. Status: ${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); + } + } + + private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload): Promise { + 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).`); + 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) + auth: (config.smtpUser || config.smtpPass) ? { + user: config.smtpUser, + pass: config.smtpPass, // Ensure password is included if user is present + } : undefined, + // tls: { rejectUnauthorized: false } // Add if needed for self-signed certs, USE WITH CAUTION + }; + + const transporter = nodemailer.createTransport(transporterOptions); + + const defaultSubject = `星枢终端通知: ${payload.event}`; + const subject = this._renderTemplate(config.subjectTemplate, payload, defaultSubject); + + // Basic default body (plain text) + const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); + const defaultBody = `事件: ${payload.event}\n时间: ${new Date(payload.timestamp).toISOString()}\n详情:\n${detailsString}`; + // Note: Email body templates are not implemented in this version. Using default text. + const body = defaultBody; + + const mailOptions: Mail.Options = { + from: config.from, + to: config.to, + subject: subject, + text: body, + // html: `

${body.replace(/\n/g, '
')}

` // Simple HTML version + }; + + try { + console.log(`[Notification] Sending Email via ${config.smtpHost}:${config.smtpPort} to ${config.to} for event ${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}`); + } catch (error: any) { + console.error(`[Notification] Error sending email for setting ID ${setting.id} via ${config.smtpHost}:`, error); + } + } + + private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload): Promise { + const config = setting.config as TelegramConfig; + if (!config.botToken || !config.chatId) { + console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`); + return; + } + + // Default message format + const detailsStr = payload.details ? `\n详情: \`\`\`\n${typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details, null, 2)}\n\`\`\`` : ''; + const defaultMessage = `*星枢终端通知*\n\n事件: \`${payload.event}\`\n时间: ${new Date(payload.timestamp).toISOString()}${detailsStr}`; + + const messageText = this._renderTemplate(config.messageTemplate, payload, defaultMessage); + 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}`); + const response = await axios.post(telegramApiUrl, { + chat_id: config.chatId, + text: messageText, + parse_mode: 'Markdown', // Or 'HTML' depending on template needs + }, { timeout: 10000 }); // Add timeout + console.log(`[Notification] Telegram message sent successfully. Response 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); + } + } +} + +// Optional: Export a singleton instance if needed throughout the backend +// export const notificationService = new NotificationService(); diff --git a/packages/backend/src/services/sftp.service.ts b/packages/backend/src/services/sftp.service.ts index 54a167a..35c6796 100644 --- a/packages/backend/src/services/sftp.service.ts +++ b/packages/backend/src/services/sftp.service.ts @@ -282,28 +282,53 @@ export class SftpService { } } - /** 删除空目录 */ - async rmdir(sessionId: string, path: string, requestId: string): Promise { - const state = this.clientStates.get(sessionId); - if (!state || !state.sftp) { - console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`); - state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type - return; - } - console.debug(`[SFTP ${sessionId}] Received rmdir request for ${path} (ID: ${requestId})`); + /** 删除目录 (强制递归) */ + async rmdir(sessionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(sessionId); + // 检查 SSH 客户端是否存在,而不是 SFTP 实例 + if (!state || !state.sshClient) { + console.warn(`[SSH Exec] SSH 客户端未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`); + state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SSH 会话未就绪', requestId: requestId })); + return; + } + console.debug(`[SSH Exec ${sessionId}] Received rmdir (force) request for ${path} (ID: ${requestId})`); + + // 构建 rm -rf 命令,确保路径被正确引用 + const command = `rm -rf "${path.replace(/"/g, '\\"')}"`; // Basic quoting for paths with spaces/quotes + console.log(`[SSH Exec ${sessionId}] Executing command: ${command} (ID: ${requestId})`); + try { - state.sftp.rmdir(path, (err) => { + state.sshClient.exec(command, (err, stream) => { if (err) { - console.error(`[SFTP ${sessionId}] rmdir ${path} failed (ID: ${requestId}):`, err); - state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${err.message}`, requestId: requestId })); - } else { - console.log(`[SFTP ${sessionId}] rmdir ${path} success (ID: ${requestId})`); - state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId })); // Send specific success type + console.error(`[SSH Exec ${sessionId}] Failed to start exec for rmdir ${path} (ID: ${requestId}):`, err); + state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `执行删除命令失败: ${err.message}`, requestId: requestId })); + return; } + + let stderrOutput = ''; + stream.stderr.on('data', (data: Buffer) => { + stderrOutput += data.toString(); + }); + + stream.on('close', (code: number | null, signal: string | null) => { + if (code === 0) { + console.log(`[SSH Exec ${sessionId}] rmdir ${path} command executed successfully (ID: ${requestId})`); + state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId })); + } else { + const errorMessage = stderrOutput.trim() || `命令退出,代码: ${code ?? 'N/A'}${signal ? `, 信号: ${signal}` : ''}`; + console.error(`[SSH Exec ${sessionId}] rmdir ${path} command failed (ID: ${requestId}). Code: ${code}, Signal: ${signal}, Stderr: ${stderrOutput}`); + state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${errorMessage}`, requestId: requestId })); + } + }); + + stream.on('data', (data: Buffer) => { + // 通常 rm -rf 成功时 stdout 没有输出,但可以记录以防万一 + console.debug(`[SSH Exec ${sessionId}] rmdir stdout (ID: ${requestId}): ${data.toString()}`); + }); }); } catch (error: any) { - console.error(`[SFTP ${sessionId}] rmdir ${path} caught unexpected error (ID: ${requestId}):`, error); - state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录时发生意外错误: ${error.message}`, requestId: requestId })); + console.error(`[SSH Exec ${sessionId}] rmdir ${path} caught unexpected error during exec setup (ID: ${requestId}):`, error); + state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `执行删除时发生意外错误: ${error.message}`, requestId: requestId })); } } diff --git a/packages/backend/src/settings/settings.controller.ts b/packages/backend/src/settings/settings.controller.ts index 9b97a65..2212062 100644 --- a/packages/backend/src/settings/settings.controller.ts +++ b/packages/backend/src/settings/settings.controller.ts @@ -1,5 +1,9 @@ import { Request, Response } from 'express'; import { settingsService } from '../services/settings.service'; +import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService +import { ipBlacklistService } from '../services/ip-blacklist.service'; // 引入 IP 黑名单服务 + +const auditLogService = new AuditLogService(); // 实例化 AuditLogService export const settingsController = { /** @@ -29,6 +33,14 @@ export const settingsController = { // 可以在这里添加更严格的验证,例如检查值的类型等 await settingsService.setMultipleSettings(settingsToUpdate); + // 记录审计日志 + // 区分 IP 白名单更新和其他设置更新 + const updatedKeys = Object.keys(settingsToUpdate); + if (updatedKeys.includes('ipWhitelist')) { + auditLogService.logAction('IP_WHITELIST_UPDATED', { updatedKeys }); + } else { + auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys }); + } res.status(200).json({ message: '设置已成功更新' }); } catch (error: any) { console.error('更新设置时出错:', error); @@ -42,4 +54,40 @@ export const settingsController = { // async getSetting(req: Request, res: Response): Promise { ... } // async setSetting(req: Request, res: Response): Promise { ... } // async deleteSetting(req: Request, res: Response): Promise { ... } + + /** + * 获取 IP 黑名单列表 (分页) + */ + async getIpBlacklist(req: Request, res: Response): Promise { + try { + const limit = parseInt(req.query.limit as string || '50', 10); + const offset = parseInt(req.query.offset as string || '0', 10); + const result = await ipBlacklistService.getBlacklist(limit, offset); + res.json(result); + } catch (error: any) { + console.error('获取 IP 黑名单时出错:', error); + res.status(500).json({ message: '获取 IP 黑名单失败', error: error.message }); + } + }, + + /** + * 从 IP 黑名单中删除一个 IP + */ + async deleteIpFromBlacklist(req: Request, res: Response): Promise { + try { + const ipToDelete = req.params.ip; + if (!ipToDelete) { + res.status(400).json({ message: '缺少要删除的 IP 地址' }); + return; + } + // TODO: 可以添加对 IP 格式的验证 + await ipBlacklistService.removeFromBlacklist(ipToDelete); + // 记录审计日志 (可选) + // auditLogService.logAction('IP_BLACKLIST_REMOVED', { ip: ipToDelete }); + res.status(200).json({ message: `IP 地址 ${ipToDelete} 已从黑名单中移除` }); + } catch (error: any) { + console.error(`从 IP 黑名单删除 ${req.params.ip} 时出错:`, error); + res.status(500).json({ message: '从 IP 黑名单删除失败', error: error.message }); + } + } }; diff --git a/packages/backend/src/settings/settings.routes.ts b/packages/backend/src/settings/settings.routes.ts index 6383c29..ad985dc 100644 --- a/packages/backend/src/settings/settings.routes.ts +++ b/packages/backend/src/settings/settings.routes.ts @@ -11,4 +11,12 @@ router.use(isAuthenticated); router.get('/', settingsController.getAllSettings); // GET /api/v1/settings router.put('/', settingsController.updateSettings); // PUT /api/v1/settings +// --- IP 黑名单管理路由 --- +// GET /api/v1/settings/ip-blacklist - 获取 IP 黑名单列表 (需要认证) +router.get('/ip-blacklist', settingsController.getIpBlacklist); + +// DELETE /api/v1/settings/ip-blacklist/:ip - 从黑名单中删除指定 IP (需要认证) +router.delete('/ip-blacklist/:ip', settingsController.deleteIpFromBlacklist); + + export default router; diff --git a/packages/backend/src/tags/tags.controller.ts b/packages/backend/src/tags/tags.controller.ts index 11abd87..374d198 100644 --- a/packages/backend/src/tags/tags.controller.ts +++ b/packages/backend/src/tags/tags.controller.ts @@ -1,5 +1,8 @@ import { Request, Response } from 'express'; import * as TagService from '../services/tag.service'; +import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService + +const auditLogService = new AuditLogService(); // 实例化 AuditLogService /** * 创建新标签 (POST /api/v1/tags) @@ -14,6 +17,8 @@ export const createTag = async (req: Request, res: Response): Promise => { try { const newTag = await TagService.createTag(name); + // 记录审计日志 + auditLogService.logAction('TAG_CREATED', { tagId: newTag.id, name: newTag.name }); res.status(201).json({ message: '标签创建成功。', tag: newTag }); } catch (error: any) { console.error('Controller: 创建标签时发生错误:', error); @@ -83,6 +88,8 @@ export const updateTag = async (req: Request, res: Response): Promise => { if (!updatedTag) { res.status(404).json({ message: '标签未找到。' }); } else { + // 记录审计日志 + auditLogService.logAction('TAG_UPDATED', { tagId, newName: name }); res.status(200).json({ message: '标签更新成功。', tag: updatedTag }); } } catch (error: any) { @@ -114,6 +121,8 @@ export const deleteTag = async (req: Request, res: Response): Promise => { if (!deleted) { res.status(404).json({ message: '标签未找到。' }); } else { + // 记录审计日志 + auditLogService.logAction('TAG_DELETED', { tagId }); res.status(200).json({ message: '标签删除成功。' }); } } catch (error: any) { diff --git a/packages/backend/src/types/audit.types.ts b/packages/backend/src/types/audit.types.ts new file mode 100644 index 0000000..c809872 --- /dev/null +++ b/packages/backend/src/types/audit.types.ts @@ -0,0 +1,69 @@ +// 定义审计日志记录的操作类型 +export type AuditLogActionType = + // Authentication + | 'LOGIN_SUCCESS' + | 'LOGIN_FAILURE' + | 'LOGOUT' + | 'PASSWORD_CHANGED' + | '2FA_ENABLED' + | '2FA_DISABLED' + | 'PASSKEY_REGISTERED' + | 'PASSKEY_DELETED' // Assuming deletion is possible later + + // Connections + | 'CONNECTION_CREATED' + | 'CONNECTION_UPDATED' + | 'CONNECTION_DELETED' + | 'CONNECTION_TESTED' // Maybe log test attempts? + | 'CONNECTIONS_IMPORTED' + | 'CONNECTIONS_EXPORTED' + + // Proxies + | 'PROXY_CREATED' + | 'PROXY_UPDATED' + | 'PROXY_DELETED' + + // Tags + | 'TAG_CREATED' + | 'TAG_UPDATED' + | 'TAG_DELETED' + + // Settings + | 'SETTINGS_UPDATED' // General settings update + | 'IP_WHITELIST_UPDATED' // Specific setting update + + // Notifications + | 'NOTIFICATION_SETTING_CREATED' + | 'NOTIFICATION_SETTING_UPDATED' + | 'NOTIFICATION_SETTING_DELETED' + + // API Keys + | 'API_KEY_CREATED' + | 'API_KEY_DELETED' + + // SFTP (Consider logging specific actions if needed, e.g., UPLOAD, DOWNLOAD, DELETE_FILE) + | 'SFTP_ACTION' // Generic SFTP action for now + + // SSH Actions (via WebSocket) + | 'SSH_CONNECT_SUCCESS' + | 'SSH_CONNECT_FAILURE' + | 'SSH_SHELL_FAILURE' + + // System/Error + | 'SERVER_STARTED' + | 'SERVER_ERROR' // Log significant backend errors + | 'DATABASE_MIGRATION'; + +// 审计日志条目的结构 (从数据库读取时) +export interface AuditLogEntry { + id: number; + timestamp: number; // Unix timestamp (seconds) + action_type: AuditLogActionType; + details: string | null; // JSON string or null +} + +// 用于创建日志条目的数据结构 +export interface AuditLogData { + actionType: AuditLogActionType; + details?: Record | string | null; +} diff --git a/packages/backend/src/types/notification.types.ts b/packages/backend/src/types/notification.types.ts new file mode 100644 index 0000000..3bd931a --- /dev/null +++ b/packages/backend/src/types/notification.types.ts @@ -0,0 +1,77 @@ +export type NotificationChannelType = 'webhook' | 'email' | 'telegram'; + +export type NotificationEvent = + | 'LOGIN_SUCCESS' + | 'LOGIN_FAILURE' + | 'CONNECTION_ADDED' + | 'CONNECTION_UPDATED' + | 'CONNECTION_DELETED' + | 'SETTINGS_UPDATED' + | 'PROXY_ADDED' + | 'PROXY_UPDATED' + | 'PROXY_DELETED' + | 'TAG_ADDED' + | 'TAG_UPDATED' + | 'TAG_DELETED' + | 'API_KEY_ADDED' + | 'API_KEY_DELETED' + | 'PASSKEY_ADDED' + | 'PASSKEY_DELETED' + | 'SERVER_ERROR'; // Generic error event + +export interface WebhookConfig { + url: string; + method?: 'POST' | 'GET' | 'PUT'; // Default to POST + headers?: Record; // Optional custom headers + bodyTemplate?: string; // Optional template for the request body (e.g., using placeholders like {{event}}, {{details}}) +} + +export interface EmailConfig { + to: string; // Comma-separated list of recipient emails + subjectTemplate?: string; // Optional subject template + // SMTP settings per channel + smtpHost?: string; + smtpPort?: number; + smtpSecure?: boolean; // Use TLS + smtpUser?: string; + smtpPass?: string; // Consider encryption or secure storage + from?: string; // Sender email address +} + +export interface TelegramConfig { + botToken: string; // Consider storing this securely, maybe encrypted or via env vars + chatId: string; // Target chat ID + messageTemplate?: string; // Optional message template +} + +export type NotificationChannelConfig = WebhookConfig | EmailConfig | TelegramConfig; + +export interface NotificationSetting { + id?: number; + channel_type: NotificationChannelType; + name: string; + enabled: boolean; + config: NotificationChannelConfig; // Parsed JSON config + enabled_events: NotificationEvent[]; // Parsed JSON array + created_at?: number | string; + updated_at?: number | string; +} + +// Raw data structure from the database before parsing JSON fields +export interface RawNotificationSetting { + id: number; + channel_type: NotificationChannelType; + name: string; + enabled: number; // SQLite stores BOOLEAN as 0 or 1 + config: string; // JSON string + enabled_events: string; // JSON string + created_at: number | string; + updated_at: number | string; +} + +// Type for the data sent with a notification event +export interface NotificationPayload { + event: NotificationEvent; + timestamp: number; + details?: Record | string; // Contextual information about the event +} diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index dacd063..11653dd 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -5,9 +5,11 @@ import { Client, ClientChannel } from 'ssh2'; import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一的会话 ID import { getDb } from './database'; import { decrypt } from './utils/crypto'; -import { SftpService } from './services/sftp.service'; // 引入 SftpService -import { StatusMonitorService } from './services/status-monitor.service'; // 引入 StatusMonitorService -import * as SshService from './services/ssh.service'; // 引入重构后的 SshService 函数 +import { SftpService } from './services/sftp.service'; +import { StatusMonitorService } from './services/status-monitor.service'; +import * as SshService from './services/ssh.service'; +import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService +import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType // 扩展 WebSocket 类型以包含会话 ID interface AuthenticatedWebSocket extends WebSocket { @@ -27,6 +29,7 @@ export interface ClientState { // 导出以便 Service 可以导入 dbConnectionId: number; sftp?: SFTPWrapper; // 添加 sftp 实例 (由 SftpService 管理) statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理) + ipAddress?: string; // 添加 IP 地址字段 } // 存储所有活动客户端的状态 (key: sessionId) @@ -34,8 +37,9 @@ const clientStates = new Map(); // --- 服务实例化 --- // 将 clientStates 传递给需要访问共享状态的服务 -const sftpService = new SftpService(clientStates); // 移除 as any -const statusMonitorService = new StatusMonitorService(clientStates); // 移除 as any +const sftpService = new SftpService(clientStates); +const statusMonitorService = new StatusMonitorService(clientStates); +const auditLogService = new AuditLogService(); // 实例化 AuditLogService /** * 清理指定会话 ID 关联的所有资源 @@ -55,7 +59,6 @@ const cleanupClientConnection = (sessionId: string | undefined) => { sftpService.cleanupSftpSession(sessionId); // 3. 清理 SSH 连接 (调用 SshService 中的底层清理逻辑,或直接操作) - // SshService.cleanupConnection(state.ws); // 旧版 SshService 的清理方式,需要调整 state.sshShellStream?.end(); // 结束 shell 流 state.sshClient?.end(); // 结束 SSH 客户端 @@ -102,10 +105,16 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH return; } console.log(`WebSocket 认证成功:用户 ${request.session.username} (ID: ${request.session.userId})`); + // 获取客户端 IP 地址 + const ipAddress = request.ip; + console.log(`WebSocket: 升级请求来自 IP: ${ipAddress}`); + wss.handleUpgrade(request, socket, head, (ws) => { const extWs = ws as AuthenticatedWebSocket; extWs.userId = request.session.userId; extWs.username = request.session.username; + // 将 IP 地址附加到 request 对象上传递给 connection 事件处理器,以便后续使用 + (request as any).clientIpAddress = ipAddress; wss.emit('connection', extWs, request); }); }); @@ -152,44 +161,37 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH console.log(`WebSocket: 用户 ${ws.username} 请求连接到数据库 ID: ${dbConnectionId}`); ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在处理连接请求...' })); + // 从传递过来的 request 对象获取 IP 地址 (在 catch 块中也需要访问) + const clientIp = (request as any).clientIpAddress || 'unknown'; try { - // 调用 SshService 建立连接并打开 Shell - // 注意:SshService.connectAndOpenShell 现在需要返回 Client 和 ShellStream - // 或者我们在这里编排,调用 SshService 的不同部分 - // 这里采用 SshService.connectAndOpenShell 返回包含 client 和 shell 的对象的假设 - // SshService 内部不再管理 activeSessions Map - - // 模拟调用 SshService (实际应调用重构后的函数) - // const { client, shellStream } = await SshService.connectAndOpenShell(dbConnectionId, ws); // 假设 SshService 返回这些 - // --- 手动编排 SSH 连接流程 --- - // 1. 获取连接信息 (与旧代码类似,但移到这里) + // 1. 获取连接信息 ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' })); - const connInfo = await SshService.getConnectionDetails(dbConnectionId); // 假设 SshService 提供此函数 + const connInfo = await SshService.getConnectionDetails(dbConnectionId); - // 2. 建立 SSH 连接 (调用 SshService 的底层连接函数) + // 2. 建立 SSH 连接 ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${connInfo.host}...` })); - const sshClient = await SshService.establishSshConnection(connInfo); // 假设 SshService 提供此函数 + const sshClient = await SshService.establishSshConnection(connInfo); // 3. 连接成功,创建状态 - const newSessionId = uuidv4(); // 生成唯一会话 ID - ws.sessionId = newSessionId; // 关联到 WebSocket + const newSessionId = uuidv4(); + ws.sessionId = newSessionId; const newState: ClientState = { ws: ws, sshClient: sshClient, dbConnectionId: dbConnectionId, - // shellStream 稍后添加 + ipAddress: clientIp, // 存储 IP 地址 }; clientStates.set(newSessionId, newState); - console.log(`WebSocket: 为用户 ${ws.username} 创建新会话 ${newSessionId} (DB ID: ${dbConnectionId})`); + console.log(`WebSocket: 为用户 ${ws.username} (IP: ${clientIp}) 创建新会话 ${newSessionId} (DB ID: ${dbConnectionId})`); // 4. 打开 Shell ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' })); try { - const shellStream = await SshService.openShell(sshClient); // 假设 SshService 提供此函数 - newState.sshShellStream = shellStream; // 存储 Shell 流 + const shellStream = await SshService.openShell(sshClient); + newState.sshShellStream = shellStream; // 5. 设置 Shell 事件转发 shellStream.on('data', (data: Buffer) => { @@ -206,46 +208,54 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH shellStream.on('close', () => { console.log(`SSH: 会话 ${newSessionId} 的 Shell 通道已关闭。`); ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' })); - cleanupClientConnection(newSessionId); // Shell 关闭时清理整个会话 + cleanupClientConnection(newSessionId); }); - // 6. 发送 SSH 连接成功消息 (Shell 已就绪) + // 6. 发送 SSH 连接成功消息 ws.send(JSON.stringify({ type: 'ssh:connected', payload: { connectionId: dbConnectionId, sessionId: newSessionId - // sftpReady 标志移除,将通过 sftp_ready 消息通知 } })); console.log(`WebSocket: 会话 ${newSessionId} SSH 连接和 Shell 建立成功。`); + // 记录审计日志:SSH 连接成功 + auditLogService.logAction('SSH_CONNECT_SUCCESS', { + userId: ws.userId, + username: ws.username, + connectionId: dbConnectionId, + sessionId: newSessionId, + ip: newState.ipAddress + }); // 7. 异步初始化 SFTP 和启动状态监控 console.log(`WebSocket: 会话 ${newSessionId} 正在异步初始化 SFTP...`); sftpService.initializeSftpSession(newSessionId) - .then(() => { - console.log(`SFTP: 会话 ${newSessionId} 异步初始化成功。`); - // SFTP 初始化成功后,前端会收到 sftp_ready 消息 - // FileManager 会在 isConnected 变为 true 后自动请求目录 - }) - .catch(sftpInitError => { - console.error(`WebSocket: 会话 ${newSessionId} 异步初始化 SFTP 失败:`, sftpInitError); - // 错误消息已在 initializeSftpSession 内部发送 - }); + .then(() => console.log(`SFTP: 会话 ${newSessionId} 异步初始化成功。`)) + .catch(sftpInitError => console.error(`WebSocket: 会话 ${newSessionId} 异步初始化 SFTP 失败:`, sftpInitError)); console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`); - statusMonitorService.startStatusPolling(newSessionId); // 启动状态轮询 + statusMonitorService.startStatusPolling(newSessionId); } catch (shellError: any) { console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, shellError); + // 记录审计日志:打开 Shell 失败 + auditLogService.logAction('SSH_SHELL_FAILURE', { + userId: ws.userId, + username: ws.username, + connectionId: dbConnectionId, + sessionId: newSessionId, + ip: newState.ipAddress, + reason: shellError.message + }); ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${shellError.message}` })); - cleanupClientConnection(newSessionId); // 打开 Shell 失败也需要清理 + cleanupClientConnection(newSessionId); } - // 7. 设置 SSH Client 的关闭和错误处理 + // 8. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后) sshClient.on('close', () => { console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`); - // Shell 关闭事件通常会先触发清理,这里作为保险 cleanupClientConnection(newSessionId); }); sshClient.on('error', (err: Error) => { @@ -255,9 +265,16 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH }); } catch (connectError: any) { - console.error(`WebSocket: 用户 ${ws.username} 连接到数据库 ID ${dbConnectionId} 失败:`, connectError); + console.error(`WebSocket: 用户 ${ws.username} (IP: ${clientIp}) 连接到数据库 ID ${dbConnectionId} 失败:`, connectError); + // 记录审计日志:SSH 连接失败 + auditLogService.logAction('SSH_CONNECT_FAILURE', { + userId: ws.userId, + username: ws.username, + connectionId: dbConnectionId, + ip: clientIp, + reason: connectError.message + }); ws.send(JSON.stringify({ type: 'ssh:error', payload: `连接失败: ${connectError.message}` })); - // 此处不需要 cleanup,因为状态尚未创建 } break; } // end case 'ssh:connect' @@ -293,92 +310,73 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH case 'sftp:readdir': case 'sftp:stat': case 'sftp:readfile': - case 'sftp:writefile': // Added missing case + case 'sftp:writefile': case 'sftp:mkdir': case 'sftp:rmdir': case 'sftp:unlink': case 'sftp:rename': case 'sftp:chmod': - case 'sftp:realpath': { // Add realpath case + case 'sftp:realpath': { if (!sessionId || !state) { console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`); - // 尝试包含 requestId 发送错误,如果 requestId 存在的话 const errPayload: { message: string; requestId?: string } = { message: '无效的会话' }; if (requestId) errPayload.requestId = requestId; ws.send(JSON.stringify({ type: 'sftp_error', payload: errPayload })); return; } - - // --- 添加 Request ID 检查 --- - // 对于需要响应关联的操作,强制要求 requestId if (!requestId) { console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 SFTP 请求 (${type}),但缺少 requestId。`); ws.send(JSON.stringify({ type: 'sftp_error', payload: { message: `SFTP 操作 ${type} 缺少 requestId` } })); - return; // 没有 requestId 则不继续处理 + return; } - // --- 结束 Request ID 检查 --- - - // Explicitly call SftpService methods based on type + // TODO: 在这里或 SftpService 内部添加 SFTP 操作的审计日志记录 (可选) + // 例如: auditLogService.logAction('SFTP_ACTION', { type, path: payload?.path, userId: ws.userId, ip: state.ipAddress }); try { switch (type) { case 'sftp:readdir': - if (payload?.path) { - sftpService.readdir(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for readdir"); } + if (payload?.path) sftpService.readdir(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for readdir"); break; case 'sftp:stat': - if (payload?.path) { - sftpService.stat(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for stat"); } + if (payload?.path) sftpService.stat(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for stat"); break; case 'sftp:readfile': - if (payload?.path) { - sftpService.readFile(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for readfile"); } + if (payload?.path) sftpService.readFile(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for readfile"); break; case 'sftp:writefile': - // Handle both 'data' (from potential future upload refactor) and 'content' - const fileContent = payload?.content ?? payload?.data ?? ''; // Default to empty string for create + const fileContent = payload?.content ?? payload?.data ?? ''; if (payload?.path) { - // Ensure content is base64 encoded if needed (assuming frontend sends base64 for now) - // If creating empty file, data might be empty string, Buffer.from('') is fine. - const dataToSend = (typeof fileContent === 'string') ? fileContent : ''; // Ensure it's a string + const dataToSend = (typeof fileContent === 'string') ? fileContent : ''; sftpService.writefile(sessionId, payload.path, dataToSend, requestId); - } else { throw new Error("Missing 'path' in payload for writefile"); } + } else throw new Error("Missing 'path' in payload for writefile"); break; case 'sftp:mkdir': - if (payload?.path) { - sftpService.mkdir(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for mkdir"); } + if (payload?.path) sftpService.mkdir(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for mkdir"); break; case 'sftp:rmdir': - if (payload?.path) { - sftpService.rmdir(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for rmdir"); } + if (payload?.path) sftpService.rmdir(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for rmdir"); break; case 'sftp:unlink': - if (payload?.path) { - sftpService.unlink(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for unlink"); } + if (payload?.path) sftpService.unlink(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for unlink"); break; case 'sftp:rename': - if (payload?.oldPath && payload?.newPath) { - sftpService.rename(sessionId, payload.oldPath, payload.newPath, requestId); - } else { throw new Error("Missing 'oldPath' or 'newPath' in payload for rename"); } + if (payload?.oldPath && payload?.newPath) sftpService.rename(sessionId, payload.oldPath, payload.newPath, requestId); + else throw new Error("Missing 'oldPath' or 'newPath' in payload for rename"); break; case 'sftp:chmod': - if (payload?.path && typeof payload?.mode === 'number') { - sftpService.chmod(sessionId, payload.path, payload.mode, requestId); - } else { throw new Error("Missing 'path' or invalid 'mode' in payload for chmod"); } + if (payload?.path && typeof payload?.mode === 'number') sftpService.chmod(sessionId, payload.path, payload.mode, requestId); + else throw new Error("Missing 'path' or invalid 'mode' in payload for chmod"); break; - case 'sftp:realpath': // Add realpath handler - if (payload?.path) { - sftpService.realpath(sessionId, payload.path, requestId); - } else { throw new Error("Missing 'path' in payload for realpath"); } + case 'sftp:realpath': + if (payload?.path) sftpService.realpath(sessionId, payload.path, requestId); + else throw new Error("Missing 'path' in payload for realpath"); break; - default: - // Should not happen if already checked type, but as a safeguard - throw new Error(`Unhandled SFTP type: ${type}`); + default: throw new Error(`Unhandled SFTP type: ${type}`); } } catch (sftpCallError: any) { console.error(`WebSocket: Error preparing/calling SFTP service for ${type} (Request ID: ${requestId}):`, sftpCallError); @@ -386,7 +384,7 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH } break; } - // --- SFTP 文件上传 (委托给 SftpService) --- + // --- SFTP 文件上传 --- case 'sftp:upload:start': { if (!sessionId || !state) { console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`); @@ -398,34 +396,27 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId, remotePath 或 size' } })); return; } + // TODO: Add audit log for SFTP upload start? sftpService.startUpload(sessionId, payload.uploadId, payload.remotePath, payload.size); break; } case 'sftp:upload:chunk': { - if (!sessionId || !state) { - // Don't warn repeatedly for chunks if session is gone - return; - } + if (!sessionId || !state) return; if (!payload?.uploadId || typeof payload?.chunkIndex !== 'number' || !payload?.data) { console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId, chunkIndex 或 data。`); - // Avoid flooding with errors for every chunk if something is wrong - // Consider sending a single error and potentially cancelling on the service side return; } - // Assuming data is base64 encoded string from frontend sftpService.handleUploadChunk(sessionId, payload.uploadId, payload.chunkIndex, payload.data); break; } case 'sftp:upload:cancel': { - if (!sessionId || !state) { - // Don't warn if session is already gone - return; - } + if (!sessionId || !state) return; if (!payload?.uploadId) { console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId。`); ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId' } })); return; } + // TODO: Add audit log for SFTP upload cancel? sftpService.cancelUpload(sessionId, payload.uploadId); break; } @@ -437,20 +428,18 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH } catch (error: any) { console.error(`WebSocket: 处理来自 ${ws.username} (会话: ${sessionId}) 的消息 (${type}) 时发生顶层错误:`, error); ws.send(JSON.stringify({ type: 'error', payload: `处理消息时发生内部错误: ${error.message}` })); - // 考虑是否需要清理连接?取决于错误的性质 - // cleanupClientConnection(sessionId); } }); // --- 连接关闭和错误处理 --- ws.on('close', (code, reason) => { console.log(`WebSocket:客户端 ${ws.username} (会话: ${ws.sessionId}) 已断开连接。代码: ${code}, 原因: ${reason.toString()}`); - cleanupClientConnection(ws.sessionId); // 使用会话 ID 清理 + cleanupClientConnection(ws.sessionId); }); ws.on('error', (error) => { console.error(`WebSocket:客户端 ${ws.username} (会话: ${ws.sessionId}) 发生错误:`, error); - cleanupClientConnection(ws.sessionId); // 使用会话 ID 清理 + cleanupClientConnection(ws.sessionId); }); }); @@ -458,7 +447,6 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH wss.on('close', () => { console.log('WebSocket 服务器正在关闭,清理心跳定时器和所有活动会话...'); clearInterval(heartbeatInterval); - // 关闭所有活动的连接 clientStates.forEach((state, sessionId) => { cleanupClientConnection(sessionId); }); @@ -470,11 +458,3 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH }; // --- 移除旧的辅助函数 --- -// - connectSshClient -// - fetchServerStatus -// - executeSshCommand -// - startStatusPolling -// - cleanupSshConnection (旧版本) -// - activeSshConnections Map -// - activeUploads Map -// - previousNetStats Map diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index fd999cf66f7d68c884c090f65697f818e9872822..79b8b2972edb1b9d9354338c445891e07317db0f 100644 GIT binary patch literal 110592 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 delta 1920 zcmbVMYfMvT7(QRyLoZN1kYT69a2Q6dNa=+Zyf6hRy%$3mXekG13q9qUIVnz2 z2j0qZoGb*q47Mn!WNL=f#bw!#{m^7?afw?r;n4D9GitJIKgf1Y3%a>2OZFx2Ir-k( z^FHtUp40a*sjrWFeDwxnM|5SqvZx@F~Iojz-=@g&d_aG7fgHlS(&X`T3Zo zrV3j)b#;E~leyUg^TX5ggJF95V00`Tn>rVp4pZY6kU_N>0W`AS^pP>fN?$xh_fJMo zzmxE!hsNeU8;V}|5ZjQjFZ9jEZl0h&n4~`)jt)=Km;33d(df}jQW3_S{Cuj<0&-M; zMUP#ZiQO7t5-yxPpMZ{zT&EAr&=)DDwxz%bhGP5vM;z7`40QMGA>5e3>d@-6cFgKD zn_uFes9o)(NW{wBm@VC3 z!{#OlaNrGUyaMEcWa`Tb@LdAn(AdKJ=VG@;=Z~JB zr>-(AVgn!3M<(gGo|$MYJWP$~!1j3i6WaN)f#}7X|K=@{mLyX@>cF;~6!Eu+fc+xE z76D(9nF;#$#`_tV^RbaxkaWU~_8YD2e<`SE3V_n1blx>ACBD%Pa*V z>3U(3WNT(5UHmHbwj79(Mex@IP8Aw?T`UDjktXrF)%`2Ee2GK?-bs#wc95PR8GrDo z%U1AFhO(j*ODyp7FJc*kCz48ekuZM)72FPv2^j_eZbRU8howG)L3n_nAn^)@!Q)wY^sl@`C!T;0)Oms^O|P`A?_ zw2-c<`fk~-?oL^;$-9V7uyW-tNQ3Zuga>aU@E1tK+wcy<;|eqOKhJfGiX$TPkKZyzeS0apeQOx*pQtu8YJYoBXDle#rM337>1~yzCEmACh)AR$b4@ri4g~NPqSd3I zPD8MIUv0Oyz*=tco2^>4J?O6~*Vt+|_Xaw3R-Z3V)ex#~_v<%1d@4h`3MYG&guY2* zuWv0YYVp}fkJ;hEUBxBd#R(%TobY-cWGHbMC|?{333;p=t}fxrnA zf%o79GvN`Kp2ra(5~g|t;qlnI@yNr+uk7$Ho{4!E&!QWF2uWX+u#(=y|4N}Lk`*ar zN_nwNQMftu$h$af^Dd76lt}&nfj`3sGwE%2p*)R)A{jhhp<3x(9Ias`4a7AHrHZ*} zSrM^20-qr8H~55!qG1-Fn*o>*P7*gm$T+fTku(H>&)_r0AqrQa94-JHb_tV&O!Z6V Sr|>Cbo` { {{ t('nav.connections') }} | {{ t('nav.proxies') }} | {{ t('nav.tags') }} | + {{ t('nav.notifications') }} | + {{ t('nav.auditLogs') }} | {{ t('nav.settings') }} | {{ t('nav.login') }} {{ t('nav.logout') }} diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index add8c45..6426c1d 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -46,6 +46,7 @@ const { readFile, // 暴露给 useFileEditor writeFile, // 暴露给 useFileEditor joinPath, // 从 composable 获取 joinPath + clearSftpError, // 导入清除错误的函数 } = useSftpActions(currentPath); // 传入 currentPath ref // 文件上传模块 @@ -84,6 +85,9 @@ const sortKey = ref('filename'); const sortDirection = ref<'asc' | 'desc'>('asc'); // 排序方向 const initialLoadDone = ref(false); // Track if the initial load has been triggered const isFetchingInitialPath = ref(false); // Track if fetching realpath +const isEditingPath = ref(false); // State for path editing mode +const pathInputRef = ref(null); // Ref for the path input element +const editablePath = ref(''); // Temp storage for the path being edited // --- Column Resizing State --- const tableRef = ref(null); @@ -273,6 +277,11 @@ const handleItemClick = (event: MouseEvent, item: FileListItem) => { } if (item.attrs.isDirectory) { + // 检查是否已在加载,防止快速重复点击 + if (isLoading.value) { + console.log('[文件管理器] 忽略目录点击,因为正在加载...'); + return; + } // 处理目录点击:导航 const newPath = item.filename === '..' ? currentPath.value.substring(0, currentPath.value.lastIndexOf('/')) || '/' @@ -596,6 +605,7 @@ const getColumnKeyByIndex = (index: number): keyof typeof colWidths.value | null }; const startResize = (event: MouseEvent, index: number) => { + event.stopPropagation(); // Stop the event from bubbling up to the th's click handler event.preventDefault(); // Prevent text selection during drag isResizing.value = true; resizingColumnIndex.value = index; @@ -639,21 +649,77 @@ const stopResize = () => { document.removeEventListener('mousemove', handleResize); document.removeEventListener('mouseup', stopResize); document.body.style.cursor = ''; // Reset cursor - document.body.style.userSelect = ''; // Reset text selection + document.body.style.userSelect = ''; // Reset text selection } }; +// --- Path Editing Logic --- +const startPathEdit = () => { + if (isLoading.value || !props.isConnected) return; // Don't allow edit while loading or disconnected + editablePath.value = currentPath.value; // Initialize input with current path + isEditingPath.value = true; + nextTick(() => { + pathInputRef.value?.focus(); // Focus the input after it becomes visible + pathInputRef.value?.select(); // Select the text + }); +}; + +const handlePathInput = async (event?: Event) => { + // Check if triggered by blur or Enter key + if (event && event instanceof KeyboardEvent && event.key !== 'Enter') { + return; // Ignore other key presses + } + + const newPath = editablePath.value.trim(); + isEditingPath.value = false; // Exit editing mode immediately + + if (newPath === currentPath.value || !newPath) { + return; // No change or empty path, do nothing + } + + console.log(`[文件管理器] 尝试导航到新路径: ${newPath}`); + // Call loadDirectory which handles path validation via backend + await loadDirectory(newPath); + + // If loadDirectory resulted in an error (handled within useSftpActions), + // the currentPath will not have changed, effectively reverting the UI. + // If successful, currentPath is updated by loadDirectory, and the UI reflects the new path. +}; + +const cancelPathEdit = () => { + isEditingPath.value = false; + // No need to reset editablePath, it will be set on next edit start +}; + +// Function to clear the error message - now calls the composable's function +const clearError = () => { + clearSftpError(); +}; +