update
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
|
||||
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<void> => {
|
||||
|
||||
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<void> => {
|
||||
// 不设置完整 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<void>
|
||||
|
||||
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<void>
|
||||
}
|
||||
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<void> =>
|
||||
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();
|
||||
|
||||
@@ -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 设置,生成密钥和二维码
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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<voi
|
||||
|
||||
// 将请求体传递给服务层处理
|
||||
const newConnection = await ConnectionService.createConnection(req.body);
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host });
|
||||
res.status(201).json({ message: '连接创建成功。', connection: newConnection });
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -107,6 +112,8 @@ export const updateConnection = async (req: Request, res: Response): Promise<voi
|
||||
if (!updatedConnection) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
} else {
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('CONNECTION_UPDATED', { connectionId, updatedFields: Object.keys(req.body) });
|
||||
res.status(200).json({ message: '连接更新成功。', connection: updatedConnection });
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -136,6 +143,8 @@ export const deleteConnection = async (req: Request, res: Response): Promise<voi
|
||||
if (!deleted) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
} else {
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('CONNECTION_DELETED', { connectionId });
|
||||
res.status(200).json({ message: '连接删除成功。' }); // 或使用 204 No Content
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -160,9 +169,13 @@ export const testConnection = async (req: Request, res: Response): Promise<void>
|
||||
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<vo
|
||||
const filename = `nexus-terminal-connections-${timestamp}.json`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
// 记录审计日志 - 使用数组长度
|
||||
auditLogService.logAction('CONNECTIONS_EXPORTED', { count: exportedData.length });
|
||||
res.status(200).json(exportedData);
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -213,6 +228,8 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
|
||||
});
|
||||
} else {
|
||||
// Complete success
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('CONNECTIONS_IMPORTED', { successCount: result.successCount, failureCount: result.failureCount });
|
||||
res.status(200).json({
|
||||
message: `导入成功完成。共导入 ${result.successCount} 条连接。`,
|
||||
successCount: result.successCount,
|
||||
|
||||
@@ -14,6 +14,8 @@ import sftpRouter from './sftp/sftp.routes';
|
||||
import proxyRoutes from './proxies/proxies.routes'; // 导入代理路由
|
||||
import tagsRouter from './tags/tags.routes'; // 导入标签路由
|
||||
import settingsRoutes from './settings/settings.routes'; // 导入设置路由
|
||||
import notificationRoutes from './notifications/notification.routes'; // 导入通知路由
|
||||
import auditRoutes from './audit/audit.routes'; // 导入审计路由
|
||||
import { initializeWebSocket } from './websocket';
|
||||
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入 IP 白名单中间件
|
||||
|
||||
@@ -21,6 +23,12 @@ import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入
|
||||
const app = express();
|
||||
const server = http.createServer(app); // 创建 HTTP 服务器实例
|
||||
|
||||
// --- 信任代理设置 (用于正确获取 req.ip) ---
|
||||
// 如果应用部署在反向代理后面,需要设置此项
|
||||
// 'true' 信任直接连接的代理;更安全的做法是配置具体的代理 IP 或子网
|
||||
app.set('trust proxy', true);
|
||||
// --- 结束信任代理设置 ---
|
||||
|
||||
// --- 会话存储设置 ---
|
||||
const SQLiteStore = connectSqlite3(session);
|
||||
const dbPath = path.resolve(__dirname, '../../data'); // 数据库目录路径
|
||||
@@ -92,6 +100,8 @@ app.use('/api/v1/sftp', sftpRouter);
|
||||
app.use('/api/v1/proxies', proxyRoutes); // 挂载代理相关的路由
|
||||
app.use('/api/v1/tags', tagsRouter); // 挂载标签相关的路由
|
||||
app.use('/api/v1/settings', settingsRoutes); // 挂载设置相关的路由
|
||||
app.use('/api/v1/notifications', notificationRoutes); // 挂载通知相关的路由
|
||||
app.use('/api/v1/audit-logs', auditRoutes); // 挂载审计日志相关的路由
|
||||
|
||||
// 状态检查接口
|
||||
app.get('/api/v1/status', (req: Request, res: Response) => {
|
||||
|
||||
@@ -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<void> => {
|
||||
try {
|
||||
// 创建 settings 表 (如果不存在)
|
||||
@@ -96,6 +189,74 @@ export const runMigrations = async (db: Database): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
// 创建 notification_settings 表 (如果不存在)
|
||||
await new Promise<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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);
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
const settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'> = 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<void> => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>> = 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<void> => {
|
||||
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<void> => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<ProxyService.ProxyData> | 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} 的代理进行删除` });
|
||||
|
||||
@@ -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, any> | string | null): Promise<void> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,6 @@ export const findFullConnectionById = async (id: number): Promise<any | null> =>
|
||||
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
|
||||
|
||||
@@ -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<NotificationSetting[]> {
|
||||
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<NotificationSetting | null> {
|
||||
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<NotificationSetting[]> {
|
||||
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<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
|
||||
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<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> {
|
||||
// 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<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, any> | string | null): Promise<void> {
|
||||
// 在这里可以添加额外的逻辑,例如:
|
||||
// - 检查是否需要记录此类型的日志 (基于配置)
|
||||
// - 格式化 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();
|
||||
@@ -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<IpBlacklistEntry | undefined> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
// 如果是本地 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<void>((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<void>((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<void> {
|
||||
try {
|
||||
await new Promise<void>((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<IpBlacklistEntry[]>((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<number>((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<void> {
|
||||
try {
|
||||
await new Promise<void>((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();
|
||||
@@ -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<NotificationSetting[]> {
|
||||
return this.repository.getAll();
|
||||
}
|
||||
|
||||
async getSettingById(id: number): Promise<NotificationSetting | null> {
|
||||
return this.repository.getById(id);
|
||||
}
|
||||
|
||||
async createSetting(settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
|
||||
// Add validation if needed
|
||||
return this.repository.create(settingData);
|
||||
}
|
||||
|
||||
async updateSetting(id: number, settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> {
|
||||
// Add validation if needed
|
||||
// Ensure password is not overwritten if not provided explicitly? Or handle in controller/route.
|
||||
// For now, we assume the full config (including potentially sensitive fields) is passed for updates if needed.
|
||||
return this.repository.update(id, settingData);
|
||||
}
|
||||
|
||||
async deleteSetting(id: number): Promise<boolean> {
|
||||
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: `<p>这是一封来自 <b>星枢终端 (Nexus Terminal)</b> 的测试邮件。</p><p>如果收到此邮件,表示您的 SMTP 配置工作正常。</p><p>时间: ${new Date().toISOString()}</p>`,
|
||||
};
|
||||
|
||||
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, any> | string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const config = setting.config as EmailConfig;
|
||||
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
|
||||
console.error(`[Notification] Email setting ID ${setting.id} is missing required SMTP configuration (to, smtpHost, smtpPort, from).`);
|
||||
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: `<p>${body.replace(/\n/g, '<br>')}</p>` // Simple HTML version
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[Notification] Sending Email via ${config.smtpHost}:${config.smtpPort} to ${config.to} for event ${payload.event}`);
|
||||
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<void> {
|
||||
const config = setting.config as TelegramConfig;
|
||||
if (!config.botToken || !config.chatId) {
|
||||
console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`);
|
||||
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();
|
||||
@@ -282,28 +282,53 @@ export class SftpService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除空目录 */
|
||||
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
|
||||
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<void> {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> { ... }
|
||||
// async setSetting(req: Request, res: Response): Promise<void> { ... }
|
||||
// async deleteSetting(req: Request, res: Response): Promise<void> { ... }
|
||||
|
||||
/**
|
||||
* 获取 IP 黑名单列表 (分页)
|
||||
*/
|
||||
async getIpBlacklist(req: Request, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> => {
|
||||
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
if (!deleted) {
|
||||
res.status(404).json({ message: '标签未找到。' });
|
||||
} else {
|
||||
// 记录审计日志
|
||||
auditLogService.logAction('TAG_DELETED', { tagId });
|
||||
res.status(200).json({ message: '标签删除成功。' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -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, any> | string | null;
|
||||
}
|
||||
@@ -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<string, string>; // 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, any> | string; // Contextual information about the event
|
||||
}
|
||||
@@ -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<string, ClientState>();
|
||||
|
||||
// --- 服务实例化 ---
|
||||
// 将 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
|
||||
|
||||
Binary file not shown.
@@ -21,6 +21,8 @@ const handleLogout = () => {
|
||||
<RouterLink to="/connections">{{ t('nav.connections') }}</RouterLink> |
|
||||
<RouterLink to="/proxies">{{ t('nav.proxies') }}</RouterLink> | <!-- 新增代理链接 -->
|
||||
<RouterLink to="/tags">{{ t('nav.tags') }}</RouterLink> | <!-- 新增标签链接 -->
|
||||
<RouterLink to="/notifications">{{ t('nav.notifications') }}</RouterLink> | <!-- 新增通知链接 -->
|
||||
<RouterLink to="/audit-logs">{{ t('nav.auditLogs') }}</RouterLink> | <!-- 新增审计日志链接 -->
|
||||
<RouterLink to="/settings">{{ t('nav.settings') }}</RouterLink> | <!-- 新增设置链接 -->
|
||||
<RouterLink v-if="!isAuthenticated" to="/login">{{ t('nav.login') }}</RouterLink>
|
||||
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout">{{ t('nav.logout') }}</a>
|
||||
|
||||
@@ -46,6 +46,7 @@ const {
|
||||
readFile, // 暴露给 useFileEditor
|
||||
writeFile, // 暴露给 useFileEditor
|
||||
joinPath, // 从 composable 获取 joinPath
|
||||
clearSftpError, // 导入清除错误的函数
|
||||
} = useSftpActions(currentPath); // 传入 currentPath ref
|
||||
|
||||
// 文件上传模块
|
||||
@@ -84,6 +85,9 @@ const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('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<HTMLInputElement | null>(null); // Ref for the path input element
|
||||
const editablePath = ref(''); // Temp storage for the path being edited
|
||||
|
||||
// --- Column Resizing State ---
|
||||
const tableRef = ref<HTMLTableElement | null>(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();
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-manager"> <!-- Removed @click handler -->
|
||||
<div class="file-manager">
|
||||
<div class="toolbar">
|
||||
<div class="path-bar">
|
||||
{{ t('fileManager.currentPath') }}: <strong>{{ currentPath }}</strong>
|
||||
<button @click="loadDirectory(currentPath)" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.refresh')">🔄</button>
|
||||
<span v-show="!isEditingPath">
|
||||
{{ t('fileManager.currentPath') }}: <strong @click="startPathEdit" :title="t('fileManager.editPathTooltip')" class="editable-path">{{ currentPath }}</strong>
|
||||
</span>
|
||||
<input
|
||||
v-show="isEditingPath"
|
||||
ref="pathInputRef"
|
||||
type="text"
|
||||
v-model="editablePath"
|
||||
class="path-input"
|
||||
@keyup.enter="handlePathInput"
|
||||
@blur="handlePathInput"
|
||||
@keyup.esc="cancelPathEdit"
|
||||
|
||||
/>
|
||||
<button @click.stop="loadDirectory(currentPath)" :disabled="isLoading || !isConnected || isEditingPath" :title="t('fileManager.actions.refresh')">🔄</button>
|
||||
<!-- Pass event to handleItemClick for '..' -->
|
||||
<button @click="handleItemClick($event, { filename: '..', longname: '', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })" :disabled="isLoading || !isConnected || currentPath === '/'" :title="t('fileManager.actions.parentDirectory')">⬆️</button>
|
||||
<button @click.stop="handleItemClick($event, { filename: '..', longname: '', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })" :disabled="isLoading || !isConnected || currentPath === '/' || isEditingPath" :title="t('fileManager.actions.parentDirectory')">⬆️</button>
|
||||
</div>
|
||||
<div class="actions-bar">
|
||||
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple style="display: none;" />
|
||||
@@ -672,17 +738,28 @@ const stopResize = () => {
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<!-- Error Alert Box -->
|
||||
<div v-if="error" class="error-alert">
|
||||
<span>{{ error }}</span>
|
||||
<button @click="clearError" class="close-error-btn" :title="t('common.dismiss')">×</button> <!-- Use clearSftpError -->
|
||||
</div>
|
||||
|
||||
<!-- 1. Initial Loading Indicator -->
|
||||
<div v-if="isLoading && !initialLoadDone" class="loading">{{ t('fileManager.loading') }}</div>
|
||||
|
||||
<!-- 2. Error Indicator -->
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<!-- 3. File Table (Show if not initial loading, no error, and there's something to display: either files or '..') -->
|
||||
<!-- 2. File Table (Show if not initial loading) -->
|
||||
<!-- Removed the error condition here, table shows regardless of error -->
|
||||
<table v-else-if="sortedFileList.length > 0 || currentPath !== '/'" ref="tableRef" class="resizable-table" @contextmenu.prevent>
|
||||
<!-- Temporarily removed colgroup for debugging -->
|
||||
<colgroup>
|
||||
<col :style="{ width: `${colWidths.type}px` }">
|
||||
<col :style="{ width: `${colWidths.name}px` }">
|
||||
<col :style="{ width: `${colWidths.size}px` }">
|
||||
<col :style="{ width: `${colWidths.permissions}px` }">
|
||||
<col :style="{ width: `${colWidths.modified}px` }">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Remove width style from th, controlled by colgroup -->
|
||||
<th @click="handleSort('type')" class="sortable">
|
||||
{{ t('fileManager.headers.type') }}
|
||||
<span v-if="sortKey === 'type'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
@@ -733,7 +810,11 @@ const stopResize = () => {
|
||||
</table>
|
||||
|
||||
<!-- 4. Empty Directory Message (Show if not initial loading, no error, list is empty, and at root) -->
|
||||
<div v-else class="no-files">{{ t('fileManager.emptyDirectory') }}</div>
|
||||
<!-- 3. Empty Directory Message (Show only if not loading AND list is empty AND not at root) -->
|
||||
<div v-else-if="!isLoading && sortedFileList.length === 0 && currentPath === '/'" class="no-files">{{ t('fileManager.emptyDirectory') }}</div>
|
||||
<!-- Note: If there's an error, the table will still render (potentially empty if initial load failed),
|
||||
but the error message will be shown above. The "Empty Directory" message
|
||||
is now only shown if explicitly empty and not loading. -->
|
||||
</div>
|
||||
|
||||
<!-- 使用 FileUploadPopup 组件 -->
|
||||
@@ -775,8 +856,28 @@ const stopResize = () => {
|
||||
/* Styles remain the same, but add .selected style */
|
||||
.file-manager { height: 100%; display: flex; flex-direction: column; font-family: sans-serif; font-size: 0.9rem; overflow: hidden; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; background-color: #f0f0f0; border-bottom: 1px solid #ccc; flex-wrap: wrap; }
|
||||
.path-bar { white-space: nowrap; overflow-x: auto; flex-grow: 1; margin-right: 1rem; }
|
||||
.path-bar button { margin-left: 0.5rem; background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 0.1rem 0.3rem; }
|
||||
.path-bar { white-space: nowrap; overflow-x: auto; flex-grow: 1; margin-right: 1rem; padding: 0.2rem 0.4rem; border-radius: 3px; } /* Remove cursor:text and hover */
|
||||
.path-bar strong.editable-path {
|
||||
font-weight: normal;
|
||||
background-color: #e0e0e0;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.3rem;
|
||||
cursor: text; /* Add cursor only to the clickable part */
|
||||
}
|
||||
.path-bar strong.editable-path:hover {
|
||||
background-color: #d0d0d0; /* Slightly darker hover for the path */
|
||||
}
|
||||
.path-input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
width: calc(100% - 70px); /* Adjust width based on button sizes */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.path-bar button { margin-left: 0.5rem; background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 0.1rem 0.3rem; vertical-align: middle; }
|
||||
.path-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.actions-bar button { padding: 0.3rem 0.6rem; cursor: pointer; margin-left: 0.5rem; }
|
||||
.actions-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
@@ -787,8 +888,29 @@ const stopResize = () => {
|
||||
.upload-popup progress { margin: 0 0.5rem; width: 80px; height: 0.8em; }
|
||||
.upload-popup .error { color: red; margin-left: 0.5rem; flex-basis: 100%; font-size: 0.8em; }
|
||||
.upload-popup .cancel-btn { margin-left: auto; padding: 0.1rem 0.4rem; font-size: 0.8em; background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; cursor: pointer; }
|
||||
.loading, .error, .no-files { padding: 1rem; text-align: center; color: #666; }
|
||||
.error { color: red; }
|
||||
.loading, .no-files { padding: 1rem; text-align: center; color: #666; }
|
||||
/* Removed .error style for the main container */
|
||||
.error-alert {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
padding: 0.75rem 1.25rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.close-error-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.file-list-container { flex-grow: 1; overflow-y: auto; position: relative; /* Needed for overlay */ }
|
||||
.file-list-container.drag-over {
|
||||
outline: 2px dashed #007bff; /* Blue dashed outline */
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="notification-setting-form">
|
||||
<h3>{{ isEditing ? $t('settings.notifications.form.editTitle') : $t('settings.notifications.form.addTitle') }}</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="setting-name" class="form-label">{{ $t('settings.notifications.form.name') }}</label>
|
||||
<input type="text" id="setting-name" v-model="formData.name" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" id="setting-enabled" v-model="formData.enabled" class="form-check-input">
|
||||
<label for="setting-enabled" class="form-check-label">{{ $t('common.enabled') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="setting-channel-type" class="form-label">{{ $t('settings.notifications.form.channelType') }}</label>
|
||||
<select id="setting-channel-type" v-model="formData.channel_type" class="form-select" required :disabled="isEditing">
|
||||
<option value="webhook">{{ $t('settings.notifications.types.webhook') }}</option>
|
||||
<option value="email">{{ $t('settings.notifications.types.email') }}</option>
|
||||
<option value="telegram">{{ $t('settings.notifications.types.telegram') }}</option>
|
||||
</select>
|
||||
<small v-if="isEditing" class="text-muted">{{ $t('settings.notifications.form.channelTypeEditNote') }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Channel Specific Config -->
|
||||
<div v-if="formData.channel_type === 'webhook'" class="channel-config mb-3 p-3 border rounded">
|
||||
<h4>{{ $t('settings.notifications.types.webhook') }} {{ $t('common.settings') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="webhook-url" class="form-label">URL</label>
|
||||
<input type="url" id="webhook-url" v-model="webhookConfig.url" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="webhook-method" class="form-label">{{ $t('settings.notifications.form.webhookMethod') }}</label>
|
||||
<select id="webhook-method" v-model="webhookConfig.method" class="form-select">
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="PUT">PUT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="webhook-headers" class="form-label">{{ $t('settings.notifications.form.webhookHeaders') }} (JSON)</label>
|
||||
<textarea id="webhook-headers" v-model="webhookHeadersString" class="form-control" rows="3" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer ..."}'></textarea>
|
||||
<small v-if="headerError" class="text-danger">{{ headerError }}</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="webhook-body" class="form-label">{{ $t('settings.notifications.form.webhookBodyTemplate') }}</label>
|
||||
<textarea id="webhook-body" v-model="webhookConfig.bodyTemplate" class="form-control" rows="3" :placeholder="$t('settings.notifications.form.webhookBodyPlaceholder')"></textarea>
|
||||
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.channel_type === 'email'" class="channel-config mb-3 p-3 border rounded">
|
||||
<h4>{{ $t('settings.notifications.types.email') }} {{ $t('common.settings') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="email-to" class="form-label">{{ $t('settings.notifications.form.emailTo') }}</label>
|
||||
<input type="email" id="email-to" v-model="emailConfig.to" class="form-control" required placeholder="recipient1@example.com, recipient2@example.com">
|
||||
<small class="text-muted">{{ $t('settings.notifications.form.emailToHelp') }}</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email-subject" class="form-label">{{ $t('settings.notifications.form.emailSubjectTemplate') }}</label>
|
||||
<input type="text" id="email-subject" v-model="emailConfig.subjectTemplate" class="form-control" :placeholder="$t('settings.notifications.form.emailSubjectPlaceholder')">
|
||||
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
|
||||
</div>
|
||||
<!-- SMTP Settings -->
|
||||
<div class="mb-3">
|
||||
<label for="smtp-host" class="form-label">{{ $t('settings.notifications.form.smtpHost') }}</label>
|
||||
<input type="text" id="smtp-host" v-model="emailConfig.smtpHost" class="form-control" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="smtp-port" class="form-label">{{ $t('settings.notifications.form.smtpPort') }}</label>
|
||||
<input type="number" id="smtp-port" v-model.number="emailConfig.smtpPort" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="smtp-secure" v-model="emailConfig.smtpSecure" class="form-check-input">
|
||||
<label for="smtp-secure" class="form-check-label">{{ $t('settings.notifications.form.smtpSecure') }} (TLS)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="smtp-user" class="form-label">{{ $t('settings.notifications.form.smtpUser') }}</label>
|
||||
<input type="text" id="smtp-user" v-model="emailConfig.smtpUser" class="form-control">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="smtp-pass" class="form-label">{{ $t('settings.notifications.form.smtpPass') }}</label>
|
||||
<input type="password" id="smtp-pass" v-model="emailConfig.smtpPass" class="form-control">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="smtp-from" class="form-label">{{ $t('settings.notifications.form.smtpFrom') }}</label>
|
||||
<input type="email" id="smtp-from" v-model="emailConfig.from" class="form-control" required placeholder="sender@example.com">
|
||||
<small class="text-muted">{{ $t('settings.notifications.form.smtpFromHelp') }}</small>
|
||||
</div>
|
||||
<button type="button" @click="handleTestNotification" class="btn btn-outline-secondary btn-sm" :disabled="!isEditing || testingNotification">
|
||||
<span v-if="testingNotification" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
{{ testingNotification ? $t('common.testing') : $t('settings.notifications.form.testButton') }}
|
||||
</button>
|
||||
<small v-if="testResult" :class="['d-block mt-2', testResult.success ? 'text-success' : 'text-danger']">{{ testResult.message }}</small>
|
||||
<small v-if="!isEditing" class="d-block mt-2 text-muted">{{ $t('settings.notifications.form.saveToTest') }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.channel_type === 'telegram'" class="channel-config mb-3 p-3 border rounded">
|
||||
<h4>{{ $t('settings.notifications.types.telegram') }} {{ $t('common.settings') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="telegram-token" class="form-label">{{ $t('settings.notifications.form.telegramToken') }}</label>
|
||||
<input type="password" id="telegram-token" v-model="telegramConfig.botToken" class="form-control" required>
|
||||
<small class="text-muted">{{ $t('settings.notifications.form.telegramTokenHelp') }}</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="telegram-chatid" class="form-label">{{ $t('settings.notifications.form.telegramChatId') }}</label>
|
||||
<input type="text" id="telegram-chatid" v-model="telegramConfig.chatId" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="telegram-message" class="form-label">{{ $t('settings.notifications.form.telegramMessageTemplate') }}</label>
|
||||
<textarea id="telegram-message" v-model="telegramConfig.messageTemplate" class="form-control" rows="3" :placeholder="$t('settings.notifications.form.telegramMessagePlaceholder')"></textarea>
|
||||
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enabled Events -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ $t('settings.notifications.form.enabledEvents') }}</label>
|
||||
<div class="row">
|
||||
<div v-for="event in allNotificationEvents" :key="event" class="col-md-4 col-sm-6">
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="'event-' + event"
|
||||
:value="event"
|
||||
v-model="formData.enabled_events"
|
||||
class="form-check-input"
|
||||
>
|
||||
<label :for="'event-' + event" class="form-check-label">{{ getEventDisplayName(event) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="button" @click="handleCancel" class="btn btn-secondary me-2">{{ $t('common.cancel') }}</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="store.isLoading || !!headerError || testingNotification">
|
||||
{{ store.isLoading ? $t('common.saving') : $t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="formError" class="alert alert-danger mt-3">{{ formError }}</div>
|
||||
<div v-if="testError" class="alert alert-danger mt-3">{{ testError }}</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, PropType, nextTick } from 'vue';
|
||||
import { useNotificationsStore } from '../stores/notifications.store';
|
||||
import {
|
||||
NotificationSetting,
|
||||
NotificationSettingData,
|
||||
NotificationChannelType,
|
||||
NotificationEvent,
|
||||
WebhookConfig,
|
||||
EmailConfig, // Keep this, but we'll add SMTP fields
|
||||
TelegramConfig
|
||||
} from '../types/server.types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// Extend EmailConfig for SMTP fields
|
||||
interface SmtpEmailConfig extends EmailConfig {
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpSecure?: boolean;
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
from?: string; // Add 'from' address
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
type: Object as PropType<NotificationSetting | null>,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save', 'cancel']);
|
||||
|
||||
const store = useNotificationsStore();
|
||||
const { t } = useI18n();
|
||||
const formError = ref<string | null>(null);
|
||||
const headerError = ref<string | null>(null);
|
||||
const testError = ref<string | null>(null);
|
||||
const testingNotification = ref(false);
|
||||
const testResult = ref<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
|
||||
const isEditing = computed(() => !!props.initialData?.id);
|
||||
|
||||
// Define all possible events
|
||||
const allNotificationEvents: 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'
|
||||
];
|
||||
|
||||
// Reactive form data structure
|
||||
const getDefaultFormData = (): Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at' | 'config'> & { config: any } => ({
|
||||
name: '',
|
||||
enabled: true,
|
||||
channel_type: 'webhook',
|
||||
config: {}, // Will be populated based on channel_type
|
||||
enabled_events: ['LOGIN_FAILURE', 'SERVER_ERROR'], // Sensible defaults
|
||||
});
|
||||
|
||||
const formData = reactive(getDefaultFormData());
|
||||
|
||||
// Specific config refs for easier v-model binding
|
||||
const webhookConfig = ref<WebhookConfig>({ url: '', method: 'POST', headers: {}, bodyTemplate: '' });
|
||||
const emailConfig = ref<SmtpEmailConfig>({ // Use extended type
|
||||
to: '',
|
||||
subjectTemplate: '',
|
||||
smtpHost: '',
|
||||
smtpPort: 587, // Default port
|
||||
smtpSecure: true, // Default to true (TLS)
|
||||
smtpUser: '',
|
||||
smtpPass: '',
|
||||
from: ''
|
||||
});
|
||||
const telegramConfig = ref<TelegramConfig>({ botToken: '', chatId: '', messageTemplate: '' });
|
||||
const webhookHeadersString = ref('{}'); // For textarea binding
|
||||
|
||||
// Watch for initialData changes (when editing)
|
||||
watch(() => props.initialData, (newData) => {
|
||||
if (newData) {
|
||||
Object.assign(formData, newData);
|
||||
// Populate specific config refs based on channel type
|
||||
if (newData.channel_type === 'webhook') {
|
||||
webhookConfig.value = { ...(newData.config as WebhookConfig) };
|
||||
webhookHeadersString.value = JSON.stringify(webhookConfig.value.headers || {}, null, 2);
|
||||
} else if (newData.channel_type === 'email') {
|
||||
// Ensure all fields are present, providing defaults if missing from saved config
|
||||
const savedConfig = newData.config as SmtpEmailConfig;
|
||||
emailConfig.value = {
|
||||
to: savedConfig.to || '',
|
||||
subjectTemplate: savedConfig.subjectTemplate || '',
|
||||
smtpHost: savedConfig.smtpHost || '',
|
||||
smtpPort: savedConfig.smtpPort || 587,
|
||||
smtpSecure: savedConfig.smtpSecure === undefined ? true : savedConfig.smtpSecure, // Default true if undefined
|
||||
smtpUser: savedConfig.smtpUser || '',
|
||||
smtpPass: savedConfig.smtpPass || '', // Password might not be sent back, handle appropriately
|
||||
from: savedConfig.from || ''
|
||||
};
|
||||
} else if (newData.channel_type === 'telegram') {
|
||||
telegramConfig.value = { ...(newData.config as TelegramConfig) };
|
||||
}
|
||||
} else {
|
||||
// Reset form if initialData becomes null (e.g., switching from edit to add)
|
||||
Object.assign(formData, getDefaultFormData());
|
||||
webhookConfig.value = { url: '', method: 'POST', headers: {}, bodyTemplate: '' };
|
||||
// Reset email config with defaults
|
||||
emailConfig.value = {
|
||||
to: '', subjectTemplate: '', smtpHost: '', smtpPort: 587, smtpSecure: true, smtpUser: '', smtpPass: '', from: ''
|
||||
};
|
||||
telegramConfig.value = { botToken: '', chatId: '', messageTemplate: '' };
|
||||
webhookHeadersString.value = '{}';
|
||||
}
|
||||
headerError.value = null; // Reset header error on data change
|
||||
testError.value = null; // Reset test error
|
||||
testResult.value = null; // Reset test result
|
||||
testingNotification.value = false; // Reset testing state
|
||||
}, { immediate: true });
|
||||
|
||||
// Watch channel type change to reset specific config
|
||||
watch(() => formData.channel_type, (newType, oldType) => {
|
||||
if (newType !== oldType && !isEditing.value) { // Only reset if not editing or type changes during add mode
|
||||
webhookConfig.value = { url: '', method: 'POST', headers: {}, bodyTemplate: '' };
|
||||
emailConfig.value = {
|
||||
to: '', subjectTemplate: '', smtpHost: '', smtpPort: 587, smtpSecure: true, smtpUser: '', smtpPass: '', from: ''
|
||||
};
|
||||
telegramConfig.value = { botToken: '', chatId: '', messageTemplate: '' };
|
||||
webhookHeadersString.value = '{}';
|
||||
headerError.value = null;
|
||||
testError.value = null;
|
||||
testResult.value = null;
|
||||
testingNotification.value = false;
|
||||
}
|
||||
// Always reset test state when type changes
|
||||
testError.value = null;
|
||||
testResult.value = null;
|
||||
testingNotification.value = false;
|
||||
});
|
||||
|
||||
// Watch header string to validate JSON
|
||||
watch(webhookHeadersString, (newVal) => {
|
||||
if (formData.channel_type !== 'webhook') return;
|
||||
try {
|
||||
const parsed = JSON.parse(newVal || '{}');
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('Headers must be a JSON object.');
|
||||
}
|
||||
webhookConfig.value.headers = parsed;
|
||||
headerError.value = null;
|
||||
} catch (e: any) {
|
||||
headerError.value = t('settings.notifications.form.invalidJson') + `: ${e.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for changes in email config to clear previous test results
|
||||
watch(emailConfig, () => {
|
||||
testResult.value = null;
|
||||
testError.value = null;
|
||||
}, { deep: true });
|
||||
|
||||
|
||||
const getEventDisplayName = (event: NotificationEvent): string => {
|
||||
// Use i18n key, fallback to formatted name if key not found
|
||||
const i18nKey = `settings.notifications.events.${event}`;
|
||||
const translated = t(i18nKey);
|
||||
// If translation returns the key itself, it means translation is missing
|
||||
if (translated === i18nKey) {
|
||||
console.warn(`Missing translation for notification event: ${i18nKey}`);
|
||||
return event.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); // Fallback
|
||||
}
|
||||
return translated;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
formError.value = null;
|
||||
if (headerError.value) return; // Don't submit if headers are invalid
|
||||
|
||||
// Assign the correct config based on channel type
|
||||
if (formData.channel_type === 'webhook') {
|
||||
formData.config = webhookConfig.value;
|
||||
} else if (formData.channel_type === 'email') {
|
||||
formData.config = emailConfig.value;
|
||||
} else if (formData.channel_type === 'telegram') {
|
||||
formData.config = telegramConfig.value;
|
||||
}
|
||||
|
||||
let result: NotificationSetting | null = null;
|
||||
if (isEditing.value && props.initialData?.id) {
|
||||
result = await store.updateSetting(props.initialData.id, formData);
|
||||
} else {
|
||||
result = await store.addSetting(formData);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
emit('save', result);
|
||||
} else {
|
||||
formError.value = store.error || t('common.errorOccurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
if (!props.initialData?.id || formData.channel_type !== 'email') return;
|
||||
|
||||
testingNotification.value = true;
|
||||
testError.value = null;
|
||||
testResult.value = null;
|
||||
|
||||
// Use the current form values for testing, even if not saved yet
|
||||
const testConfig: SmtpEmailConfig = { ...emailConfig.value };
|
||||
|
||||
try {
|
||||
const result = await store.testSetting(props.initialData.id, testConfig);
|
||||
testResult.value = { success: true, message: result.message || t('settings.notifications.form.testSuccess') };
|
||||
} catch (error: any) {
|
||||
console.error("Test notification error:", error);
|
||||
const message = error?.response?.data?.message || error.message || t('settings.notifications.form.testFailed');
|
||||
testResult.value = { success: false, message: message };
|
||||
// Optionally set testError if you want a separate display area for errors vs results
|
||||
// testError.value = message;
|
||||
} finally {
|
||||
testingNotification.value = false;
|
||||
// Automatically clear the result message after a few seconds
|
||||
await nextTick(); // Ensure DOM is updated before setting timeout
|
||||
setTimeout(() => {
|
||||
testResult.value = null;
|
||||
}, 5000); // Clear after 5 seconds
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-setting-form {
|
||||
padding: 1.5rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.3rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.channel-config h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Add more specific styling if needed */
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="notification-settings">
|
||||
<h2>{{ $t('settings.notifications.title') }}</h2>
|
||||
|
||||
<div v-if="store.isLoading" class="loading-indicator">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<div v-if="store.error" class="error-message">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!store.isLoading && !store.error">
|
||||
<button @click="showAddForm = true" class="btn btn-primary mb-3">
|
||||
{{ $t('settings.notifications.addChannel') }}
|
||||
</button>
|
||||
|
||||
<div v-if="settings.length === 0" class="alert alert-info">
|
||||
{{ $t('settings.notifications.noChannels') }}
|
||||
</div>
|
||||
|
||||
<ul v-else class="list-group">
|
||||
<li v-for="setting in settings" :key="setting.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong class="me-2">{{ setting.name }}</strong>
|
||||
<span class="badge bg-secondary me-1">{{ getChannelTypeName(setting.channel_type) }}</span>
|
||||
<span :class="['badge', setting.enabled ? 'bg-success' : 'bg-warning']">
|
||||
{{ setting.enabled ? $t('common.enabled') : $t('common.disabled') }}
|
||||
</span>
|
||||
<small class="d-block text-muted">{{ getEventNames(setting.enabled_events) }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<button @click="editSetting(setting)" class="btn btn-sm btn-outline-secondary me-2">
|
||||
{{ $t('common.edit') }}
|
||||
</button>
|
||||
<button @click="confirmDelete(setting)" class="btn btn-sm btn-outline-danger">
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Form Modal (Placeholder - will create NotificationSettingForm.vue next) -->
|
||||
<div v-if="showAddForm || editingSetting" class="modal-placeholder">
|
||||
<!-- Use a simple conditional rendering for the form for now -->
|
||||
<!-- TODO: Consider using a proper modal component for better UX -->
|
||||
<NotificationSettingForm
|
||||
v-if="showAddForm || editingSetting"
|
||||
:initial-data="editingSetting"
|
||||
@save="handleSave"
|
||||
@cancel="closeForm"
|
||||
class="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useNotificationsStore } from '../stores/notifications.store';
|
||||
import { NotificationSetting, NotificationChannelType, NotificationEvent } from '../types/server.types';
|
||||
import NotificationSettingForm from './NotificationSettingForm.vue'; // Import the form component
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const store = useNotificationsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const showAddForm = ref(false);
|
||||
const editingSetting = ref<NotificationSetting | null>(null);
|
||||
|
||||
const settings = computed(() => store.settings);
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchSettings();
|
||||
});
|
||||
|
||||
const getChannelTypeName = (type: NotificationChannelType): string => {
|
||||
switch (type) {
|
||||
case 'webhook': return t('settings.notifications.types.webhook');
|
||||
case 'email': return t('settings.notifications.types.email');
|
||||
case 'telegram': return t('settings.notifications.types.telegram');
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventNames = (events: NotificationEvent[]): string => {
|
||||
if (!events || events.length === 0) return t('settings.notifications.noEventsEnabled');
|
||||
// TODO: Translate event names if needed
|
||||
return t('settings.notifications.triggers') + ': ' + events.join(', ');
|
||||
};
|
||||
|
||||
const editSetting = (setting: NotificationSetting) => {
|
||||
editingSetting.value = { ...setting }; // Clone to avoid modifying store directly
|
||||
showAddForm.value = false; // Ensure add form is hidden
|
||||
};
|
||||
|
||||
const confirmDelete = (setting: NotificationSetting) => {
|
||||
if (setting.id && confirm(t('settings.notifications.confirmDelete', { name: setting.name }))) {
|
||||
store.deleteSetting(setting.id);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
showAddForm.value = false;
|
||||
editingSetting.value = null;
|
||||
};
|
||||
|
||||
// TODO: Implement save logic when form component is ready
|
||||
const handleSave = (savedSetting: NotificationSetting) => {
|
||||
console.log('Setting saved:', savedSetting);
|
||||
closeForm();
|
||||
// The store should have updated the list automatically after add/update
|
||||
// Optionally, you could force a refresh if needed: store.fetchSettings();
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-settings {
|
||||
padding: 1rem;
|
||||
}
|
||||
.loading-indicator, .error-message {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-message {
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
.modal-placeholder {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed #ccc;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
@@ -37,6 +37,11 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
const isLoading = ref<boolean>(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Function to clear the error state
|
||||
const clearSftpError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
// --- Action Methods ---
|
||||
|
||||
const loadDirectory = (path: string) => {
|
||||
@@ -227,9 +232,9 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
const onSftpReaddirError = (payload: string, message: WebSocketMessage) => {
|
||||
if (message.path === currentPathRef.value) {
|
||||
console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload);
|
||||
error.value = payload;
|
||||
error.value = payload; // Set the error message
|
||||
isLoading.value = false;
|
||||
fileList.value = []; // Clear list on error
|
||||
// Do NOT clear fileList.value here, keep the previous list visible
|
||||
}
|
||||
};
|
||||
|
||||
@@ -316,5 +321,6 @@ export function useSftpActions(currentPathRef: Ref<string>) {
|
||||
readFile, // Expose if needed by editor composable
|
||||
writeFile, // Expose if needed by editor composable
|
||||
joinPath, // Expose helper if needed externally
|
||||
clearSftpError, // Expose the clear error function
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"tags": "Tags",
|
||||
"notifications": "Notifications",
|
||||
"auditLogs": "Audit Logs",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"login": {
|
||||
@@ -240,7 +242,8 @@
|
||||
"loadingFile": "Loading file...",
|
||||
"saving": "Saving",
|
||||
"saveSuccess": "Save successful",
|
||||
"saveError": "Save error"
|
||||
"saveError": "Save error",
|
||||
"editPathTooltip": "Click path to edit"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tag Management",
|
||||
@@ -358,10 +361,132 @@
|
||||
"success": {
|
||||
"registered": "Passkey registered successfully!"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notification Settings",
|
||||
"addChannel": "Add Notification Channel",
|
||||
"noChannels": "No notification channels configured yet.",
|
||||
"triggers": "Triggers",
|
||||
"noEventsEnabled": "No events enabled",
|
||||
"confirmDelete": "Are you sure you want to delete the notification channel \"{name}\"? This cannot be undone.",
|
||||
"types": {
|
||||
"webhook": "Webhook",
|
||||
"email": "Email",
|
||||
"telegram": "Telegram"
|
||||
},
|
||||
"form": {
|
||||
"addTitle": "Add Notification Channel",
|
||||
"editTitle": "Edit Notification Channel",
|
||||
"name": "Channel Name:",
|
||||
"channelType": "Channel Type:",
|
||||
"channelTypeEditNote": "Channel type cannot be changed after creation.",
|
||||
"webhookMethod": "HTTP Method:",
|
||||
"webhookHeaders": "Custom Headers",
|
||||
"webhookBodyTemplate": "Body Template (Optional)",
|
||||
"webhookBodyPlaceholder": "Default: JSON payload. Use {{event}}, {{timestamp}}, {{details}}.",
|
||||
"emailTo": "Recipient Email(s):",
|
||||
"emailToHelp": "Comma-separated list.",
|
||||
"emailSubjectTemplate": "Subject Template (Optional)",
|
||||
"emailSubjectPlaceholder": "Default: Notification: {{event}}",
|
||||
"smtpHost": "SMTP Host:",
|
||||
"smtpPort": "SMTP Port:",
|
||||
"smtpSecure": "Use TLS/SSL",
|
||||
"smtpUser": "SMTP Username:",
|
||||
"smtpPass": "SMTP Password:",
|
||||
"smtpFrom": "Sender Email:",
|
||||
"smtpFromHelp": "Email address used in the 'From' field.",
|
||||
"testButton": "Test Notification",
|
||||
"testSuccess": "Test email sent successfully!",
|
||||
"testFailed": "Test email failed",
|
||||
"saveToTest": "Save the settings before testing.",
|
||||
"telegramToken": "Bot Token:",
|
||||
"telegramTokenHelp": "Store securely. Consider environment variables.",
|
||||
"telegramChatId": "Chat ID:",
|
||||
"telegramMessageTemplate": "Message Template (Optional)",
|
||||
"telegramMessagePlaceholder": "Default: Markdown format. Use {{event}}, {{timestamp}}, {{details}}.",
|
||||
"enabledEvents": "Enabled Events:",
|
||||
"templateHelp": "Placeholders: {{event}}, {{timestamp}}, {{details}} (JSON string)",
|
||||
"invalidJson": "Invalid JSON"
|
||||
},
|
||||
"events": {
|
||||
"LOGIN_SUCCESS": "Login Success",
|
||||
"LOGIN_FAILURE": "Login Failure",
|
||||
"CONNECTION_ADDED": "Connection Added",
|
||||
"CONNECTION_UPDATED": "Connection Updated",
|
||||
"CONNECTION_DELETED": "Connection Deleted",
|
||||
"SETTINGS_UPDATED": "Settings Updated",
|
||||
"PROXY_ADDED": "Proxy Added",
|
||||
"PROXY_UPDATED": "Proxy Updated",
|
||||
"PROXY_DELETED": "Proxy Deleted",
|
||||
"TAG_ADDED": "Tag Added",
|
||||
"TAG_UPDATED": "Tag Updated",
|
||||
"TAG_DELETED": "Tag Deleted",
|
||||
"API_KEY_ADDED": "API Key Added",
|
||||
"API_KEY_DELETED": "API Key Deleted",
|
||||
"PASSKEY_ADDED": "Passkey Added",
|
||||
"PASSKEY_DELETED": "Passkey Deleted",
|
||||
"SERVER_ERROR": "Server Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"testing": "Testing...",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"settings": "Settings",
|
||||
"errorOccurred": "An error occurred.",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Audit Logs",
|
||||
"noLogs": "No audit logs found.",
|
||||
"table": {
|
||||
"timestamp": "Timestamp",
|
||||
"actionType": "Action Type",
|
||||
"details": "Details"
|
||||
},
|
||||
"paginationInfo": "Page {currentPage} of {totalPages} ({totalLogs} total logs)",
|
||||
"actions": {
|
||||
"LOGIN_SUCCESS": "Login Success",
|
||||
"LOGIN_FAILURE": "Login Failure",
|
||||
"LOGOUT": "Logout",
|
||||
"PASSWORD_CHANGED": "Password Changed",
|
||||
"2FA_ENABLED": "2FA Enabled",
|
||||
"2FA_DISABLED": "2FA Disabled",
|
||||
"PASSKEY_REGISTERED": "Passkey Registered",
|
||||
"PASSKEY_DELETED": "Passkey Deleted",
|
||||
"CONNECTION_CREATED": "Connection Created",
|
||||
"CONNECTION_UPDATED": "Connection Updated",
|
||||
"CONNECTION_DELETED": "Connection Deleted",
|
||||
"CONNECTION_TESTED": "Connection Tested",
|
||||
"CONNECTIONS_IMPORTED": "Connections Imported",
|
||||
"CONNECTIONS_EXPORTED": "Connections Exported",
|
||||
"PROXY_CREATED": "Proxy Created",
|
||||
"PROXY_UPDATED": "Proxy Updated",
|
||||
"PROXY_DELETED": "Proxy Deleted",
|
||||
"TAG_CREATED": "Tag Created",
|
||||
"TAG_UPDATED": "Tag Updated",
|
||||
"TAG_DELETED": "Tag Deleted",
|
||||
"SETTINGS_UPDATED": "Settings Updated",
|
||||
"IP_WHITELIST_UPDATED": "IP Whitelist Updated",
|
||||
"NOTIFICATION_SETTING_CREATED": "Notification Setting Created",
|
||||
"NOTIFICATION_SETTING_UPDATED": "Notification Setting Updated",
|
||||
"NOTIFICATION_SETTING_DELETED": "Notification Setting Deleted",
|
||||
"API_KEY_CREATED": "API Key Created",
|
||||
"API_KEY_DELETED": "API Key Deleted",
|
||||
"SFTP_ACTION": "SFTP Action",
|
||||
"SSH_CONNECT_SUCCESS": "SSH Connection Successful",
|
||||
"SSH_CONNECT_FAILURE": "SSH Connection Failed",
|
||||
"SSH_SHELL_FAILURE": "SSH Shell Open Failed",
|
||||
"SERVER_STARTED": "Server Started",
|
||||
"SERVER_ERROR": "Server Error",
|
||||
"DATABASE_MIGRATION": "Database Migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
"tags": "标签管理",
|
||||
"notifications": "通知管理",
|
||||
"auditLogs": "审计日志",
|
||||
"settings": "设置"
|
||||
},
|
||||
"login": {
|
||||
@@ -17,7 +19,8 @@
|
||||
"loggingIn": "正在登录...",
|
||||
"error": "登录失败,请检查用户名或密码。",
|
||||
"twoFactorPrompt": "请输入两步验证码:",
|
||||
"verifyButton": "验证"
|
||||
"verifyButton": "验证",
|
||||
"rememberMe": "记住我 (7 天)"
|
||||
},
|
||||
"connections": {
|
||||
"title": "连接管理",
|
||||
@@ -243,7 +246,8 @@
|
||||
"loadingFile": "正在加载文件...",
|
||||
"saving": "正在保存",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveError": "保存出错"
|
||||
"saveError": "保存出错",
|
||||
"editPathTooltip": "点击路径进行编辑"
|
||||
},
|
||||
"tags": {
|
||||
"title": "标签管理",
|
||||
@@ -361,10 +365,132 @@
|
||||
"success": {
|
||||
"registered": "Passkey 注册成功!"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知设置",
|
||||
"addChannel": "添加通知渠道",
|
||||
"noChannels": "尚未配置任何通知渠道。",
|
||||
"triggers": "触发事件",
|
||||
"noEventsEnabled": "未启用任何事件",
|
||||
"confirmDelete": "确定要删除通知渠道 \"{name}\" 吗?此操作不可撤销。",
|
||||
"types": {
|
||||
"webhook": "Webhook",
|
||||
"email": "邮件",
|
||||
"telegram": "Telegram"
|
||||
},
|
||||
"form": {
|
||||
"addTitle": "添加通知渠道",
|
||||
"editTitle": "编辑通知渠道",
|
||||
"name": "渠道名称:",
|
||||
"channelType": "渠道类型:",
|
||||
"channelTypeEditNote": "创建后无法修改渠道类型。",
|
||||
"webhookMethod": "HTTP 方法:",
|
||||
"webhookHeaders": "自定义 Headers",
|
||||
"webhookBodyTemplate": "请求体模板 (可选)",
|
||||
"webhookBodyPlaceholder": "默认: JSON 格式负载。可使用 {{event}}, {{timestamp}}, {{details}}。",
|
||||
"emailTo": "收件人邮箱:",
|
||||
"emailToHelp": "多个邮箱用逗号分隔。",
|
||||
"emailSubjectTemplate": "邮件主题模板 (可选)",
|
||||
"emailSubjectPlaceholder": "默认: 通知: {{event}}",
|
||||
"smtpHost": "SMTP 主机:",
|
||||
"smtpPort": "SMTP 端口:",
|
||||
"smtpSecure": "使用 TLS/SSL",
|
||||
"smtpUser": "SMTP 用户名:",
|
||||
"smtpPass": "SMTP 密码:",
|
||||
"smtpFrom": "发件人邮箱:",
|
||||
"smtpFromHelp": "用于邮件 'From' 字段的地址。",
|
||||
"testButton": "测试通知",
|
||||
"testSuccess": "测试邮件发送成功!",
|
||||
"testFailed": "测试邮件发送失败",
|
||||
"saveToTest": "请先保存设置再进行测试。",
|
||||
"telegramToken": "机器人 Token:",
|
||||
"telegramTokenHelp": "请安全存储。建议使用环境变量。",
|
||||
"telegramChatId": "聊天 ID:",
|
||||
"telegramMessageTemplate": "消息模板 (可选)",
|
||||
"telegramMessagePlaceholder": "默认: Markdown 格式。可使用 {{event}}, {{timestamp}}, {{details}}。",
|
||||
"enabledEvents": "启用的事件:",
|
||||
"templateHelp": "可用占位符: {{event}}, {{timestamp}}, {{details}} (JSON 字符串)",
|
||||
"invalidJson": "无效的 JSON 格式"
|
||||
},
|
||||
"events": {
|
||||
"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 密钥已添加",
|
||||
"API_KEY_DELETED": "API 密钥已删除",
|
||||
"PASSKEY_ADDED": "Passkey 已添加",
|
||||
"PASSKEY_DELETED": "Passkey 已删除",
|
||||
"SERVER_ERROR": "服务器错误"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"testing": "测试中...",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"settings": "设置",
|
||||
"errorOccurred": "发生错误。",
|
||||
"dismiss": "关闭"
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "审计日志",
|
||||
"noLogs": "未找到审计日志记录。",
|
||||
"table": {
|
||||
"timestamp": "时间戳",
|
||||
"actionType": "操作类型",
|
||||
"details": "详细信息"
|
||||
},
|
||||
"paginationInfo": "第 {currentPage} 页 / 共 {totalPages} 页 (总计 {totalLogs} 条记录)",
|
||||
"actions": {
|
||||
"LOGIN_SUCCESS": "登录成功",
|
||||
"LOGIN_FAILURE": "登录失败",
|
||||
"LOGOUT": "登出",
|
||||
"PASSWORD_CHANGED": "密码已修改",
|
||||
"2FA_ENABLED": "两步验证已启用",
|
||||
"2FA_DISABLED": "两步验证已禁用",
|
||||
"PASSKEY_REGISTERED": "Passkey 已注册",
|
||||
"PASSKEY_DELETED": "Passkey 已删除",
|
||||
"CONNECTION_CREATED": "连接已创建",
|
||||
"CONNECTION_UPDATED": "连接已更新",
|
||||
"CONNECTION_DELETED": "连接已删除",
|
||||
"CONNECTION_TESTED": "连接已测试",
|
||||
"CONNECTIONS_IMPORTED": "连接已导入",
|
||||
"CONNECTIONS_EXPORTED": "连接已导出",
|
||||
"PROXY_CREATED": "代理已创建",
|
||||
"PROXY_UPDATED": "代理已更新",
|
||||
"PROXY_DELETED": "代理已删除",
|
||||
"TAG_CREATED": "标签已创建",
|
||||
"TAG_UPDATED": "标签已更新",
|
||||
"TAG_DELETED": "标签已删除",
|
||||
"SETTINGS_UPDATED": "设置已更新",
|
||||
"IP_WHITELIST_UPDATED": "IP 白名单已更新",
|
||||
"NOTIFICATION_SETTING_CREATED": "通知设置已创建",
|
||||
"NOTIFICATION_SETTING_UPDATED": "通知设置已更新",
|
||||
"NOTIFICATION_SETTING_DELETED": "通知设置已删除",
|
||||
"API_KEY_CREATED": "API 密钥已创建",
|
||||
"API_KEY_DELETED": "API 密钥已删除",
|
||||
"SFTP_ACTION": "SFTP 操作",
|
||||
"SSH_CONNECT_SUCCESS": "SSH 连接成功",
|
||||
"SSH_CONNECT_FAILURE": "SSH 连接失败",
|
||||
"SSH_SHELL_FAILURE": "SSH Shell 打开失败",
|
||||
"SERVER_STARTED": "服务器已启动",
|
||||
"SERVER_ERROR": "服务器错误",
|
||||
"DATABASE_MIGRATION": "数据库迁移"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,18 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: 'Settings',
|
||||
component: () => import('../views/SettingsView.vue')
|
||||
},
|
||||
// 新增:通知管理页面
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('../views/NotificationsView.vue')
|
||||
},
|
||||
// 新增:审计日志页面
|
||||
{
|
||||
path: '/audit-logs',
|
||||
name: 'AuditLogs',
|
||||
component: () => import('../views/AuditLogView.vue')
|
||||
},
|
||||
// 其他路由...
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { AuditLogEntry, AuditLogApiResponse, AuditLogActionType } from '../types/server.types';
|
||||
|
||||
export const useAuditLogStore = defineStore('auditLog', () => {
|
||||
const logs = ref<AuditLogEntry[]>([]);
|
||||
const totalLogs = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const currentPage = ref(1);
|
||||
const logsPerPage = ref(50); // Default page size
|
||||
|
||||
const fetchLogs = async (page: number = 1, filters: { actionType?: AuditLogActionType, startDate?: number, endDate?: number } = {}) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
currentPage.value = page;
|
||||
const offset = (page - 1) * logsPerPage.value;
|
||||
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
limit: logsPerPage.value,
|
||||
offset: offset,
|
||||
...filters // Spread filter parameters
|
||||
};
|
||||
// Remove undefined filter values
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
|
||||
|
||||
const response = await axios.get<AuditLogApiResponse>('/api/v1/audit-logs', { params });
|
||||
logs.value = response.data.logs;
|
||||
totalLogs.value = response.data.total;
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching audit logs:', err);
|
||||
error.value = err.response?.data?.message || '获取审计日志失败';
|
||||
logs.value = [];
|
||||
totalLogs.value = 0;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to change page size and refetch
|
||||
const setLogsPerPage = (size: number) => {
|
||||
logsPerPage.value = size;
|
||||
fetchLogs(1); // Reset to first page when size changes
|
||||
};
|
||||
|
||||
return {
|
||||
logs,
|
||||
totalLogs,
|
||||
isLoading,
|
||||
error,
|
||||
currentPage,
|
||||
logsPerPage,
|
||||
fetchLogs,
|
||||
setLogsPerPage,
|
||||
};
|
||||
});
|
||||
@@ -9,6 +9,13 @@ interface UserInfo {
|
||||
isTwoFactorEnabled?: boolean; // 后端 /status 接口会返回这个
|
||||
}
|
||||
|
||||
// 新增:登录请求的载荷接口
|
||||
interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
rememberMe?: boolean; // 可选的“记住我”标志
|
||||
}
|
||||
|
||||
// Auth Store State 接口
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
@@ -16,6 +23,11 @@ interface AuthState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
|
||||
// 新增:存储 IP 黑名单数据
|
||||
ipBlacklist: {
|
||||
entries: any[]; // TODO: Define a proper type for blacklist entries
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -25,20 +37,22 @@ export const useAuthStore = defineStore('auth', {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loginRequires2FA: false, // 初始为不需要
|
||||
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
|
||||
}),
|
||||
getters: {
|
||||
// 可以添加一些 getter,例如获取用户名
|
||||
loggedInUser: (state) => state.user?.username,
|
||||
},
|
||||
actions: {
|
||||
// 登录 Action
|
||||
async login(credentials: { username: string; password: string }) {
|
||||
// 登录 Action - 更新为接受 LoginPayload
|
||||
async login(payload: LoginPayload) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.loginRequires2FA = false; // 重置 2FA 状态
|
||||
try {
|
||||
// 后端可能返回 user 或 requiresTwoFactor
|
||||
const response = await axios.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/api/v1/auth/login', credentials);
|
||||
// 将完整的 payload (包含 rememberMe) 发送给后端
|
||||
const response = await axios.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/api/v1/auth/login', payload);
|
||||
|
||||
if (response.data.requiresTwoFactor) {
|
||||
// 需要 2FA 验证
|
||||
@@ -198,6 +212,56 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// --- IP 黑名单管理 Actions ---
|
||||
/**
|
||||
* 获取 IP 黑名单列表
|
||||
* @param limit 每页数量
|
||||
* @param offset 偏移量
|
||||
*/
|
||||
async fetchIpBlacklist(limit: number = 50, offset: number = 0) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await axios.get('/api/v1/settings/ip-blacklist', {
|
||||
params: { limit, offset }
|
||||
});
|
||||
// 注意:这里需要将获取到的数据存储在 state 中,
|
||||
// 但当前 AuthState 没有定义相关字段。
|
||||
// 暂时只返回数据,需要在 state 中添加 ipBlacklist 字段。
|
||||
console.log('获取 IP 黑名单成功:', response.data);
|
||||
return response.data; // { entries: [], total: number }
|
||||
} catch (err: any) {
|
||||
console.error('获取 IP 黑名单失败:', err);
|
||||
this.error = err.response?.data?.message || err.message || '获取 IP 黑名单时发生未知错误。';
|
||||
// 确保抛出 Error 时提供字符串消息
|
||||
throw new Error(this.error ?? '获取 IP 黑名单时发生未知错误。');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 从 IP 黑名单中删除一个 IP
|
||||
* @param ip 要删除的 IP 地址
|
||||
*/
|
||||
async deleteIpFromBlacklist(ip: string) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await axios.delete(`/api/v1/settings/ip-blacklist/${encodeURIComponent(ip)}`);
|
||||
console.log(`IP ${ip} 已从黑名单删除`);
|
||||
// 成功后需要重新获取列表或从本地 state 中移除
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.error(`删除 IP ${ip} 失败:`, err);
|
||||
this.error = err.response?.data?.message || err.message || '删除 IP 时发生未知错误。';
|
||||
// 确保抛出 Error 时提供字符串消息
|
||||
throw new Error(this.error ?? '删除 IP 时发生未知错误。');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true, // 使用默认持久化配置 (localStorage, 持久化所有 state)
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from 'axios'; // Assuming axios is globally available or installed
|
||||
import { NotificationSetting, NotificationSettingData } from '../types/server.types';
|
||||
|
||||
export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const settings = ref<NotificationSetting[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await axios.get<NotificationSetting[]>('/api/v1/notifications');
|
||||
settings.value = response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching notification settings:', err);
|
||||
error.value = err.response?.data?.message || '获取通知设置失败';
|
||||
settings.value = []; // Clear settings on error
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addSetting = async (settingData: NotificationSettingData): Promise<NotificationSetting | null> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await axios.post<NotificationSetting>('/api/v1/notifications', settingData);
|
||||
settings.value.push(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Error adding notification setting:', err);
|
||||
error.value = err.response?.data?.message || '添加通知设置失败';
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = async (id: number, settingData: Partial<NotificationSettingData>): Promise<NotificationSetting | null> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await axios.put<NotificationSetting>(`/api/v1/notifications/${id}`, settingData);
|
||||
const index = settings.value.findIndex(s => s.id === id);
|
||||
if (index !== -1) {
|
||||
settings.value[index] = response.data;
|
||||
} else {
|
||||
// If not found locally, maybe fetch again or just add it
|
||||
settings.value.push(response.data);
|
||||
}
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error(`Error updating notification setting ${id}:`, err);
|
||||
error.value = err.response?.data?.message || '更新通知设置失败';
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSetting = async (id: number): Promise<boolean> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await axios.delete(`/api/v1/notifications/${id}`);
|
||||
settings.value = settings.value.filter(s => s.id !== id);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.error(`Error deleting notification setting ${id}:`, err);
|
||||
error.value = err.response?.data?.message || '删除通知设置失败';
|
||||
return false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const testSetting = async (id: number, config: any): Promise<{ success: boolean; message: string }> => {
|
||||
// Note: We don't set isLoading here as it might interfere with the main form submission state.
|
||||
// The component handles its own 'testingNotification' state.
|
||||
error.value = null; // Clear previous general errors
|
||||
try {
|
||||
// Send the config to test in the request body
|
||||
const response = await axios.post<{ message: string }>(`/api/v1/notifications/${id}/test`, { config });
|
||||
return { success: true, message: response.data.message || '测试成功' };
|
||||
} catch (err: any) {
|
||||
console.error(`Error testing notification setting ${id}:`, err);
|
||||
// Don't set the main 'error' ref here, let the component handle test-specific errors/results.
|
||||
// Throw the error so the component's catch block can handle it.
|
||||
throw err; // Re-throw the error to be caught in the component
|
||||
}
|
||||
// No finally block needed here as loading state is managed in the component
|
||||
};
|
||||
|
||||
|
||||
// Computed property to get settings by type (example)
|
||||
const webhookSettings = computed(() => settings.value.filter(s => s.channel_type === 'webhook'));
|
||||
const emailSettings = computed(() => settings.value.filter(s => s.channel_type === 'email'));
|
||||
const telegramSettings = computed(() => settings.value.filter(s => s.channel_type === 'telegram'));
|
||||
|
||||
return {
|
||||
settings,
|
||||
isLoading,
|
||||
error,
|
||||
fetchSettings,
|
||||
addSetting,
|
||||
updateSetting,
|
||||
deleteSetting,
|
||||
testSetting, // Add the new function here
|
||||
webhookSettings,
|
||||
emailSettings,
|
||||
telegramSettings,
|
||||
};
|
||||
});
|
||||
@@ -13,3 +13,93 @@ export interface ServerStatus {
|
||||
}
|
||||
|
||||
// 可以根据需要添加其他与服务器或连接状态相关的类型
|
||||
|
||||
// --- Notification Settings Types (Mirrors backend/src/types/notification.types.ts) ---
|
||||
|
||||
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';
|
||||
|
||||
export interface WebhookConfig {
|
||||
url: string;
|
||||
method?: 'POST' | 'GET' | 'PUT';
|
||||
headers?: Record<string, string>;
|
||||
bodyTemplate?: string;
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
to: string;
|
||||
subjectTemplate?: string;
|
||||
// SMTP settings are global on the backend
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
botToken: string; // Consider masking this in the UI
|
||||
chatId: string;
|
||||
messageTemplate?: string;
|
||||
}
|
||||
|
||||
export type NotificationChannelConfig = WebhookConfig | EmailConfig | TelegramConfig;
|
||||
|
||||
export interface NotificationSetting {
|
||||
id?: number;
|
||||
channel_type: NotificationChannelType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: NotificationChannelConfig;
|
||||
enabled_events: NotificationEvent[];
|
||||
created_at?: number | string; // Represented as string or number (timestamp)
|
||||
updated_at?: number | string;
|
||||
}
|
||||
|
||||
// Helper type for creating/updating settings, omitting generated fields
|
||||
export type NotificationSettingData = Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>;
|
||||
|
||||
// --- Audit Log Types (Mirrors backend/src/types/audit.types.ts) ---
|
||||
|
||||
// Keep action types aligned with backend for potential filtering
|
||||
export type AuditLogActionType =
|
||||
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
|
||||
| '2FA_ENABLED' | '2FA_DISABLED' | 'PASSKEY_REGISTERED' | 'PASSKEY_DELETED'
|
||||
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED' | 'CONNECTION_TESTED'
|
||||
| 'CONNECTIONS_IMPORTED' | 'CONNECTIONS_EXPORTED'
|
||||
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
|
||||
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
|
||||
| 'SETTINGS_UPDATED' | 'IP_WHITELIST_UPDATED'
|
||||
| 'NOTIFICATION_SETTING_CREATED' | 'NOTIFICATION_SETTING_UPDATED' | 'NOTIFICATION_SETTING_DELETED'
|
||||
| 'API_KEY_CREATED' | 'API_KEY_DELETED'
|
||||
| 'SFTP_ACTION'
|
||||
| 'SERVER_STARTED' | 'SERVER_ERROR' | 'DATABASE_MIGRATION';
|
||||
|
||||
// Structure for a single log entry received from the API
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
timestamp: number; // Unix timestamp (seconds)
|
||||
action_type: AuditLogActionType;
|
||||
details: Record<string, any> | { raw: string; parseError: boolean } | null; // Parsed JSON or raw string with error flag
|
||||
}
|
||||
|
||||
// Structure for the API response when fetching logs
|
||||
export interface AuditLogApiResponse {
|
||||
logs: AuditLogEntry[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="audit-log-view">
|
||||
<h1>{{ $t('auditLog.title') }}</h1>
|
||||
|
||||
<!-- TODO: Add filtering options (Action Type, Date Range) -->
|
||||
|
||||
<div v-if="store.isLoading" class="loading-indicator">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<div v-if="store.error" class="error-message">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!store.isLoading && !store.error">
|
||||
<div v-if="logs.length === 0" class="alert alert-info">
|
||||
{{ $t('auditLog.noLogs') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('auditLog.table.timestamp') }}</th>
|
||||
<th>{{ $t('auditLog.table.actionType') }}</th>
|
||||
<th>{{ $t('auditLog.table.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td>{{ formatTimestamp(log.timestamp) }}</td>
|
||||
<td>{{ translateActionType(log.action_type) }}</td>
|
||||
<td>
|
||||
<pre v-if="log.details">{{ formatDetails(log.details) }}</pre>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Audit Log Pagination" v-if="totalPages > 1">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="changePage(currentPage - 1)">«</a>
|
||||
</li>
|
||||
<li v-for="page in paginationRange" :key="page" class="page-item" :class="{ active: page === currentPage, 'disabled': page === '...' }">
|
||||
<a v-if="page !== '...'" class="page-link" href="#" @click.prevent="changePage(page as number)">{{ page }}</a>
|
||||
<span v-else class="page-link">...</span>
|
||||
</li>
|
||||
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="changePage(currentPage + 1)">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="text-center text-muted mt-2">
|
||||
{{ $t('auditLog.paginationInfo', { currentPage, totalPages, totalLogs }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useAuditLogStore } from '../stores/audit.store';
|
||||
import { AuditLogEntry, AuditLogActionType } from '../types/server.types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const store = useAuditLogStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const logs = computed(() => store.logs);
|
||||
const totalLogs = computed(() => store.totalLogs);
|
||||
const currentPage = computed(() => store.currentPage);
|
||||
const logsPerPage = computed(() => store.logsPerPage);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(totalLogs.value / logsPerPage.value));
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchLogs();
|
||||
});
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
// Convert seconds to milliseconds for Date constructor
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const translateActionType = (actionType: AuditLogActionType): string => {
|
||||
// Attempt to translate using a convention like auditLog.actions.ACTION_TYPE
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
// If translation is missing, return the original type
|
||||
return translated === key ? actionType : translated;
|
||||
};
|
||||
|
||||
const formatDetails = (details: AuditLogEntry['details']): string => {
|
||||
if (!details) return '';
|
||||
if (typeof details === 'object' && details !== null) {
|
||||
if ('raw' in details && details.parseError) {
|
||||
return `[Parse Error] Raw: ${details.raw}`;
|
||||
}
|
||||
return JSON.stringify(details, null, 2); // Pretty print JSON
|
||||
}
|
||||
return String(details); // Should ideally not happen if backend sends JSON string
|
||||
};
|
||||
|
||||
const changePage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
|
||||
store.fetchLogs(page);
|
||||
}
|
||||
};
|
||||
|
||||
// Simple pagination range logic (can be improved for many pages)
|
||||
const paginationRange = computed(() => {
|
||||
const range: (number | string)[] = [];
|
||||
const delta = 2; // Number of pages around current page
|
||||
const left = currentPage.value - delta;
|
||||
const right = currentPage.value + delta + 1;
|
||||
let l: number | null = null; // Keep track of the last number added
|
||||
|
||||
for (let i = 1; i <= totalPages.value; i++) {
|
||||
if (i === 1 || i === totalPages.value || (i >= left && i < right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const result: (number | string)[] = [];
|
||||
for (const pageNum of range) {
|
||||
// Ensure pageNum is treated as number for comparison/arithmetic
|
||||
const currentNum = pageNum as number;
|
||||
if (l !== null) {
|
||||
// Calculate difference explicitly as numbers
|
||||
if (currentNum - l === 2) {
|
||||
result.push(l + 1);
|
||||
} else if (currentNum - l > 1) { // Check if difference is greater than 1
|
||||
result.push('...');
|
||||
}
|
||||
}
|
||||
result.push(currentNum);
|
||||
l = currentNum; // Store the current number
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-log-view {
|
||||
padding: 20px;
|
||||
}
|
||||
.loading-indicator, .error-message {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.error-message {
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap; /* Allow wrapping */
|
||||
word-break: break-all; /* Break long strings */
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ const credentials = reactive({
|
||||
password: '',
|
||||
});
|
||||
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
||||
const rememberMe = ref(false); // 新增:记住我状态,默认为 false
|
||||
|
||||
// 处理登录或 2FA 验证提交
|
||||
const handleSubmit = async () => {
|
||||
@@ -22,8 +23,8 @@ const handleSubmit = async () => {
|
||||
// 如果需要 2FA,则调用 2FA 验证 action
|
||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
||||
} else {
|
||||
// 否则,调用常规登录 action
|
||||
await authStore.login(credentials);
|
||||
// 否则,调用常规登录 action,并传递 rememberMe 状态
|
||||
await authStore.login({ ...credentials, rememberMe: rememberMe.value });
|
||||
}
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
@@ -45,6 +46,11 @@ const handleSubmit = async () => {
|
||||
<label for="password">{{ t('login.password') }}:</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
|
||||
</div>
|
||||
<!-- 新增:记住我复选框 -->
|
||||
<div class="form-group form-group-checkbox">
|
||||
<input type="checkbox" id="rememberMe" v-model="rememberMe" :disabled="isLoading" />
|
||||
<label for="rememberMe">{{ t('login.rememberMe') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA 验证码输入 -->
|
||||
@@ -93,6 +99,26 @@ h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Specific style for checkbox group */
|
||||
.form-group-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem; /* Keep consistent margin */
|
||||
}
|
||||
|
||||
.form-group-checkbox input[type="checkbox"] {
|
||||
width: auto; /* Override default width */
|
||||
margin-right: 0.5rem; /* Space between checkbox and label */
|
||||
accent-color: #007bff; /* Optional: Style the checkmark color */
|
||||
}
|
||||
|
||||
.form-group-checkbox label {
|
||||
margin-bottom: 0; /* Remove bottom margin for inline label */
|
||||
font-weight: normal; /* Optional: Make label less bold */
|
||||
cursor: pointer; /* Indicate it's clickable */
|
||||
}
|
||||
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="notifications-view">
|
||||
<!-- <h1>{{ $t('nav.notifications') }}</h1> --> <!-- Add nav.notifications to i18n later -->
|
||||
<h1>通知管理</h1> <!-- Temporary title -->
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NotificationSettings from '../components/NotificationSettings.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notifications-view {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -99,15 +99,86 @@
|
||||
|
||||
<!-- 其他设置项可以在这里添加 -->
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- IP 黑名单管理 -->
|
||||
<div class="settings-section">
|
||||
<h2>IP 黑名单管理</h2>
|
||||
<p>配置登录失败次数限制和自动封禁时长。本地地址 (127.0.0.1, ::1) 不会被封禁。</p>
|
||||
|
||||
<!-- 黑名单配置表单 -->
|
||||
<form @submit.prevent="handleUpdateBlacklistSettings" class="blacklist-settings-form">
|
||||
<div class="form-group inline-group">
|
||||
<label for="maxLoginAttempts">最大失败次数:</label>
|
||||
<input type="number" id="maxLoginAttempts" v-model="blacklistSettings.maxLoginAttempts" min="1" required>
|
||||
</div>
|
||||
<div class="form-group inline-group">
|
||||
<label for="loginBanDuration">封禁时长 (秒):</label>
|
||||
<input type="number" id="loginBanDuration" v-model="blacklistSettings.loginBanDuration" min="1" required>
|
||||
</div>
|
||||
<button type="submit" :disabled="blacklistSettings.loading">{{ blacklistSettings.loading ? '保存中...' : '保存配置' }}</button>
|
||||
<p v-if="blacklistSettings.message" :class="{ 'success-message': blacklistSettings.success, 'error-message': !blacklistSettings.success }">{{ blacklistSettings.message }}</p>
|
||||
</form>
|
||||
|
||||
<hr style="margin-top: 20px; margin-bottom: 20px;">
|
||||
|
||||
<h3>当前已封禁的 IP 地址</h3>
|
||||
<div v-if="ipBlacklist.loading" class="loading-message">正在加载黑名单...</div>
|
||||
<div v-if="ipBlacklist.error" class="error-message">{{ ipBlacklist.error }}</div>
|
||||
|
||||
<div v-if="!ipBlacklist.loading && !ipBlacklist.error">
|
||||
<table v-if="ipBlacklist.entries.length > 0" class="blacklist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP 地址</th>
|
||||
<th>失败次数</th>
|
||||
<th>最后尝试时间</th>
|
||||
<th>封禁截止时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in ipBlacklist.entries" :key="entry.ip">
|
||||
<td>{{ entry.ip }}</td>
|
||||
<td>{{ entry.attempts }}</td>
|
||||
<td>{{ new Date(entry.last_attempt_at * 1000).toLocaleString() }}</td>
|
||||
<td>{{ entry.blocked_until ? new Date(entry.blocked_until * 1000).toLocaleString() : 'N/A' }}</td>
|
||||
<td>
|
||||
<button
|
||||
@click="handleDeleteIp(entry.ip)"
|
||||
:disabled="blacklistDeleteLoading && blacklistToDeleteIp === entry.ip"
|
||||
class="btn-danger"
|
||||
>
|
||||
{{ (blacklistDeleteLoading && blacklistToDeleteIp === entry.ip) ? '删除中...' : '移除' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else>当前没有 IP 地址在黑名单中。</p>
|
||||
|
||||
<!-- 分页控件 (如果需要) -->
|
||||
<!--
|
||||
<div class="pagination" v-if="ipBlacklist.total > ipBlacklist.limit">
|
||||
<button @click="fetchIpBlacklist(ipBlacklist.currentPage - 1)" :disabled="ipBlacklist.currentPage <= 1">上一页</button>
|
||||
<span>第 {{ ipBlacklist.currentPage }} 页 / 共 {{ Math.ceil(ipBlacklist.total / ipBlacklist.limit) }} 页</span>
|
||||
<button @click="fetchIpBlacklist(ipBlacklist.currentPage + 1)" :disabled="ipBlacklist.currentPage * ipBlacklist.limit >= ipBlacklist.total">下一页</button>
|
||||
</div>
|
||||
-->
|
||||
<p v-if="blacklistDeleteError" class="error-message">{{ blacklistDeleteError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, reactive } from 'vue'; // 导入 computed 和 reactive
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios'; // 导入 axios
|
||||
import { startRegistration } from '@simplewebauthn/browser'; // 导入 simplewebauthn
|
||||
// import NotificationSettings from '../components/NotificationSettings.vue'; // 确认移除或根据需要取消注释
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
@@ -188,6 +259,29 @@ const ipWhitelistLoading = ref(false);
|
||||
const ipWhitelistMessage = ref('');
|
||||
const ipWhitelistSuccess = ref(false);
|
||||
|
||||
// --- IP 黑名单状态 ---
|
||||
const ipBlacklist = reactive({
|
||||
entries: [] as any[], // TODO: Define proper type
|
||||
total: 0,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
currentPage: 1,
|
||||
limit: 10, // 每页显示数量
|
||||
});
|
||||
const blacklistToDeleteIp = ref<string | null>(null); // 存储待确认删除的 IP
|
||||
const blacklistDeleteLoading = ref(false);
|
||||
const blacklistDeleteError = ref<string | null>(null);
|
||||
|
||||
// --- 黑名单配置状态 ---
|
||||
const blacklistSettings = reactive({
|
||||
maxLoginAttempts: '5', // 默认值
|
||||
loginBanDuration: '300', // 默认值 (秒)
|
||||
loading: false,
|
||||
message: '',
|
||||
success: false,
|
||||
});
|
||||
|
||||
|
||||
// 计算属性判断当前是否处于 2FA 设置流程中
|
||||
const isSettingUp2FA = computed(() => setupData.value !== null);
|
||||
|
||||
@@ -353,6 +447,102 @@ const handleUpdateIpWhitelist = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- IP 黑名单相关方法 ---
|
||||
const fetchIpBlacklist = async (page = 1) => {
|
||||
ipBlacklist.loading = true;
|
||||
ipBlacklist.error = null;
|
||||
const offset = (page - 1) * ipBlacklist.limit;
|
||||
try {
|
||||
const data = await authStore.fetchIpBlacklist(ipBlacklist.limit, offset);
|
||||
ipBlacklist.entries = data.entries;
|
||||
ipBlacklist.total = data.total;
|
||||
ipBlacklist.currentPage = page;
|
||||
} catch (error: any) {
|
||||
ipBlacklist.error = error.message || '获取黑名单失败';
|
||||
} finally {
|
||||
ipBlacklist.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteIp = async (ip: string) => {
|
||||
blacklistToDeleteIp.value = ip; // 设置待确认的 IP
|
||||
// 可以在这里添加一个确认对话框
|
||||
if (confirm(`确定要从黑名单中移除 IP 地址 "${ip}" 吗?`)) {
|
||||
blacklistDeleteLoading.value = true;
|
||||
blacklistDeleteError.value = null;
|
||||
try {
|
||||
await authStore.deleteIpFromBlacklist(ip);
|
||||
// 成功后刷新列表
|
||||
await fetchIpBlacklist(ipBlacklist.currentPage);
|
||||
} catch (error: any) {
|
||||
blacklistDeleteError.value = error.message || '删除失败';
|
||||
} finally {
|
||||
blacklistDeleteLoading.value = false;
|
||||
blacklistToDeleteIp.value = null; // 清除待确认 IP
|
||||
}
|
||||
} else {
|
||||
blacklistToDeleteIp.value = null; // 用户取消,清除待确认 IP
|
||||
}
|
||||
};
|
||||
|
||||
// 获取黑名单配置
|
||||
const fetchBlacklistSettings = async () => {
|
||||
blacklistSettings.loading = true;
|
||||
blacklistSettings.message = '';
|
||||
try {
|
||||
const response = await axios.get<Record<string, string>>('/api/v1/settings');
|
||||
blacklistSettings.maxLoginAttempts = response.data['maxLoginAttempts'] || '5';
|
||||
blacklistSettings.loginBanDuration = response.data['loginBanDuration'] || '300';
|
||||
} catch (error: any) {
|
||||
console.error('获取黑名单配置失败:', error);
|
||||
blacklistSettings.message = '获取黑名单配置失败';
|
||||
blacklistSettings.success = false;
|
||||
} finally {
|
||||
blacklistSettings.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 更新黑名单配置
|
||||
const handleUpdateBlacklistSettings = async () => {
|
||||
blacklistSettings.loading = true;
|
||||
blacklistSettings.message = '';
|
||||
blacklistSettings.success = false;
|
||||
try {
|
||||
// 验证输入是否为有效数字
|
||||
const maxAttempts = parseInt(blacklistSettings.maxLoginAttempts, 10);
|
||||
const banDuration = parseInt(blacklistSettings.loginBanDuration, 10);
|
||||
if (isNaN(maxAttempts) || maxAttempts <= 0) {
|
||||
throw new Error('最大失败次数必须是正整数。');
|
||||
}
|
||||
if (isNaN(banDuration) || banDuration <= 0) {
|
||||
throw new Error('封禁时长必须是正整数(秒)。');
|
||||
}
|
||||
|
||||
await axios.put('/api/v1/settings', {
|
||||
maxLoginAttempts: blacklistSettings.maxLoginAttempts,
|
||||
loginBanDuration: blacklistSettings.loginBanDuration,
|
||||
});
|
||||
blacklistSettings.message = '黑名单配置已成功更新。';
|
||||
blacklistSettings.success = true;
|
||||
} catch (error: any) {
|
||||
console.error('更新黑名单配置失败:', error);
|
||||
blacklistSettings.message = error.message || '更新黑名单配置失败';
|
||||
blacklistSettings.success = false;
|
||||
} finally {
|
||||
blacklistSettings.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 在 onMounted 中调用 fetchIpBlacklist 和 fetchBlacklistSettings
|
||||
onMounted(async () => { // 使 onMounted 异步
|
||||
await checkTwoFactorStatus(); // 等待状态检查完成
|
||||
await fetchIpWhitelist(); // 获取 IP 白名单设置
|
||||
await fetchIpBlacklist(); // 获取 IP 黑名单列表
|
||||
await fetchBlacklistSettings(); // 获取黑名单配置
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -439,4 +629,85 @@ img {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Blacklist Table Styles */
|
||||
.blacklist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.blacklist-table th,
|
||||
.blacklist-table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.blacklist-table th {
|
||||
background-color: #f2f2f2;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.blacklist-table .btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.blacklist-table .btn-danger:disabled {
|
||||
background-color: #f8d7da;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin-top: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Pagination Styles (Optional) */
|
||||
.pagination {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.pagination button {
|
||||
margin: 0 5px;
|
||||
}
|
||||
.pagination span {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* Blacklist Settings Form Styles */
|
||||
.blacklist-settings-form {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.blacklist-settings-form .inline-group {
|
||||
display: inline-block; /* 让 label 和 input 在一行显示 */
|
||||
margin-right: 20px; /* 组之间的间距 */
|
||||
margin-bottom: 10px; /* 增加底部间距 */
|
||||
}
|
||||
.blacklist-settings-form .inline-group label {
|
||||
display: inline-block; /* 行内块 */
|
||||
margin-right: 5px; /* label 和 input 之间的间距 */
|
||||
width: auto; /* 覆盖默认的 block 宽度 */
|
||||
margin-bottom: 0; /* 移除默认的底部间距 */
|
||||
}
|
||||
.blacklist-settings-form .inline-group input[type="number"] {
|
||||
width: 80px; /* 设置一个合适的宽度 */
|
||||
display: inline-block; /* 行内块 */
|
||||
padding: 6px; /* 调整内边距 */
|
||||
}
|
||||
.blacklist-settings-form button {
|
||||
vertical-align: bottom; /* 对齐按钮和输入框 */
|
||||
}
|
||||
.blacklist-settings-form p { /* 消息样式 */
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user