This commit is contained in:
Baobhan Sith
2025-04-15 18:59:56 +08:00
parent 7649a7b69d
commit c026a42d06
43 changed files with 3479 additions and 169 deletions
+1
View File
@@ -6143,6 +6143,7 @@
"@simplewebauthn/server": "^13.1.1",
"@types/multer": "^1.4.12",
"@types/uuid": "^10.0.0",
"axios": "^1.8.4",
"bcrypt": "^5.1.1",
"connect-sqlite3": "^0.9.15",
"express": "^5.1.0",
+1
View File
@@ -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;
+79 -4
View File
@@ -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();
+6 -4
View File
@@ -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,
+10
View File
@@ -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) => {
+161
View File
@@ -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();
+42 -17
View File
@@ -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) {
+69
View File
@@ -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
}
+95 -115
View File
@@ -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.
+2
View File
@@ -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>
+137 -15
View File
@@ -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')">&times;</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
};
}
+127 -2
View File
@@ -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"
}
}
}
+129 -3
View File
@@ -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": "数据库迁移"
}
}
}
+12
View File
@@ -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,
};
});
+67 -3
View File
@@ -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)">&laquo;</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)">&raquo;</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>
+28 -2
View File
@@ -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>
+272 -1
View File
@@ -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>