update
This commit is contained in:
@@ -34,7 +34,7 @@ const backgroundUpload = multer({
|
||||
},
|
||||
limits: { fileSize: 5 * 1024 * 1024 } // 限制文件大小为 5MB
|
||||
});
|
||||
// --- End Background Image Upload Config ---
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,14 +16,14 @@ router.put('/', appearanceController.updateAppearanceSettingsController);
|
||||
// POST /api/v1/appearance/background/page - 上传页面背景图片
|
||||
router.post(
|
||||
'/background/page',
|
||||
appearanceController.uploadPageBackgroundMiddleware, // 使用 multer 中间件
|
||||
appearanceController.uploadPageBackgroundMiddleware,
|
||||
appearanceController.uploadPageBackgroundController
|
||||
);
|
||||
|
||||
// POST /api/v1/appearance/background/terminal - 上传终端背景图片
|
||||
router.post(
|
||||
'/background/terminal',
|
||||
appearanceController.uploadTerminalBackgroundMiddleware, // 使用 multer 中间件
|
||||
appearanceController.uploadTerminalBackgroundMiddleware,
|
||||
appearanceController.uploadTerminalBackgroundController
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export class AuditController {
|
||||
res.status(400).json({ message: '无效的 endDate 参数' });
|
||||
return;
|
||||
}
|
||||
// TODO: 可以添加对 actionType 是否有效的验证
|
||||
|
||||
// 将 searchTerm 传递给 service
|
||||
const result = await auditLogService.getLogs(limit, offset, actionType, startDate, endDate, searchTerm);
|
||||
@@ -52,7 +51,7 @@ export class AuditController {
|
||||
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 }; // 保留原始字符串并标记错误
|
||||
parsedDetails = { raw: log.details, parseError: true };
|
||||
}
|
||||
}
|
||||
return { ...log, details: parsedDetails };
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // Use the correct auth middleware
|
||||
import { isAuthenticated } from '../auth/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;
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
// Import the instance getter and promisified helpers
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
|
||||
import sqlite3, { RunResult } from 'sqlite3'; // Keep sqlite3 for type hints if needed
|
||||
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 黑名单服务
|
||||
import { captchaService } from '../services/captcha.service'; // <-- Import CaptchaService
|
||||
import { settingsService } from '../services/settings.service'; // <-- Import SettingsService for config check
|
||||
import { PasskeyService } from '../services/passkey.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
import { ipBlacklistService } from '../services/ip-blacklist.service';
|
||||
import { captchaService } from '../services/captcha.service';
|
||||
import { settingsService } from '../services/settings.service';
|
||||
|
||||
// Remove top-level db instance acquisition
|
||||
// const db = getDb();
|
||||
const passkeyService = new PasskeyService(); // 实例化 PasskeyService
|
||||
const notificationService = new NotificationService(); // 实例化 NotificationService
|
||||
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
||||
|
||||
// 用户数据结构占位符 (理想情况下应定义在共享的 types 文件中)
|
||||
const passkeyService = new PasskeyService();
|
||||
const notificationService = new NotificationService();
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
hashed_password: string; // 数据库中存储的哈希密码
|
||||
two_factor_secret?: string | null; // 2FA 密钥 (数据库中可能为 NULL)
|
||||
// 其他可能的字段...
|
||||
hashed_password: string;
|
||||
two_factor_secret?: string | null;
|
||||
}
|
||||
|
||||
// 扩展 SessionData 接口以包含临时的 2FA 密钥
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
tempTwoFactorSecret?: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
currentChallenge?: string; // 用于存储 Passkey 操作的挑战
|
||||
rememberMe?: boolean; // 新增:临时存储“记住我”选项
|
||||
currentChallenge?: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,54 +52,39 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
if (captchaConfig.enabled) {
|
||||
const { captchaToken } = req.body;
|
||||
if (!captchaToken) {
|
||||
console.log(`[AuthController] 登录尝试失败: CAPTCHA 已启用但未提供令牌 - ${username}`);
|
||||
// 记录审计日志等(可选,看是否需要区分)
|
||||
res.status(400).json({ message: '需要提供 CAPTCHA 令牌。' });
|
||||
return; // 添加 return 语句以确保函数在此处终止
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isCaptchaValid = await captchaService.verifyToken(captchaToken);
|
||||
if (!isCaptchaValid) {
|
||||
console.log(`[AuthController] 登录尝试失败: CAPTCHA 验证失败 - ${username}`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
ipBlacklistService.recordFailedAttempt(clientIp); // Record failed attempt for invalid CAPTCHA
|
||||
ipBlacklistService.recordFailedAttempt(clientIp);
|
||||
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
|
||||
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
|
||||
res.status(401).json({ message: 'CAPTCHA 验证失败。' });
|
||||
return; // 添加 return 语句以确保函数在此处终止
|
||||
return;
|
||||
}
|
||||
console.log(`[AuthController] CAPTCHA 验证成功 - ${username}`);
|
||||
} catch (captchaError: any) {
|
||||
console.error(`[AuthController] CAPTCHA 验证过程中出错 (${username}):`, captchaError.message);
|
||||
// 如果是配置错误或 API 请求失败,返回 500
|
||||
res.status(500).json({ message: 'CAPTCHA 验证服务出错,请稍后重试或检查配置。' });
|
||||
return; // 添加 return 语句以确保函数在此处终止
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log(`[AuthController] CAPTCHA 未启用,跳过验证 - ${username}`);
|
||||
}
|
||||
// --- End CAPTCHA Verification ---
|
||||
|
||||
const db = await getDbInstance(); // Get DB instance inside the function
|
||||
// Use the promisified getDb helper
|
||||
|
||||
const db = await getDbInstance();
|
||||
const user = await getDb<User>(db, 'SELECT id, username, hashed_password, two_factor_secret FROM users WHERE username = ?', [username]);
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
// 查询用户,包含 2FA 密钥
|
||||
db.get('SELECT id, username, hashed_password, two_factor_secret FROM users WHERE username = ?', [username], (err, row: User) => {
|
||||
if (err) {
|
||||
console.error('查询用户时出错:', err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
|
||||
if (!user) {
|
||||
console.log(`登录尝试失败: 用户未找到 - ${username}`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录失败尝试
|
||||
ipBlacklistService.recordFailedAttempt(clientIp);
|
||||
// 记录审计日志 (添加 IP)
|
||||
@@ -153,11 +132,11 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
// 根据 rememberMe 设置 cookie maxAge
|
||||
if (rememberMe) {
|
||||
// 如果勾选了“记住我”,设置 cookie 有效期为 1 年 (毫秒)
|
||||
req.session.cookie.maxAge = 315360000000; // 10 years = 10 * 365 * 24 * 60 * 60 * 1000 (Effectively permanent)
|
||||
// 如果勾选了“记住我”,设置 cookie 有效期为 10 年 (毫秒)
|
||||
req.session.cookie.maxAge = 315360000000;
|
||||
} else {
|
||||
// 如果未勾选,则不设置 maxAge,使其成为会话 cookie
|
||||
req.session.cookie.maxAge = undefined; // 或者 null
|
||||
req.session.cookie.maxAge = undefined;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
@@ -190,19 +169,7 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
|
||||
// 查询用户的 2FA 状态 using promisified getDb
|
||||
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]);
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const user = await new Promise<{ two_factor_secret: string | null } | undefined>((resolve, reject) => {
|
||||
db.get('SELECT two_factor_secret FROM users WHERE id = ?', [userId], (err, row: { two_factor_secret: string | null }) => {
|
||||
if (err) {
|
||||
console.error(`查询用户 ${userId} 2FA 状态时出错:`, err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// 如果找不到用户(理论上不应发生),也视为未认证
|
||||
// 如果找不到用户,也视为未认证
|
||||
if (!user) {
|
||||
res.status(401).json({ isAuthenticated: false });
|
||||
return;
|
||||
@@ -241,21 +208,11 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
const db = await getDbInstance();
|
||||
// 获取用户的 2FA 密钥 using promisified getDb
|
||||
const user = await getDb<User>(db, 'SELECT id, username, two_factor_secret FROM users WHERE id = ?', [userId]);
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
db.get('SELECT id, username, two_factor_secret FROM users WHERE id = ?', [userId], (err, row: User) => {
|
||||
if (err) {
|
||||
console.error(`查询用户 ${userId} 的 2FA 密钥时出错:`, err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
|
||||
if (!user || !user.two_factor_secret) {
|
||||
console.error(`2FA 验证错误: 未找到用户 ${userId} 或未设置密钥。`);
|
||||
@@ -342,21 +299,10 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
// Use promisified getDb
|
||||
const db = await getDbInstance();
|
||||
const user = await getDb<User>(db, 'SELECT id, hashed_password FROM users WHERE id = ?', [userId]);
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
db.get('SELECT id, hashed_password FROM users WHERE id = ?', [userId], (err, row: User) => {
|
||||
if (err) {
|
||||
console.error(`查询用户 ${userId} 时出错:`, err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
|
||||
if (!user) {
|
||||
console.error(`修改密码错误: 未找到 ID 为 ${userId} 的用户。`);
|
||||
@@ -375,7 +321,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
const newHashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Use promisified runDb instead of prepare/run/finalize
|
||||
|
||||
const result = await runDb(db,
|
||||
'UPDATE users SET hashed_password = ?, updated_at = ? WHERE id = ?',
|
||||
[newHashedPassword, now, userId]
|
||||
@@ -383,7 +329,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
if (result.changes === 0) {
|
||||
console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`);
|
||||
throw new Error('未找到要更新的用户'); // Throw error to be caught below
|
||||
throw new Error('未找到要更新的用户');
|
||||
}
|
||||
|
||||
console.log(`用户 ${userId} 密码已成功修改。`);
|
||||
@@ -413,20 +359,12 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
const db = await getDbInstance();
|
||||
// 检查用户是否已启用 2FA using promisified getDb
|
||||
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]);
|
||||
const existingSecret = user ? user.two_factor_secret : null;
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const existingSecret = await new Promise<string | null>((resolve, reject) => {
|
||||
db.get('SELECT two_factor_secret FROM users WHERE id = ?', [userId], (err, row: { two_factor_secret: string | null }) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.two_factor_secret : null);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
|
||||
if (existingSecret) {
|
||||
res.status(400).json({ message: '两步验证已启用。如需重置,请先禁用。' });
|
||||
return;
|
||||
@@ -466,7 +404,7 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
};
|
||||
|
||||
|
||||
// --- 新增 Passkey 相关方法 ---
|
||||
// --- Passkey 相关方法 ---
|
||||
|
||||
/**
|
||||
* 生成 Passkey 注册选项 (POST /api/v1/auth/passkey/register-options)
|
||||
@@ -526,11 +464,10 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
|
||||
name
|
||||
);
|
||||
|
||||
// Check if verification was successful and registrationInfo is present
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 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 });
|
||||
@@ -568,7 +505,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // <<< Add this line to get the db instance
|
||||
const db = await getDbInstance();
|
||||
// 使用临时密钥验证用户提交的令牌
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: tempSecret,
|
||||
@@ -591,7 +528,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
|
||||
}
|
||||
|
||||
console.log(`用户 ${userId} 已成功激活两步验证。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp });
|
||||
|
||||
@@ -629,17 +566,10 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
// 1. 验证当前密码 using promisified getDb
|
||||
const db = await getDbInstance();
|
||||
// 验证当前密码
|
||||
const user = await getDb<User>(db, 'SELECT id, hashed_password FROM users WHERE id = ?', [userId]);
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
db.get('SELECT id, hashed_password FROM users WHERE id = ?', [userId], (err, row: User) => {
|
||||
if (err) reject(err); else resolve(row);
|
||||
});
|
||||
});
|
||||
*/
|
||||
if (!user) {
|
||||
res.status(404).json({ message: '用户不存在。' }); return;
|
||||
}
|
||||
@@ -648,7 +578,7 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
res.status(400).json({ message: '当前密码不正确。' }); return;
|
||||
}
|
||||
|
||||
// 2. 清除数据库中的 2FA 密钥 using promisified runDb
|
||||
// 清除数据库中的 2FA 密钥
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await runDb(db,
|
||||
'UPDATE users SET two_factor_secret = NULL, updated_at = ? WHERE id = ?',
|
||||
@@ -661,7 +591,7 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
|
||||
console.log(`用户 ${userId} 已成功禁用两步验证。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp });
|
||||
|
||||
@@ -684,18 +614,6 @@ export const needsSetup = async (req: Request, res: Response): Promise<void> =>
|
||||
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
|
||||
const userCount = row ? row.count : 0;
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const userCount = await new Promise<number>((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM users', (err, row: { count: number }) => {
|
||||
if (err) {
|
||||
console.error('检查 users 表时出错:', err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row ? row.count : 0); // 如果表为空,row 可能为 undefined
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
res.status(200).json({ needsSetup: userCount === 0 });
|
||||
|
||||
} catch (error) {
|
||||
@@ -711,7 +629,7 @@ export const needsSetup = async (req: Request, res: Response): Promise<void> =>
|
||||
export const setupAdmin = async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// 1. 基本输入验证
|
||||
// 基本输入验证
|
||||
if (!username || !password || !confirmPassword) {
|
||||
res.status(400).json({ message: '用户名、密码和确认密码不能为空。' });
|
||||
return;
|
||||
@@ -727,44 +645,30 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
// 2. 检查数据库中是否已存在用户 (关键安全检查) using promisified getDb
|
||||
const db = await getDbInstance();
|
||||
// 检查数据库中是否已存在用户
|
||||
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
|
||||
const userCount = row ? row.count : 0;
|
||||
|
||||
/* Original callback logic replaced by await getDb
|
||||
const userCount = await new Promise<number>((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM users', (err, row: { count: number }) => {
|
||||
if (err) {
|
||||
console.error('检查 users 表时出错 (setupAdmin):', err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row ? row.count : 0);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
if (userCount > 0) {
|
||||
console.warn('尝试在已有用户的情况下执行初始设置。');
|
||||
res.status(403).json({ message: '设置已完成,无法重复执行。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 哈希密码
|
||||
// 哈希密码
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 4. 插入新用户 using promisified runDb
|
||||
// 插入新用户
|
||||
const result = await runDb(db,
|
||||
`INSERT INTO users (username, hashed_password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[username, hashedPassword, now, now]
|
||||
);
|
||||
|
||||
// Check if insertion was successful and get the ID
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
// This might happen due to UNIQUE constraint or other issues caught by runDb's error handling
|
||||
console.error('创建初始管理员后未能获取有效的 lastID。可能原因:用户名已存在或其他数据库错误。');
|
||||
throw new Error('创建初始管理员失败,可能用户名已存在。');
|
||||
}
|
||||
@@ -772,7 +676,7 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
|
||||
console.log(`初始管理员账号 '${username}' (ID: ${newUser.id}) 已成功创建。`);
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// 记录审计日志 (添加 IP)
|
||||
auditLogService.logAction('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp });
|
||||
|
||||
@@ -788,7 +692,7 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
||||
* 处理用户登出请求 (POST /api/v1/auth/logout)
|
||||
*/
|
||||
export const logout = (req: Request, res: Response): void => {
|
||||
const userId = req.session.userId; // 获取用户 ID 用于日志记录
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username;
|
||||
|
||||
req.session.destroy((err) => {
|
||||
@@ -817,9 +721,8 @@ export const logout = (req: Request, res: Response): void => {
|
||||
export const getPublicCaptchaConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
console.log('[AuthController] Received request for public CAPTCHA config.');
|
||||
const fullConfig = await settingsService.getCaptchaConfig(); // Use settingsService
|
||||
const fullConfig = await settingsService.getCaptchaConfig();
|
||||
|
||||
// *** IMPORTANT: Filter out secret keys before sending to frontend ***
|
||||
const publicConfig = {
|
||||
enabled: fullConfig.enabled,
|
||||
provider: fullConfig.provider,
|
||||
|
||||
@@ -13,5 +13,3 @@ export const isAuthenticated = (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
};
|
||||
|
||||
// 未来可以添加基于角色的授权中间件等
|
||||
// export const isAdmin = ...
|
||||
|
||||
@@ -6,16 +6,16 @@ import {
|
||||
setup2FA,
|
||||
verifyAndActivate2FA,
|
||||
disable2FA,
|
||||
getAuthStatus, // 导入获取状态的方法
|
||||
generatePasskeyRegistrationOptions, // 导入 Passkey 方法
|
||||
verifyPasskeyRegistration, // 导入 Passkey 方法
|
||||
needsSetup, // 导入 needsSetup 控制器
|
||||
setupAdmin, // 导入 setupAdmin 控制器
|
||||
logout, // *** 新增:导入 logout 控制器 ***
|
||||
getPublicCaptchaConfig // <-- Import public CAPTCHA config controller
|
||||
getAuthStatus,
|
||||
generatePasskeyRegistrationOptions,
|
||||
verifyPasskeyRegistration,
|
||||
needsSetup,
|
||||
setupAdmin,
|
||||
logout,
|
||||
getPublicCaptchaConfig
|
||||
} from './auth.controller';
|
||||
import { isAuthenticated } from './auth.middleware';
|
||||
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件
|
||||
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -64,8 +64,5 @@ router.post('/passkey/verify-registration', isAuthenticated, verifyPasskeyRegist
|
||||
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
|
||||
router.post('/logout', logout);
|
||||
|
||||
// 未来可以添加的其他认证相关路由
|
||||
// router.get('/status', getStatus); // 获取当前登录状态
|
||||
// router.post('/setup', setupAdmin); // 已移到上面
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -23,7 +23,6 @@ export const ipBlacklistCheckMiddleware = async (req: Request, res: Response, ne
|
||||
// 可以返回更通用的错误信息,避免泄露封禁状态
|
||||
res.status(403).json({ message: '访问被拒绝。' });
|
||||
// 或者返回更具体的错误
|
||||
// res.status(429).json({ message: '尝试次数过多,请稍后再试。' });
|
||||
return; // 显式返回 void
|
||||
}
|
||||
// IP 未被封禁,继续处理请求
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import * as CommandHistoryController from './command-history.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 使用正确的认证中间件
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 应用认证中间件到所有命令历史记录相关的路由
|
||||
router.use(isAuthenticated);
|
||||
|
||||
// 定义路由
|
||||
router.post('/', CommandHistoryController.addCommand); // POST /api/command-history
|
||||
router.get('/', CommandHistoryController.getAllCommands); // GET /api/command-history
|
||||
router.delete('/:id', CommandHistoryController.deleteCommand); // DELETE /api/command-history/:id
|
||||
|
||||
@@ -6,7 +6,7 @@ export const defaultXtermTheme: ITheme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
selectionBackground: '#264f78', // 使用 selectionBackground
|
||||
selectionBackground: '#264f78',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
@@ -32,25 +32,23 @@ export const defaultUiTheme: Record<string, string> = {
|
||||
'--text-color': '#333333',
|
||||
'--text-color-secondary': '#666666',
|
||||
'--border-color': '#cccccc',
|
||||
'--link-color': '#8E44AD', // 现代紫色 (Amethyst 变种)
|
||||
'--link-hover-color': '#B180E0', // 现代紫色 - 悬停 (更亮)
|
||||
'--link-active-color': '#A06CD5', // 现代紫色 - 激活 (基础)
|
||||
'--link-active-bg-color': '#F3EBFB', /* 现代紫色 - 激活背景 (非常浅) */
|
||||
'--nav-item-active-bg-color': 'var(--link-active-bg-color)', /* Added */
|
||||
'--link-color': '#8E44AD',
|
||||
'--link-hover-color': '#B180E0',
|
||||
'--link-active-color': '#A06CD5',
|
||||
'--link-active-bg-color': '#F3EBFB',
|
||||
'--nav-item-active-bg-color': 'var(--link-active-bg-color)',
|
||||
'--header-bg-color': '#f0f0f0',
|
||||
'--footer-bg-color': '#f0f0f0',
|
||||
'--button-bg-color': '#A06CD5', // 现代紫色 - 激活 (基础)
|
||||
'--button-bg-color': '#A06CD5',
|
||||
'--button-text-color': '#ffffff',
|
||||
'--button-hover-bg-color': '#8E44AD', // 现代紫色 - 悬停 (稍暗)
|
||||
// Added new variables
|
||||
'--icon-color': 'var(--text-color-secondary)', // 图标颜色
|
||||
'--icon-hover-color': 'var(--link-hover-color)', // 图标悬停颜色 (自动更新)
|
||||
'--split-line-color': 'var(--border-color)', /* 分割线颜色 */
|
||||
'--split-line-hover-color': 'var(--border-color)', /* 分割线悬停颜色 */
|
||||
'--input-focus-border-color': 'var(--link-active-color)', /* 输入框聚焦边框颜色 (自动更新) */
|
||||
'--input-focus-glow': 'var(--link-active-color)', /* 输入框聚焦光晕值 (自动更新) */
|
||||
'--overlay-bg-color': 'rgba(0, 0, 0, 0.6)', /* Added Overlay Background - 恢复 rgba 以支持透明度 */
|
||||
// End added variables
|
||||
'--button-hover-bg-color': '#8E44AD',
|
||||
'--icon-color': 'var(--text-color-secondary)',
|
||||
'--icon-hover-color': 'var(--link-hover-color)',
|
||||
'--split-line-color': 'var(--border-color)',
|
||||
'--split-line-hover-color': 'var(--border-color)',
|
||||
'--input-focus-border-color': 'var(--link-active-color)',
|
||||
'--input-focus-glow': 'var(--link-active-color)',
|
||||
'--overlay-bg-color': 'rgba(0, 0, 0, 0.6)',
|
||||
'--font-family-sans-serif': 'sans-serif',
|
||||
'--base-padding': '1rem',
|
||||
'--base-margin': '0.5rem',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Generated by scripts/generate-iterm-themes.js
|
||||
// Source: https://github.com/mbadolato/iTerm2-Color-Schemes
|
||||
// IMPORTANT: Add the 'default' theme manually to this file if needed.
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
import type { TerminalTheme } from '../types/terminal-theme.types';
|
||||
|
||||
// 定义预设主题数组的类型,确保包含 preset_key
|
||||
|
||||
type PresetThemeDefinition = Omit<TerminalTheme, '_id' | 'createdAt' | 'updatedAt'> & { preset_key: string };
|
||||
|
||||
export const presetTerminalThemes: PresetThemeDefinition[] = [
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
// Removed duplicate import
|
||||
import * as ConnectionService from '../services/connection.service';
|
||||
import * as SshService from '../services/ssh.service'; // 引入 SshService
|
||||
import * as ImportExportService from '../services/import-export.service'; // 引入 ImportExportService
|
||||
// Removed AuditLogService import and instantiation
|
||||
import * as SshService from '../services/ssh.service';
|
||||
import * as ImportExportService from '../services/import-export.service';
|
||||
|
||||
// --- 移除所有不再需要的导入和变量 ---
|
||||
// import { Statement } from 'sqlite3';
|
||||
// import { getDb } from '../database/connection'; // Updated import path in comment
|
||||
// const db = getDb();
|
||||
// --- 清理结束 ---
|
||||
|
||||
|
||||
/**
|
||||
@@ -17,14 +10,11 @@ import * as ImportExportService from '../services/import-export.service'; // 引
|
||||
*/
|
||||
export const createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Controller performs minimal validation, Service layer handles detailed business logic validation.
|
||||
// 将请求体传递给服务层处理 (Service layer now handles validation and audit logging)
|
||||
const newConnection = await ConnectionService.createConnection(req.body);
|
||||
res.status(201).json({ message: '连接创建成功。', connection: newConnection });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 创建连接时发生错误:', error);
|
||||
// 根据错误类型返回不同的状态码,例如验证错误返回 400
|
||||
if (error.message.includes('缺少') || error.message.includes('需要提供')) {
|
||||
res.status(400).json({ message: error.message });
|
||||
} else {
|
||||
@@ -81,20 +71,16 @@ export const updateConnection = async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
// Controller performs minimal validation, Service layer handles detailed business logic validation.
|
||||
// 注意:服务层会处理更复杂的验证,比如切换认证方式时凭证是否提供
|
||||
|
||||
const updatedConnection = await ConnectionService.updateConnection(connectionId, req.body);
|
||||
|
||||
if (!updatedConnection) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
} else {
|
||||
// Audit logging is now handled by the service layer
|
||||
res.status(200).json({ message: '连接更新成功。', connection: updatedConnection });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 更新连接 ${req.params.id} 时发生错误:`, error);
|
||||
// 根据错误类型返回不同的状态码
|
||||
if (error.message.includes('需要提供')) {
|
||||
res.status(400).json({ message: error.message });
|
||||
} else {
|
||||
@@ -119,8 +105,7 @@ export const deleteConnection = async (req: Request, res: Response): Promise<voi
|
||||
if (!deleted) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
} else {
|
||||
// Audit logging is now handled by the service layer
|
||||
res.status(200).json({ message: '连接删除成功。' }); // 或使用 204 No Content
|
||||
res.status(200).json({ message: '连接删除成功。' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 删除连接 ${req.params.id} 时发生错误:`, error);
|
||||
@@ -128,7 +113,7 @@ export const deleteConnection = async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
};
|
||||
|
||||
// --- TODO: 将以下逻辑迁移到 SshService ---
|
||||
|
||||
/**
|
||||
* 测试连接 (POST /api/v1/connections/:id/test)
|
||||
*/
|
||||
@@ -143,16 +128,10 @@ export const testConnection = async (req: Request, res: Response): Promise<void>
|
||||
// 调用 SshService 进行连接测试,现在它会返回延迟
|
||||
const { latency } = await SshService.testConnection(connectionId);
|
||||
|
||||
// 如果 SshService.testConnection 没有抛出错误,则表示成功
|
||||
// 记录审计日志 (可选,看是否需要记录测试操作)
|
||||
// auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: true });
|
||||
res.status(200).json({ success: true, message: '连接测试成功。', latency }); // 返回延迟
|
||||
|
||||
} 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 || '测试连接时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
@@ -221,7 +200,7 @@ export const testUnsavedConnection = async (req: Request, res: Response): Promis
|
||||
};
|
||||
|
||||
|
||||
// --- TODO: 将以下逻辑迁移到 ImportExportService ---
|
||||
|
||||
/**
|
||||
* 导出所有连接配置 (GET /api/v1/connections/export)
|
||||
*/
|
||||
@@ -234,9 +213,6 @@ 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');
|
||||
// Audit logging for export/import might still be relevant here or in the service
|
||||
// For now, let's assume ImportExportService handles its own logging if needed
|
||||
// auditLogService.logAction('CONNECTIONS_EXPORTED', { count: exportedData.length }); // Removed from controller
|
||||
res.status(200).json(exportedData);
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -245,7 +221,6 @@ export const exportConnections = async (req: Request, res: Response): Promise<vo
|
||||
}
|
||||
};
|
||||
|
||||
// --- TODO: 将以下逻辑迁移到 ImportExportService (和 ProxyService) ---
|
||||
/**
|
||||
* 导入连接配置 (POST /api/v1/connections/import)
|
||||
*/
|
||||
@@ -259,18 +234,14 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
|
||||
const result = await ImportExportService.importConnections(req.file.buffer);
|
||||
|
||||
if (result.failureCount > 0) {
|
||||
// Partial success or complete failure
|
||||
res.status(400).json({ // Use 400 for partial success with errors
|
||||
res.status(400).json({
|
||||
message: `导入完成,但存在 ${result.failureCount} 个错误。成功导入 ${result.successCount} 条。`,
|
||||
successCount: result.successCount,
|
||||
failureCount: result.failureCount,
|
||||
errors: result.errors
|
||||
});
|
||||
} else {
|
||||
// Complete success
|
||||
// Audit logging for export/import might still be relevant here or in the service
|
||||
// For now, let's assume ImportExportService handles its own logging if needed
|
||||
// auditLogService.logAction('CONNECTIONS_IMPORTED', { successCount: result.successCount, failureCount: result.failureCount }); // Removed from controller
|
||||
|
||||
res.status(200).json({
|
||||
message: `导入成功完成。共导入 ${result.successCount} 条连接。`,
|
||||
successCount: result.successCount,
|
||||
@@ -279,12 +250,10 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 导入连接时发生错误:', error);
|
||||
// Handle specific errors like JSON parsing error from service
|
||||
if (error.message.includes('解析 JSON 文件失败')) {
|
||||
res.status(400).json({ message: error.message });
|
||||
} else {
|
||||
res.status(500).json({ message: error.message || '导入连接时发生内部服务器错误。' });
|
||||
}
|
||||
}
|
||||
// No finally block needed here as db statements are handled in service/repo now
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'; // 引入 Request, Response, NextFunction
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件
|
||||
import multer from 'multer'; // 引入 multer 用于文件上传
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import multer from 'multer';
|
||||
import {
|
||||
createConnection,
|
||||
getConnections,
|
||||
getConnectionById, // 引入获取单个连接的控制器
|
||||
updateConnection, // 引入更新连接的控制器
|
||||
deleteConnection, // 引入删除连接的控制器
|
||||
testConnection, // 引入测试连接的控制器
|
||||
testUnsavedConnection, // 添加导入: 引入测试未保存连接的控制器
|
||||
exportConnections, // 引入导出连接的控制器
|
||||
importConnections // 引入导入连接的控制器
|
||||
getConnectionById,
|
||||
updateConnection,
|
||||
deleteConnection,
|
||||
testConnection,
|
||||
testUnsavedConnection,
|
||||
exportConnections,
|
||||
importConnections
|
||||
} from './connections.controller';
|
||||
|
||||
const router = Router();
|
||||
@@ -20,14 +20,12 @@ const storage = multer.memoryStorage(); // 将文件存储在内存中作为 Buf
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 限制文件大小为 5MB
|
||||
fileFilter: (req: Request, file, cb) => { // Add type for req
|
||||
fileFilter: (req: Request, file, cb) => {
|
||||
if (file.mimetype === 'application/json') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
// Attach error to request instead of calling cb with error directly
|
||||
// This makes it easier to handle consistently and return JSON
|
||||
(req as any).fileValidationError = '只允许上传 JSON 文件!';
|
||||
cb(null, false); // Reject the file
|
||||
cb(null, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -35,34 +33,27 @@ const upload = multer({
|
||||
// 应用认证中间件到所有 /connections 路由
|
||||
router.use(isAuthenticated); // 恢复认证检查
|
||||
|
||||
// --- Specific routes before parameterized routes ---
|
||||
|
||||
// GET /api/v1/connections/export - 导出连接配置
|
||||
router.get('/export', exportConnections);
|
||||
|
||||
// POST /api/v1/connections/import - 导入连接配置
|
||||
router.post('/import', (req: Request, res: Response, next: NextFunction) => {
|
||||
// Use multer middleware, but handle errors specifically
|
||||
upload.single('connectionsFile')(req, res, (err: any) => {
|
||||
// Check for file filter validation error first
|
||||
if ((req as any).fileValidationError) {
|
||||
return res.status(400).json({ message: (req as any).fileValidationError });
|
||||
}
|
||||
// Check for other multer errors (e.g., file size limit)
|
||||
if (err instanceof multer.MulterError) {
|
||||
return res.status(400).json({ message: `文件上传错误: ${err.message}` });
|
||||
} else if (err) {
|
||||
// Other unexpected errors during upload
|
||||
console.error("Unexpected error during file upload:", err);
|
||||
return res.status(500).json({ message: '文件上传处理失败' });
|
||||
}
|
||||
// If no errors, proceed to the controller
|
||||
next();
|
||||
});
|
||||
}, importConnections);
|
||||
|
||||
|
||||
// --- General CRUD and other routes ---
|
||||
|
||||
// GET /api/v1/connections - 获取连接列表
|
||||
router.get('/', getConnections);
|
||||
|
||||
@@ -1,182 +1,118 @@
|
||||
// packages/backend/src/database/connection.ts
|
||||
import sqlite3, { OPEN_READWRITE, OPEN_CREATE } from 'sqlite3'; // Import flags
|
||||
|
||||
import sqlite3, { OPEN_READWRITE, OPEN_CREATE } from 'sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import * as schema from './schema';
|
||||
// Import the table definitions registry instead of individual repositories here
|
||||
import { tableDefinitions } from './schema.registry';
|
||||
// presetTerminalThemes might still be needed if passed directly, but likely handled in registry now
|
||||
// import { presetTerminalThemes } from '../config/preset-themes-definition';
|
||||
// Removed import for default-layout-data as logic is now in settings repository
|
||||
|
||||
// --- Revert to original path and filename ---
|
||||
// 使用 process.cwd() 获取项目根目录,然后拼接路径,确保路径一致性
|
||||
// console.log('[Connection CWD]', process.cwd()); // 移除调试日志
|
||||
// 使用 __dirname 定位到 dist/database,然后回退两级到 packages/backend,再进入 data
|
||||
|
||||
const dbDir = path.join(__dirname, '..', '..', 'data');
|
||||
const dbFilename = 'nexus-terminal.db'; // Revert to original filename
|
||||
const dbFilename = 'nexus-terminal.db';
|
||||
const dbPath = path.join(dbDir, dbFilename);
|
||||
// console.log(`[DB Path] Determined database directory: ${dbDir}`); // 移除调试日志
|
||||
// console.log(`[DB Path] Determined database file path: ${dbPath}`); // 移除调试日志
|
||||
|
||||
// Add logging before checking/creating directory
|
||||
// console.log(`[DB FS] Checking existence of directory: ${dbDir}`); // 移除调试日志
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
// console.log(`[DB FS] Directory does not exist. Attempting to create: ${dbDir}`); // 移除调试日志
|
||||
try {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
// console.log(`[DB FS] Directory successfully created: ${dbDir}`); // 移除调试日志
|
||||
} catch (mkdirErr: any) {
|
||||
console.error(`[DB FS] Failed to create directory ${dbDir}:`, mkdirErr.message);
|
||||
// Consider throwing error here to prevent proceeding if directory creation fails
|
||||
throw new Error(`Failed to create database directory: ${mkdirErr.message}`);
|
||||
console.error(`[数据库文件系统] 创建目录 ${dbDir} 失败:`, mkdirErr.message);
|
||||
throw new Error(`创建数据库目录失败: ${mkdirErr.message}`);
|
||||
}
|
||||
} else {
|
||||
// console.log(`[DB FS] Directory already exists: ${dbDir}`); // 移除调试日志
|
||||
}
|
||||
|
||||
const verboseSqlite3 = sqlite3.verbose();
|
||||
let dbInstancePromise: Promise<sqlite3.Database> | null = null;
|
||||
|
||||
// --- Promisified Database Operations ---
|
||||
|
||||
interface RunResult {
|
||||
lastID: number;
|
||||
changes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified version of db.run(). Resolves with { lastID, changes }.
|
||||
*/
|
||||
|
||||
export const runDb = (db: sqlite3.Database, sql: string, params: any[] = []): Promise<RunResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err: Error | null) { // Use function() to access this
|
||||
db.run(sql, params, function (err: Error | null) {
|
||||
if (err) {
|
||||
console.error(`[DB Error] SQL: ${sql.substring(0, 100)}... Params: ${JSON.stringify(params)} Error: ${err.message}`);
|
||||
console.error(`[数据库错误] SQL: ${sql.substring(0, 100)}... 参数: ${JSON.stringify(params)} 错误: ${err.message}`);
|
||||
reject(err);
|
||||
} else {
|
||||
// 'this' context provides lastID and changes for INSERT/UPDATE/DELETE
|
||||
resolve({ lastID: this.lastID, changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Promisified version of db.get(). Resolves with the row found, or undefined.
|
||||
*/
|
||||
|
||||
export const getDb = <T = any>(db: sqlite3.Database, sql: string, params: any[] = []): Promise<T | undefined> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err: Error | null, row: T) => { // Add type annotation for row
|
||||
db.get(sql, params, (err: Error | null, row: T) => {
|
||||
if (err) {
|
||||
console.error(`[DB Error] SQL: ${sql.substring(0, 100)}... Params: ${JSON.stringify(params)} Error: ${err.message}`);
|
||||
console.error(`[数据库错误] SQL: ${sql.substring(0, 100)}... 参数: ${JSON.stringify(params)} 错误: ${err.message}`);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row); // row will be undefined if not found
|
||||
resolve(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Promisified version of db.all(). Resolves with an array of rows found.
|
||||
*/
|
||||
|
||||
export const allDb = <T = any>(db: sqlite3.Database, sql: string, params: any[] = []): Promise<T[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err: Error | null, rows: T[]) => { // Add type annotation for rows
|
||||
db.all(sql, params, (err: Error | null, rows: T[]) => {
|
||||
if (err) {
|
||||
console.error(`[DB Error] SQL: ${sql.substring(0, 100)}... Params: ${JSON.stringify(params)} Error: ${err.message}`);
|
||||
console.error(`[数据库错误] SQL: ${sql.substring(0, 100)}... 参数: ${JSON.stringify(params)} 错误: ${err.message}`);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows); // rows will be an empty array if no matches
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Executes the database initialization sequence: creates all tables, inserts preset/default data.
|
||||
* Now returns a Promise that resolves when all initializations are complete.
|
||||
* @param db The database instance
|
||||
*/
|
||||
const runDatabaseInitializations = async (db: sqlite3.Database): Promise<void> => {
|
||||
// console.log('[DB Init] 开始数据库初始化序列...'); // 移除调试日志
|
||||
|
||||
try {
|
||||
// 1. Enable foreign key constraints
|
||||
await runDb(db, 'PRAGMA foreign_keys = ON;'); // Use promisified runDb
|
||||
// console.log('[DB Init] 外键约束已启用。'); // 移除调试日志
|
||||
|
||||
// 2. Create tables and run initializations based on the registry
|
||||
await runDb(db, 'PRAGMA foreign_keys = ON;');
|
||||
for (const tableDef of tableDefinitions) {
|
||||
await runDb(db, tableDef.sql); // Create table (IF NOT EXISTS)
|
||||
// console.log(`[DB Init] ${tableDef.name} 表已存在或已创建。`); // 移除调试日志
|
||||
await runDb(db, tableDef.sql);
|
||||
if (tableDef.init) {
|
||||
// Pass the db instance to the init function
|
||||
await tableDef.init(db);
|
||||
}
|
||||
}
|
||||
|
||||
// Default layout/sidebar data is now handled within settingsRepository.ensureDefaultSettingsExist
|
||||
// No separate call needed here anymore.
|
||||
|
||||
// Migrations (if any) would run after initial schema setup
|
||||
// import { runMigrations } from './migrations';
|
||||
// await runMigrations(db);
|
||||
// console.log('[DB Init] 迁移检查完成。');
|
||||
|
||||
// console.log('[DB Init] 数据库初始化序列成功完成。'); // 移除调试日志
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DB Init] 数据库初始化序列失败:', error);
|
||||
// Propagate the error to stop the application startup in index.ts
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the database instance. Initializes the connection and runs initializations if not already done.
|
||||
* Returns a Promise that resolves with the database instance once ready.
|
||||
*/
|
||||
// Renamed original getDb to getDbInstance to avoid confusion with the promisified getDb helper
|
||||
|
||||
export const getDbInstance = (): Promise<sqlite3.Database> => {
|
||||
if (!dbInstancePromise) {
|
||||
dbInstancePromise = new Promise((resolve, reject) => {
|
||||
// Remove connectionFailed flag and double check logic
|
||||
|
||||
// Add logging before attempting connection
|
||||
// console.log(`[DB Connection] Attempting to connect/open database file with explicit create flag: ${dbPath}`); // 移除调试日志
|
||||
// Explicitly add OPEN_READWRITE and OPEN_CREATE flags
|
||||
|
||||
const db = new verboseSqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, async (err) => { // Mark callback as async
|
||||
// --- Strict Error Check FIRST ---
|
||||
|
||||
if (err) {
|
||||
console.error(`[DB Connection] Error opening database file ${dbPath}:`, err.message);
|
||||
// connectionFailed = true; // Remove flag setting
|
||||
dbInstancePromise = null; // Reset promise on error
|
||||
reject(err); // Reject the main promise
|
||||
return; // Explicitly return
|
||||
console.error(`[数据库连接] 打开数据库文件 ${dbPath} 时出错:`, err.message);
|
||||
dbInstancePromise = null;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
// --- End Strict Error Check ---
|
||||
|
||||
// Remove Double Check Flag logic
|
||||
|
||||
// If no error, proceed with success logging and initialization
|
||||
// console.log(`[DB Connection] Successfully connected to SQLite database: ${dbPath}`); // 移除调试日志
|
||||
|
||||
try {
|
||||
// Wait for initializations to complete
|
||||
|
||||
await runDatabaseInitializations(db);
|
||||
// console.log('[DB] Database initialization complete. Ready.'); // 移除调试日志
|
||||
resolve(db); // Resolve the main promise with the db instance
|
||||
resolve(db);
|
||||
} catch (initError) {
|
||||
console.error('[DB] Initialization failed after connection, closing connection...');
|
||||
// connectionFailed = true; // Remove flag setting
|
||||
dbInstancePromise = null; // Reset promise on error
|
||||
console.error('[数据库] 连接后初始化失败,正在关闭连接...');
|
||||
dbInstancePromise = null;
|
||||
db.close((closeErr) => {
|
||||
if (closeErr) console.error('[DB] Error closing connection after init failure:', closeErr.message);
|
||||
reject(initError); // Reject with the initialization error
|
||||
if (closeErr) console.error('[数据库] 初始化失败后关闭连接时出错:', closeErr.message);
|
||||
reject(initError);
|
||||
});
|
||||
// process.exit(1); // Consider exiting on init failure
|
||||
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -184,15 +120,12 @@ export const getDbInstance = (): Promise<sqlite3.Database> => {
|
||||
return dbInstancePromise;
|
||||
};
|
||||
|
||||
// Graceful shutdown remains the same, but it might need access to the resolved instance
|
||||
// Consider a way to get the instance if needed during shutdown, e.g., a global variable set after promise resolution.
|
||||
// For now, it checks the promise state indirectly.
|
||||
process.on('SIGINT', async () => { // Mark as async if needed
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
if (dbInstancePromise) {
|
||||
console.log('[DB] 收到 SIGINT,尝试关闭数据库连接...');
|
||||
try {
|
||||
// We need the actual instance, not the promise, to close
|
||||
// Let's assume if the promise exists, we try to resolve it to get the instance
|
||||
|
||||
const db = await dbInstancePromise;
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
@@ -212,7 +145,4 @@ process.on('SIGINT', async () => { // Mark as async if needed
|
||||
}
|
||||
});
|
||||
|
||||
// Note: We now export getDbInstance (the promise for the connection)
|
||||
// and the helper functions runDb, getDb, allDb.
|
||||
// Files needing the db instance will call `const db = await getDbInstance();`
|
||||
// and then use `await runDb(db, ...)` etc.
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// packages/backend/src/migrations.ts
|
||||
import { Database } from 'sqlite3';
|
||||
// import { getDb } from './database'; // 可能不再需要直接从这里获取 db
|
||||
|
||||
|
||||
/**
|
||||
* 运行数据库迁移。
|
||||
@@ -11,14 +10,8 @@ import { Database } from 'sqlite3';
|
||||
export const runMigrations = (db: Database): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
console.log('[Migrations] 检查数据库迁移(当前无操作)。');
|
||||
// 在这里添加未来的迁移逻辑,例如:
|
||||
// db.serialize(() => {
|
||||
// db.run("ALTER TABLE users ADD COLUMN last_login INTEGER;", (err) => { ... });
|
||||
// // 更多迁移步骤...
|
||||
// });
|
||||
resolve(); // 立即解决,因为没有迁移要运行
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
// 可以保留一个默认导出或根据需要移除
|
||||
// export default runMigrations;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { DockerService, DockerCommand } from '../services/docker.service'; // 导入服务和命令类型
|
||||
import { DockerService, DockerCommand } from '../services/docker.service';
|
||||
|
||||
// 由于没有 typedi,我们将手动实例化服务或通过其他方式获取实例
|
||||
// 简单起见,这里直接 new 一个实例。在实际项目中,可能需要更复杂的实例管理。
|
||||
@@ -55,8 +55,6 @@ export class DockerController {
|
||||
// 其他执行错误,可能是 Docker 守护进程错误等
|
||||
res.status(500).json({ message: error.message || 'Failed to execute Docker command.' }); // Internal Server Error
|
||||
}
|
||||
// 注意:这里没有调用 next(error),因为我们已经处理了响应。
|
||||
// 如果希望使用统一的错误处理中间件,则应该调用 next(error)。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { DockerController } from './docker.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
const dockerController = new DockerController(); // 同样,手动实例化
|
||||
const dockerController = new DockerController();
|
||||
|
||||
// 应用认证中间件,确保只有登录用户才能访问 Docker 相关接口
|
||||
router.use(isAuthenticated);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import i18next from 'i18next';
|
||||
import Backend from 'i18next-fs-backend';
|
||||
import path from 'path';
|
||||
import fs from 'fs'; // 导入 fs 模块
|
||||
import fs from 'fs';
|
||||
|
||||
// --- 动态确定支持的语言 ---
|
||||
const localesDir = path.join(__dirname, 'locales');
|
||||
@@ -52,9 +52,6 @@ i18next
|
||||
return console.error('[i18next] Error during initialization:', err);
|
||||
}
|
||||
console.log('[i18next] Initialization complete. Loaded languages:', Object.keys(i18next.store.data));
|
||||
// console.log('[i18next] Example translation (en):', t('testNotification.subject', { lng: 'en' })); // Optional test
|
||||
// console.log('[i18next] Example translation (zh):', t('testNotification.subject', { lng: 'zh' })); // Optional test
|
||||
// console.log('[i18next] Example translation (jp):', t('testNotification.subject', { lng: 'jp' })); // Optional test for newly added lang
|
||||
});
|
||||
|
||||
export default i18next;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import express = require('express');
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs'; // 导入 fs 模块
|
||||
import path from 'path'; // 导入 path 模块
|
||||
import crypto from 'crypto'; // 导入 crypto 模块
|
||||
import dotenv from 'dotenv'; // 导入 dotenv
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import session from 'express-session';
|
||||
import sessionFileStore from 'session-file-store';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getDbInstance } from './database/connection';
|
||||
// import { runMigrations } from './database/migrations'; // Migrations are handled within getDbInstance
|
||||
import authRouter from './auth/auth.routes';
|
||||
import connectionsRouter from './connections/connections.routes';
|
||||
import sftpRouter from './sftp/sftp.routes';
|
||||
@@ -161,10 +159,7 @@ const startServer = () => {
|
||||
proxy: true, // 信任反向代理设置的 X-Forwarded-Proto 头
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
// secure: 'auto' in newer versions, relies on proxy: true here
|
||||
// secure: process.env.NODE_ENV === 'production', // Keep original logic, but proxy:true adjusts behavior
|
||||
secure: false, // Temporarily force secure to false for debugging proxy issues
|
||||
// maxAge: 默认会话期
|
||||
secure: false,
|
||||
}
|
||||
});
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { NotificationSetting } from '../types/notification.types';
|
||||
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
|
||||
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
export class NotificationController {
|
||||
private notificationService: NotificationService;
|
||||
@@ -18,7 +18,6 @@ export class NotificationController {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -27,7 +26,6 @@ export class NotificationController {
|
||||
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;
|
||||
@@ -35,14 +33,13 @@ export class NotificationController {
|
||||
|
||||
try {
|
||||
const newSettingId = await this.notificationService.createSetting(settingData);
|
||||
const newSetting = await this.notificationService.getSettingById(newSettingId); // Fetch the created setting to return it
|
||||
const newSetting = await this.notificationService.getSettingById(newSettingId);
|
||||
// 记录审计日志
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -72,7 +69,6 @@ export class NotificationController {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -96,7 +92,6 @@ export class NotificationController {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -104,7 +99,7 @@ export class NotificationController {
|
||||
// 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
|
||||
const { config } = req.body;
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ message: '无效的通知设置 ID' });
|
||||
@@ -116,28 +111,24 @@ export class NotificationController {
|
||||
}
|
||||
|
||||
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; // Return early if setting not found
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the generic testSetting method from the service, passing the channel type
|
||||
const result = await this.notificationService.testSetting(originalSetting.channel_type, 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 });
|
||||
}
|
||||
};
|
||||
@@ -158,17 +149,14 @@ export class NotificationController {
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the generic testSetting method directly with provided type and config
|
||||
const result = await this.notificationService.testSetting(channel_type, config);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: result.message });
|
||||
} else {
|
||||
// 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 unsaved notification setting:`, error);
|
||||
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // Corrected import name
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
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 saved notification setting
|
||||
|
||||
router.post('/:id/test', notificationController.testSetting);
|
||||
|
||||
// Route for testing an unsaved notification setting configuration
|
||||
router.post('/test-unsaved', notificationController.testUnsavedSetting);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as ProxyService from '../services/proxy.service';
|
||||
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
||||
|
||||
// Helper function to remove sensitive fields for response
|
||||
const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial<ProxyService.ProxyData> | null => {
|
||||
if (!proxy) return null;
|
||||
const { encrypted_password, encrypted_private_key, encrypted_passphrase, ...sanitized } = proxy;
|
||||
@@ -15,7 +15,6 @@ const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial<ProxyServi
|
||||
export const getAllProxies = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const proxies = await ProxyService.getAllProxies();
|
||||
// Sanitize each proxy before sending
|
||||
res.status(200).json(proxies.map(sanitizeProxy));
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 获取代理列表失败:', error);
|
||||
@@ -47,7 +46,6 @@ export const getProxyById = async (req: Request, res: Response) => {
|
||||
// 创建新的代理配置
|
||||
export const createProxy = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Basic validation (more in service)
|
||||
const { name, type, host, port } = req.body;
|
||||
if (!name || !type || !host || !port) {
|
||||
return res.status(400).json({ message: '缺少必要的代理信息 (name, type, host, port)' });
|
||||
@@ -61,7 +59,7 @@ export const createProxy = async (req: Request, res: Response) => {
|
||||
auditLogService.logAction('PROXY_CREATED', { proxyId: newProxy.id, name: newProxy.name, type: newProxy.type });
|
||||
res.status(201).json({
|
||||
message: '代理创建成功',
|
||||
proxy: sanitizeProxy(newProxy) // Return sanitized proxy
|
||||
proxy: sanitizeProxy(newProxy)
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -85,7 +83,6 @@ export const updateProxy = async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ message: '无效的代理 ID' });
|
||||
}
|
||||
|
||||
// Basic validation (more in service)
|
||||
const { name, type, host, port, username, password, auth_method, private_key, passphrase } = req.body;
|
||||
if (!name && !type && !host && port === undefined && username === undefined && password === undefined && auth_method === undefined && private_key === undefined && passphrase === undefined) {
|
||||
return res.status(400).json({ message: '没有提供任何要更新的字段' });
|
||||
@@ -97,7 +94,6 @@ 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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import express, { RequestHandler } from 'express'; // 引入 RequestHandler
|
||||
import express, { RequestHandler } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import {
|
||||
getAllProxies,
|
||||
@@ -6,19 +6,18 @@ import {
|
||||
createProxy,
|
||||
updateProxy,
|
||||
deleteProxy
|
||||
} from './proxies.controller'; // 引入控制器函数
|
||||
} from './proxies.controller';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 应用认证中间件到所有代理路由
|
||||
|
||||
router.use(isAuthenticated);
|
||||
|
||||
// 定义代理 CRUD 路由
|
||||
// 显式类型断言以解决潜在的类型不匹配问题
|
||||
|
||||
router.get('/', getAllProxies as RequestHandler);
|
||||
router.get('/:id', getProxyById as RequestHandler);
|
||||
router.post('/', createProxy as RequestHandler);
|
||||
router.put('/:id', updateProxy as RequestHandler); // 类型断言
|
||||
router.delete('/:id', deleteProxy as RequestHandler); // 类型断言
|
||||
router.put('/:id', updateProxy as RequestHandler);
|
||||
router.delete('/:id', deleteProxy as RequestHandler);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -120,8 +120,6 @@ export const incrementUsage = async (req: Request, res: Response): Promise<void>
|
||||
// 即使没找到也可能返回成功,避免不必要的错误提示
|
||||
console.warn(`尝试增加不存在的快捷指令 (ID: ${id}) 的使用次数`);
|
||||
res.status(200).json({ message: '使用次数已记录 (或指令不存在)' });
|
||||
// 或者严格一点返回 404:
|
||||
// res.status(404).json({ message: '未找到要增加使用次数的快捷指令' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('增加快捷指令使用次数控制器出错:', error);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import * as QuickCommandsController from './quick-commands.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 应用认证中间件到所有快捷指令相关的路由
|
||||
|
||||
router.use(isAuthenticated);
|
||||
|
||||
// 定义路由
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// packages/backend/src/repositories/appearance.repository.ts
|
||||
// Import new async helpers and the instance getter, ensuring getDb is included
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
|
||||
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
||||
import { defaultUiTheme } from '../config/default-themes';
|
||||
// Import findThemeById from terminal theme repository for validation
|
||||
import { findThemeById as findTerminalThemeById } from './terminal-theme.repository';
|
||||
import * as sqlite3 from 'sqlite3'; // Import sqlite3 for Database type hint
|
||||
import * as sqlite3 from 'sqlite3';
|
||||
|
||||
const TABLE_NAME = 'appearance_settings';
|
||||
// Remove SETTINGS_ID as the table is key-value based
|
||||
// const SETTINGS_ID = 1;
|
||||
|
||||
// Define the expected row structure from the database (key-value)
|
||||
|
||||
interface DbAppearanceSettingsRow {
|
||||
key: string;
|
||||
value: string;
|
||||
@@ -19,13 +14,13 @@ interface DbAppearanceSettingsRow {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// Helper function to map DB rows (key-value pairs) to AppearanceSettings object
|
||||
|
||||
const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): AppearanceSettings => {
|
||||
const settings: Partial<AppearanceSettings> = {};
|
||||
let latestUpdatedAt = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
// Update latestUpdatedAt
|
||||
// 更新 latestUpdatedAt
|
||||
if (row.updated_at > latestUpdatedAt) {
|
||||
latestUpdatedAt = row.updated_at;
|
||||
}
|
||||
@@ -35,7 +30,6 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
||||
settings.customUiTheme = row.value;
|
||||
break;
|
||||
case 'activeTerminalThemeId':
|
||||
// Ensure value is parsed as number or null
|
||||
const parsedId = parseInt(row.value, 10);
|
||||
settings.activeTerminalThemeId = isNaN(parsedId) ? null : parsedId;
|
||||
break;
|
||||
@@ -49,19 +43,17 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
||||
settings.editorFontSize = parseInt(row.value, 10);
|
||||
break;
|
||||
case 'terminalBackgroundImage':
|
||||
settings.terminalBackgroundImage = row.value || undefined; // Use undefined if empty string
|
||||
settings.terminalBackgroundImage = row.value || undefined;
|
||||
break;
|
||||
case 'pageBackgroundImage':
|
||||
settings.pageBackgroundImage = row.value || undefined; // Use undefined if empty string
|
||||
settings.pageBackgroundImage = row.value || undefined;
|
||||
break;
|
||||
// Add cases for other potential keys if needed
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with defaults for any missing keys and add _id and updatedAt
|
||||
const defaults = getDefaultAppearanceSettings(); // Get defaults
|
||||
const defaults = getDefaultAppearanceSettings();
|
||||
return {
|
||||
_id: 'global_appearance', // Use a fixed string ID for the conceptual global settings
|
||||
_id: 'global_appearance', // 全局外观设置的固定 ID
|
||||
customUiTheme: settings.customUiTheme ?? defaults.customUiTheme,
|
||||
activeTerminalThemeId: settings.activeTerminalThemeId ?? defaults.activeTerminalThemeId,
|
||||
terminalFontFamily: settings.terminalFontFamily ?? defaults.terminalFontFamily,
|
||||
@@ -69,59 +61,60 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
||||
editorFontSize: settings.editorFontSize ?? defaults.editorFontSize,
|
||||
terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage,
|
||||
pageBackgroundImage: settings.pageBackgroundImage ?? defaults.pageBackgroundImage,
|
||||
updatedAt: latestUpdatedAt || defaults.updatedAt, // Use latest DB timestamp or default
|
||||
updatedAt: latestUpdatedAt || defaults.updatedAt, // 使用最新的更新时间,否则使用默认时间戳
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// 获取默认外观设置 (Simplified, _id is no longer relevant here)
|
||||
// 获取默认外观设置 (已简化, _id 在此不再相关)
|
||||
const getDefaultAppearanceSettings = (): Omit<AppearanceSettings, '_id'> => {
|
||||
return {
|
||||
customUiTheme: JSON.stringify(defaultUiTheme),
|
||||
activeTerminalThemeId: null, // Default should be null initially
|
||||
activeTerminalThemeId: null, // 初始默认应为 null
|
||||
terminalFontFamily: 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"',
|
||||
terminalFontSize: 14,
|
||||
editorFontSize: 14,
|
||||
terminalBackgroundImage: undefined,
|
||||
pageBackgroundImage: undefined,
|
||||
updatedAt: Date.now(), // Provide a default timestamp
|
||||
updatedAt: Date.now(), // 提供默认时间戳
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Ensures default settings exist in the key-value table.
|
||||
* This function is called during database initialization.
|
||||
* 确保默认设置存在于键值表中。
|
||||
* 此函数在数据库初始化期间调用。
|
||||
* @param db - 活动的数据库实例
|
||||
*/
|
||||
export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<void> => {
|
||||
const defaults = getDefaultAppearanceSettings();
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const sqlInsertOrIgnore = `INSERT OR IGNORE INTO ${TABLE_NAME} (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)`;
|
||||
|
||||
// Define default key-value pairs to ensure existence
|
||||
// 定义默认键值对以确保存在
|
||||
const defaultEntries: Array<{ key: keyof Omit<AppearanceSettings, '_id' | 'updatedAt'>, value: any }> = [
|
||||
{ key: 'customUiTheme', value: defaults.customUiTheme },
|
||||
{ key: 'activeTerminalThemeId', value: null }, // Start with null
|
||||
{ key: 'activeTerminalThemeId', value: null }, // 以 null 开始
|
||||
{ key: 'terminalFontFamily', value: defaults.terminalFontFamily },
|
||||
{ key: 'terminalFontSize', value: defaults.terminalFontSize },
|
||||
{ key: 'editorFontSize', value: defaults.editorFontSize },
|
||||
{ key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // Use empty string for DB
|
||||
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // Use empty string for DB
|
||||
{ key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // 数据库中使用空字符串
|
||||
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
|
||||
];
|
||||
|
||||
try {
|
||||
for (const entry of defaultEntries) {
|
||||
// Convert value to string for DB storage, handle null/undefined
|
||||
// 将值转换为字符串以存储到数据库,处理 null/undefined
|
||||
let dbValue: string;
|
||||
if (entry.value === null || entry.value === undefined) {
|
||||
dbValue = entry.key === 'activeTerminalThemeId' ? 'null' : ''; // Store null specifically for theme ID, empty otherwise
|
||||
dbValue = entry.key === 'activeTerminalThemeId' ? 'null' : ''; // 主题 ID 特殊存储为 'null',其他情况为空字符串
|
||||
} else if (typeof entry.value === 'object') {
|
||||
dbValue = JSON.stringify(entry.value);
|
||||
} else {
|
||||
dbValue = String(entry.value);
|
||||
}
|
||||
|
||||
// Special handling for activeTerminalThemeId: store null as 'null' string or the number as string
|
||||
// 对 activeTerminalThemeId 的特殊处理:将 null 存储为 'null' 字符串,或将数字存储为字符串
|
||||
if (entry.key === 'activeTerminalThemeId') {
|
||||
dbValue = entry.value === null ? 'null' : String(entry.value);
|
||||
}
|
||||
@@ -129,9 +122,9 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
||||
|
||||
await runDb(db, sqlInsertOrIgnore, [entry.key, dbValue, nowSeconds, nowSeconds]);
|
||||
}
|
||||
console.log('[AppearanceRepo] 默认外观设置键值对检查完成。');
|
||||
// console.log('[AppearanceRepo] 默认外观设置键值对检查完成。'); // 移除:信息不太关键
|
||||
|
||||
// After ensuring keys exist, try to set the default theme ID if it's currently null
|
||||
// 确保键存在后,如果当前为 null,则尝试设置默认主题 ID
|
||||
await findAndSetDefaultThemeIdIfNull(db);
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -141,64 +134,67 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the default terminal theme ID and updates the 'activeTerminalThemeId' setting if it's currently null.
|
||||
* @param db - The active database instance
|
||||
* 查找默认终端主题 ID,并在 'activeTerminalThemeId' 设置当前为 null 时更新它。
|
||||
* @param db - 活动的数据库实例
|
||||
*/
|
||||
const findAndSetDefaultThemeIdIfNull = async (db: sqlite3.Database): Promise<void> => {
|
||||
try {
|
||||
// Check the current value of activeTerminalThemeId
|
||||
// 检查 activeTerminalThemeId 的当前值
|
||||
const currentSetting = await getDb<{ value: string }>(db, `SELECT value FROM ${TABLE_NAME} WHERE key = ?`, ['activeTerminalThemeId']);
|
||||
|
||||
// Proceed only if the setting exists and its value represents null ('null' string)
|
||||
// 仅当设置存在且其值为 'null' 字符串时继续
|
||||
if (currentSetting && currentSetting.value === 'null') {
|
||||
// Find the default theme from the terminal_themes table (assuming name 'default' marks the default)
|
||||
// 从 terminal_themes 表中查找默认主题(假设名称 'default' 标记为默认)
|
||||
const defaultThemeSql = `SELECT id FROM terminal_themes WHERE name = 'default' AND theme_type = 'preset' LIMIT 1`;
|
||||
const defaultThemeRow = await getDb<{ id: number }>(db, defaultThemeSql);
|
||||
|
||||
if (defaultThemeRow) {
|
||||
const defaultThemeIdNum = defaultThemeRow.id;
|
||||
console.log(`[AppearanceRepo] activeTerminalThemeId 为 null,尝试设置为默认主题 ID: ${defaultThemeIdNum}`);
|
||||
// Update the setting using INSERT OR REPLACE
|
||||
// console.log(`[AppearanceRepo] activeTerminalThemeId 为 null,尝试设置为默认主题 ID: ${defaultThemeIdNum}`); // 移除:信息不太关键
|
||||
// 使用 INSERT OR REPLACE 更新设置
|
||||
const sqlReplace = `INSERT OR REPLACE INTO ${TABLE_NAME} (key, value, updated_at) VALUES (?, ?, ?)`;
|
||||
await runDb(db, sqlReplace, ['activeTerminalThemeId', String(defaultThemeIdNum), Math.floor(Date.now() / 1000)]);
|
||||
} else {
|
||||
console.warn("[AppearanceRepo] 未找到名为 'default' 的预设终端主题,无法设置默认 activeTerminalThemeId。");
|
||||
}
|
||||
} else {
|
||||
// console.log(`[AppearanceRepo] activeTerminalThemeId 已设置 (${currentSetting?.value}) 或键不存在,跳过设置默认 ID。`);
|
||||
}
|
||||
// 如果 activeTerminalThemeId 已设置或键不存在,则不执行任何操作
|
||||
} catch (error: any) {
|
||||
console.error("[AppearanceRepo] 设置默认终端主题 ID 时出错:", error.message);
|
||||
// Don't throw here, just log
|
||||
// 这里不抛出错误,只记录日志
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 获取外观设置
|
||||
* @returns Promise<AppearanceSettings>
|
||||
* 获取外观设置。
|
||||
* 从数据库中检索所有外观相关的键值对,并将它们映射到一个 AppearanceSettings 对象。
|
||||
* @returns {Promise<AppearanceSettings>} 返回包含当前外观设置的对象。
|
||||
* @throws {Error} 如果从数据库获取设置失败。
|
||||
*/
|
||||
export const getAppearanceSettings = async (): Promise<AppearanceSettings> => {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// Fetch all rows from the key-value table
|
||||
// 从键值表中获取所有行
|
||||
const rows = await allDb<DbAppearanceSettingsRow>(db, `SELECT key, value, updated_at FROM ${TABLE_NAME}`);
|
||||
return mapRowsToAppearanceSettings(rows); // Map the key-value pairs to the settings object
|
||||
return mapRowsToAppearanceSettings(rows); // 将键值对映射到设置对象
|
||||
} catch (err: any) {
|
||||
console.error('获取外观设置失败:', err.message);
|
||||
console.error('[AppearanceRepo] 获取外观设置失败:', err.message);
|
||||
throw new Error('获取外观设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新外观设置 (Public API)
|
||||
* @param settingsDto 更新的数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
* 更新外观设置 (公共 API)。
|
||||
* 接收一个包含要更新设置的 DTO,执行必要的验证,然后调用内部更新函数。
|
||||
* @param {UpdateAppearanceDto} settingsDto - 包含要更新设置的对象。
|
||||
* @returns {Promise<boolean>} 如果至少有一个设置被成功更新或插入,则返回 true,否则返回 false。
|
||||
* @throws {Error} 如果验证失败或内部更新过程中发生错误。
|
||||
*/
|
||||
export const updateAppearanceSettings = async (settingsDto: UpdateAppearanceDto): Promise<boolean> => {
|
||||
const db = await getDbInstance();
|
||||
// Perform validation or complex logic if needed before calling internal update
|
||||
// Example validation (already present in service, but could be here too):
|
||||
// 在调用内部更新之前,如果需要,执行验证或复杂逻辑
|
||||
// 验证示例(已存在于服务中,但也可以在这里):
|
||||
if (settingsDto.activeTerminalThemeId !== undefined && settingsDto.activeTerminalThemeId !== null) {
|
||||
try {
|
||||
const themeExists = await findTerminalThemeById(settingsDto.activeTerminalThemeId);
|
||||
@@ -210,18 +206,20 @@ export const updateAppearanceSettings = async (settingsDto: UpdateAppearanceDto)
|
||||
throw new Error(`验证主题 ID 失败: ${validationError.message}`);
|
||||
}
|
||||
}
|
||||
// ... other validations ...
|
||||
// ... 其他验证 ...
|
||||
|
||||
return updateAppearanceSettingsInternal(db, settingsDto);
|
||||
};
|
||||
|
||||
/**
|
||||
* 内部更新外观设置函数 (供内部调用,如初始化)
|
||||
* @param db - Active database instance
|
||||
* @param settingsDto - Data to update
|
||||
* @returns Promise<boolean> - Success status
|
||||
* 内部更新外观设置函数 (供内部调用,例如在初始化或公共 API 中)。
|
||||
* 此函数直接与数据库交互,使用 INSERT OR REPLACE 来更新或插入键值对。
|
||||
* @param {sqlite3.Database} db - 活动的数据库实例。
|
||||
* @param {UpdateAppearanceDto} settingsDto - 包含要更新设置的对象。
|
||||
* @returns {Promise<boolean>} 如果至少有一个设置被成功更新或插入,则返回 true,否则返回 false。
|
||||
* @throws {Error} 如果在数据库操作期间发生错误。
|
||||
*/
|
||||
// Internal function to update settings in the key-value table
|
||||
// 在键值表中更新设置的内部函数
|
||||
const updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDto: UpdateAppearanceDto): Promise<boolean> => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const sqlReplace = `INSERT OR REPLACE INTO ${TABLE_NAME} (key, value, updated_at) VALUES (?, ?, ?)`;
|
||||
@@ -232,37 +230,36 @@ const updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDt
|
||||
const value = settingsDto[key];
|
||||
let dbValue: string;
|
||||
|
||||
// Convert value to string for DB, handle null/undefined
|
||||
// 将值转换为字符串以存储到数据库,处理 null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
dbValue = key === 'activeTerminalThemeId' ? 'null' : ''; // Store null specifically for theme ID
|
||||
dbValue = key === 'activeTerminalThemeId' ? 'null' : ''; // 主题 ID 特殊存储为 'null'
|
||||
} else if (typeof value === 'object') {
|
||||
dbValue = JSON.stringify(value);
|
||||
} else {
|
||||
dbValue = String(value);
|
||||
}
|
||||
|
||||
// Special handling for activeTerminalThemeId to store 'null' string or number string
|
||||
// 对 activeTerminalThemeId 的特殊处理:存储 'null' 字符串或数字字符串
|
||||
if (key === 'activeTerminalThemeId') {
|
||||
dbValue = value === null ? 'null' : String(value);
|
||||
}
|
||||
|
||||
|
||||
// Validation for active_terminal_theme_id type before saving
|
||||
// 保存前验证 active_terminal_theme_id 类型
|
||||
if (key === 'activeTerminalThemeId' && value !== null && typeof value !== 'number') {
|
||||
console.error(`[AppearanceRepo] 更新 activeTerminalThemeId 时收到无效类型值: ${value} (类型: ${typeof value}),应为数字或 null。跳过此字段。`);
|
||||
continue; // Skip this key
|
||||
continue; // 跳过此键
|
||||
}
|
||||
|
||||
// Execute INSERT OR REPLACE for each key-value pair
|
||||
// 对每个键值对执行 INSERT OR REPLACE
|
||||
const result = await runDb(db, sqlReplace, [key, dbValue, nowSeconds]);
|
||||
if (result.changes > 0) {
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
console.log(`[AppearanceRepo] 更新外观设置完成。是否有更改: ${changesMade}`);
|
||||
return changesMade; // Return true if any row was inserted or replaced
|
||||
return changesMade; // 如果有任何行被插入或替换,则返回 true
|
||||
} catch (err: any) {
|
||||
console.error('更新外观设置失败:', err.message);
|
||||
console.error('[AppearanceRepo] 更新外观设置失败:', err.message);
|
||||
throw new Error('更新外观设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// packages/backend/src/repositories/audit.repository.ts
|
||||
import { Database } from 'sqlite3';
|
||||
// Import new async helpers and the instance getter
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import { AuditLogEntry, AuditLogActionType } from '../types/audit.types';
|
||||
|
||||
// Define the expected row structure from the database if it matches AuditLogEntry
|
||||
|
||||
type DbAuditLogRow = AuditLogEntry;
|
||||
|
||||
export class AuditLogRepository {
|
||||
// Remove constructor or leave it empty
|
||||
// constructor() { }
|
||||
|
||||
|
||||
/**
|
||||
* 添加一条审计日志记录
|
||||
* @param actionType 操作类型
|
||||
* @param details 可选的详细信息 (对象或字符串)
|
||||
* 添加一条审计日志记录。
|
||||
* @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
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
let detailsString: string | null = null;
|
||||
|
||||
if (details) {
|
||||
try {
|
||||
detailsString = typeof details === 'string' ? details : JSON.stringify(details);
|
||||
} catch (error: any) {
|
||||
console.error(`[Audit Log] Failed to stringify details for action ${actionType}:`, error.message);
|
||||
console.error(`[审计日志] 序列化操作 ${actionType} 的详情失败:`, error.message);
|
||||
detailsString = JSON.stringify({ error: 'Failed to stringify details', originalDetails: String(details) }); // Ensure originalDetails is stringifiable
|
||||
}
|
||||
}
|
||||
@@ -35,22 +32,20 @@ export class AuditLogRepository {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
await runDb(db, sql, params);
|
||||
// console.log(`[Audit Log] Logged action: ${actionType}`); // Optional: verbose logging
|
||||
|
||||
// --- 添加日志清理逻辑 ---
|
||||
await this.cleanupOldLogs(db);
|
||||
// --- 清理逻辑结束 ---
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`[Audit Log] Error adding log entry for action ${actionType}: ${err.message}`);
|
||||
// Decide if logging failure should throw an error or just be logged
|
||||
// throw new Error(`Error adding log entry: ${err.message}`); // Uncomment to make it critical
|
||||
console.error(`[审计日志] 添加操作 ${actionType} 的日志条目时出错: ${err.message}`);
|
||||
// 决定日志记录失败是应该抛出错误还是仅记录日志
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的审计日志,保持最多 MAX_LOG_ENTRIES 条记录
|
||||
* @param db - 数据库实例
|
||||
* 清理旧的审计日志,保持最多 MAX_LOG_ENTRIES 条记录。
|
||||
* @param db - 数据库实例。
|
||||
*/
|
||||
private async cleanupOldLogs(db: Database): Promise<void> {
|
||||
const MAX_LOG_ENTRIES = 50000; // 设置最大日志条数
|
||||
@@ -71,24 +66,23 @@ export class AuditLogRepository {
|
||||
|
||||
if (total > MAX_LOG_ENTRIES) {
|
||||
const logsToDelete = total - MAX_LOG_ENTRIES;
|
||||
console.log(`[Audit Log] Log count (${total}) exceeds limit (${MAX_LOG_ENTRIES}). Deleting ${logsToDelete} oldest entries.`);
|
||||
console.log(`[审计日志] 日志数量 (${total}) 超过限制 (${MAX_LOG_ENTRIES})。正在删除 ${logsToDelete} 条最旧的记录。`);
|
||||
await runDb(db, deleteSql, [logsToDelete]);
|
||||
console.log(`[Audit Log] Successfully deleted ${logsToDelete} oldest log entries.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Audit Log] Error during log cleanup: ${err.message}`);
|
||||
// 清理失败不应阻止主日志记录流程,仅记录错误
|
||||
console.error(`[审计日志] 日志清理过程中出错: ${err.message}`);
|
||||
// 清理失败不应阻止主日志记录流程,仅记录错误。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审计日志列表 (支持分页和基本过滤)
|
||||
* @param limit 每页数量
|
||||
* @param offset 偏移量
|
||||
* @param actionType 可选的操作类型过滤
|
||||
* @param startDate 可选的开始时间戳 (秒)
|
||||
* @param endDate 可选的结束时间戳 (秒)
|
||||
* @param searchTerm 可选的搜索关键词 (模糊匹配 details)
|
||||
* 获取审计日志列表(支持分页和基本过滤)。
|
||||
* @param limit 每页数量。
|
||||
* @param offset 偏移量。
|
||||
* @param actionType 可选的操作类型过滤。
|
||||
* @param startDate 可选的开始时间戳(秒)。
|
||||
* @param endDate 可选的结束时间戳(秒)。
|
||||
* @param searchTerm 可选的搜索关键词(模糊匹配 details)。
|
||||
*/
|
||||
async getLogs(
|
||||
limit: number = 50,
|
||||
@@ -98,8 +92,7 @@ export class AuditLogRepository {
|
||||
endDate?: number,
|
||||
searchTerm?: string // 添加 searchTerm 参数
|
||||
): Promise<{ logs: AuditLogEntry[], total: number }> {
|
||||
console.log(`[Audit Repo] getLogs called with: actionType=${actionType}, searchTerm=${searchTerm}`); // 添加日志
|
||||
|
||||
|
||||
let baseSql = 'SELECT * FROM audit_logs';
|
||||
let countSql = 'SELECT COUNT(*) as total FROM audit_logs';
|
||||
const whereClauses: string[] = [];
|
||||
@@ -107,14 +100,12 @@ export class AuditLogRepository {
|
||||
const countParams: (string | number)[] = [];
|
||||
|
||||
if (actionType) {
|
||||
console.log(`[Audit Repo] Filtering by actionType: ${actionType}`); // 添加日志
|
||||
whereClauses.push('action_type = ?');
|
||||
params.push(actionType);
|
||||
countParams.push(actionType);
|
||||
}
|
||||
// 添加 searchTerm 的过滤逻辑
|
||||
if (searchTerm) {
|
||||
console.log(`[Audit Repo] Filtering by searchTerm: ${searchTerm}`); // 添加日志
|
||||
// 搜索 details 字段,使用 LIKE 进行模糊匹配
|
||||
whereClauses.push('details LIKE ?');
|
||||
const searchTermLike = `%${searchTerm}%`;
|
||||
@@ -132,25 +123,20 @@ export class AuditLogRepository {
|
||||
baseSql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
console.log(`[Audit Repo] Executing count SQL: ${countSql} with params:`, countParams); // 添加日志
|
||||
console.log(`[Audit Repo] Executing base SQL: ${baseSql} with params:`, params); // 添加日志
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// First get the total count
|
||||
const countRow = await getDbRow<{ total: number }>(db, countSql, countParams);
|
||||
const total = countRow?.total ?? 0;
|
||||
|
||||
// Then get the paginated logs
|
||||
const logs = await allDb<DbAuditLogRow>(db, baseSql, params);
|
||||
|
||||
return { logs, total };
|
||||
} catch (err: any) {
|
||||
console.error(`Error fetching audit logs:`, err.message);
|
||||
throw new Error(`Error fetching audit logs: ${err.message}`);
|
||||
console.error(`获取审计日志时出错:`, err.message);
|
||||
throw new Error(`获取审计日志时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export the class (Removed redundant export below as class is already exported)
|
||||
// export { AuditLogRepository };
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// packages/backend/src/repositories/command-history.repository.ts
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; // Import new async helpers
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
|
||||
// 定义命令历史记录的接口
|
||||
export interface CommandHistoryEntry {
|
||||
@@ -8,7 +7,6 @@ export interface CommandHistoryEntry {
|
||||
timestamp: number; // Unix 时间戳 (秒)
|
||||
}
|
||||
|
||||
// Define the expected row structure from the database if it matches CommandHistoryEntry
|
||||
type DbCommandHistoryRow = CommandHistoryEntry;
|
||||
|
||||
/**
|
||||
@@ -94,7 +92,7 @@ export const clearAllCommands = async (): Promise<number> => {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql);
|
||||
return result.changes; // Return the number of deleted rows
|
||||
return result.changes;
|
||||
} catch (err: any) {
|
||||
console.error('清空命令历史记录时出错:', err.message);
|
||||
throw new Error('无法清空命令历史记录');
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
// packages/backend/src/repositories/connection.repository.ts
|
||||
import { Database, Statement } from 'sqlite3';
|
||||
// Import new async helpers and the instance getter
|
||||
import { Database } from 'sqlite3';
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
|
||||
// Remove top-level db instance
|
||||
// const db = getDb();
|
||||
|
||||
// Define Connection 类型 (可以从 controller 或 types 文件导入,暂时在此定义)
|
||||
// 注意:这里不包含加密字段,因为 Repository 不应处理解密
|
||||
interface ConnectionBase {
|
||||
id: number;
|
||||
name: string | null; // 允许 name 为 null
|
||||
name: string | null;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -21,9 +17,8 @@ interface ConnectionBase {
|
||||
last_connected_at: number | null;
|
||||
}
|
||||
|
||||
// Type for the result of the JOIN query in findAllConnectionsWithTags and findConnectionByIdWithTags
|
||||
interface ConnectionWithTagsRow extends ConnectionBase {
|
||||
tag_ids_str: string | null; // Raw string from GROUP_CONCAT
|
||||
tag_ids_str: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectionWithTags extends ConnectionBase {
|
||||
@@ -35,12 +30,10 @@ export interface FullConnectionData extends ConnectionBase {
|
||||
encrypted_password?: string | null;
|
||||
encrypted_private_key?: string | null;
|
||||
encrypted_passphrase?: string | null;
|
||||
// Include tag_ids for creation/update convenience if needed, handled separately
|
||||
tag_ids?: number[];
|
||||
}
|
||||
|
||||
// Type for the result of the JOIN query in findFullConnectionById
|
||||
// Define a more specific type for the complex row structure
|
||||
|
||||
interface FullConnectionDbRow extends FullConnectionData {
|
||||
proxy_db_id: number | null;
|
||||
proxy_name: string | null;
|
||||
@@ -70,7 +63,6 @@ export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const rows = await allDb<ConnectionWithTagsRow>(db, sql);
|
||||
// Safely map rows, handling potential null tag_ids_str
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : []
|
||||
@@ -97,7 +89,7 @@ export const findConnectionByIdWithTags = async (id: number): Promise<Connection
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const row = await getDbRow<ConnectionWithTagsRow>(db, sql, [id]);
|
||||
if (row && typeof row.id !== 'undefined') { // Check if a valid row was found
|
||||
if (row && typeof row.id !== 'undefined') {
|
||||
return {
|
||||
...row,
|
||||
tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : []
|
||||
@@ -155,7 +147,6 @@ export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'cr
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, params);
|
||||
// Ensure lastID is valid before returning
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
throw new Error('创建连接后未能获取有效的 lastID');
|
||||
}
|
||||
@@ -176,7 +167,7 @@ export const updateConnection = async (id: number, data: Partial<Omit<FullConnec
|
||||
delete fieldsToUpdate.id;
|
||||
delete fieldsToUpdate.created_at;
|
||||
delete fieldsToUpdate.last_connected_at;
|
||||
delete fieldsToUpdate.tag_ids; // Tags handled separately
|
||||
delete fieldsToUpdate.tag_ids;
|
||||
|
||||
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -209,7 +200,6 @@ export const deleteConnection = async (id: number): Promise<boolean> => {
|
||||
const sql = `DELETE FROM connections WHERE id = ?`;
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// ON DELETE CASCADE in connection_tags and ON DELETE SET NULL for proxy_id handle related data
|
||||
const result = await runDb(db, sql, [id]);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
@@ -245,21 +235,18 @@ export const updateLastConnected = async (id: number, timestamp: number): Promis
|
||||
*/
|
||||
export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise<void> => {
|
||||
const db = await getDbInstance();
|
||||
// Use a transaction to ensure atomicity
|
||||
try {
|
||||
await runDb(db, 'BEGIN TRANSACTION');
|
||||
|
||||
// 1. Delete old associations
|
||||
|
||||
await runDb(db, `DELETE FROM connection_tags WHERE connection_id = ?`, [connectionId]);
|
||||
|
||||
// 2. Insert new associations (if any)
|
||||
if (tagIds.length > 0) {
|
||||
const insertSql = `INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`;
|
||||
// Use Promise.all for potentially better performance, though sequential inserts are safer for constraints
|
||||
|
||||
const insertPromises = tagIds
|
||||
.filter(tagId => typeof tagId === 'number' && tagId > 0) // Basic validation
|
||||
.filter(tagId => typeof tagId === 'number' && tagId > 0)
|
||||
.map(tagId => runDb(db, insertSql, [connectionId, tagId]).catch(err => {
|
||||
// Log warning but don't fail the whole transaction for a single tag insert error (e.g., invalid tag ID)
|
||||
console.warn(`Repository: 更新连接 ${connectionId} 标签时,插入 tag_id ${tagId} 失败: ${err.message}`);
|
||||
}));
|
||||
await Promise.all(insertPromises);
|
||||
@@ -269,21 +256,20 @@ export const updateConnectionTags = async (connectionId: number, tagIds: number[
|
||||
} catch (err: any) {
|
||||
console.error(`Repository: 更新连接 ${connectionId} 的标签关联时出错:`, err.message);
|
||||
try {
|
||||
await runDb(db, 'ROLLBACK'); // Attempt to rollback on error
|
||||
await runDb(db, 'ROLLBACK');
|
||||
} catch (rollbackErr: any) {
|
||||
console.error(`Repository: 回滚连接 ${connectionId} 的标签更新事务失败:`, rollbackErr.message);
|
||||
}
|
||||
throw new Error('处理标签关联失败'); // Re-throw original error
|
||||
throw new Error('处理标签关联失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量插入连接(用于导入)
|
||||
* 注意:此函数应在事务中调用 (由调用者负责事务)
|
||||
* Returns an array mapping new connection IDs to their original import data (for tag association)
|
||||
*/
|
||||
export const bulkInsertConnections = async (
|
||||
db: Database, // Pass the transaction-aware db instance
|
||||
db: Database,
|
||||
connections: Array<Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }>
|
||||
): Promise<{ connectionId: number, originalData: any }[]> => {
|
||||
|
||||
@@ -291,8 +277,6 @@ export const bulkInsertConnections = async (
|
||||
const results: { connectionId: number, originalData: any }[] = [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Prepare statement outside the loop for efficiency (though sqlite3 might cache implicitly)
|
||||
// Using direct runDb might be simpler here unless performance is critical
|
||||
|
||||
for (const connData of connections) {
|
||||
const params = [
|
||||
@@ -304,19 +288,15 @@ export const bulkInsertConnections = async (
|
||||
now, now
|
||||
];
|
||||
try {
|
||||
// Use the passed db instance (which should be in a transaction)
|
||||
const connResult = await runDb(db, insertConnSql, params);
|
||||
if (typeof connResult.lastID !== 'number' || connResult.lastID <= 0) {
|
||||
throw new Error(`插入连接 "${connData.name}" 后未能获取有效的 lastID`);
|
||||
}
|
||||
results.push({ connectionId: connResult.lastID, originalData: connData });
|
||||
} catch (err: any) {
|
||||
// Log error but continue with other connections? Or re-throw to fail the whole batch?
|
||||
console.error(`Repository: 批量插入连接 "${connData.name}" 时出错: ${err.message}`);
|
||||
// Decide on error handling strategy for batch operations
|
||||
throw new Error(`批量插入连接 "${connData.name}" 失败`); // Fail fast for now
|
||||
throw new Error(`批量插入连接 "${connData.name}" 失败`);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
// Tag insertion should be handled separately after connections are inserted, using the returned IDs
|
||||
};
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// packages/backend/src/repositories/notification.repository.ts
|
||||
import { Database } from 'sqlite3';
|
||||
// Import new async helpers and the instance getter
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import { NotificationSetting, RawNotificationSetting, NotificationChannelType, NotificationEvent, NotificationChannelConfig } from '../types/notification.types';
|
||||
|
||||
// Helper to parse raw data from DB
|
||||
|
||||
const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => {
|
||||
try {
|
||||
return {
|
||||
@@ -13,20 +10,18 @@ const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => {
|
||||
config: JSON.parse(raw.config || '{}'),
|
||||
enabled_events: JSON.parse(raw.enabled_events || '[]'),
|
||||
};
|
||||
} catch (error: any) { // Add type annotation
|
||||
console.error(`Error parsing notification setting ID ${raw.id}:`, error.message);
|
||||
} catch (error: any) {
|
||||
console.error(`解析通知设置 ID ${raw.id} 时出错:`, error.message);
|
||||
return {
|
||||
...raw,
|
||||
enabled: Boolean(raw.enabled),
|
||||
config: {} as NotificationChannelConfig, // Indicate parsing error
|
||||
config: {} as NotificationChannelConfig,
|
||||
enabled_events: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export class NotificationSettingsRepository {
|
||||
// Remove constructor or leave it empty
|
||||
// constructor() { }
|
||||
|
||||
async getAll(): Promise<NotificationSetting[]> {
|
||||
try {
|
||||
@@ -34,8 +29,8 @@ export class NotificationSettingsRepository {
|
||||
const rows = await allDb<RawNotificationSetting>(db, 'SELECT * FROM notification_settings ORDER BY created_at ASC');
|
||||
return rows.map(parseRawSetting);
|
||||
} catch (err: any) {
|
||||
console.error(`Error fetching notification settings:`, err.message);
|
||||
throw new Error(`Error fetching notification settings: ${err.message}`);
|
||||
console.error(`获取通知设置时出错:`, err.message);
|
||||
throw new Error(`获取通知设置时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +40,12 @@ export class NotificationSettingsRepository {
|
||||
const row = await getDbRow<RawNotificationSetting>(db, 'SELECT * FROM notification_settings WHERE id = ?', [id]);
|
||||
return row ? parseRawSetting(row) : null;
|
||||
} catch (err: any) {
|
||||
console.error(`Error fetching notification setting by ID ${id}:`, err.message);
|
||||
throw new Error(`Error fetching notification setting by ID ${id}: ${err.message}`);
|
||||
console.error(`通过 ID ${id} 获取通知设置时出错:`, err.message);
|
||||
throw new Error(`通过 ID ${id} 获取通知设置时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getEnabledByEvent(event: NotificationEvent): Promise<NotificationSetting[]> {
|
||||
// Note: Query remains inefficient, consider optimization later if needed.
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const rows = await allDb<RawNotificationSetting>(db, 'SELECT * FROM notification_settings WHERE enabled = 1');
|
||||
@@ -59,8 +53,8 @@ export class NotificationSettingsRepository {
|
||||
const filteredRows = parsedRows.filter(setting => setting.enabled_events.includes(event));
|
||||
return filteredRows;
|
||||
} catch (err: any) {
|
||||
console.error(`Error fetching enabled notification settings:`, err.message);
|
||||
throw new Error(`Error fetching enabled notification settings: ${err.message}`);
|
||||
console.error(`获取启用的通知设置时出错:`, err.message);
|
||||
throw new Error(`获取启用的通知设置时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +62,10 @@ export class NotificationSettingsRepository {
|
||||
const sql = `
|
||||
INSERT INTO notification_settings (channel_type, name, enabled, config, enabled_events, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
|
||||
`; // Added created_at, updated_at
|
||||
`;
|
||||
const params = [
|
||||
setting.channel_type,
|
||||
setting.name ?? '', // Ensure name is not undefined
|
||||
setting.name ?? '',
|
||||
setting.enabled ? 1 : 0,
|
||||
JSON.stringify(setting.config || {}),
|
||||
JSON.stringify(setting.enabled_events || [])
|
||||
@@ -85,17 +79,15 @@ export class NotificationSettingsRepository {
|
||||
}
|
||||
return result.lastID;
|
||||
} catch (err: any) {
|
||||
console.error(`Error creating notification setting:`, err.message);
|
||||
throw new Error(`Error creating notification setting: ${err.message}`);
|
||||
console.error(`创建通知设置时出错:`, err.message);
|
||||
throw new Error(`创建通知设置时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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)[] = [];
|
||||
|
||||
// Dynamically build SET clauses
|
||||
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); }
|
||||
@@ -103,11 +95,11 @@ export class NotificationSettingsRepository {
|
||||
if (setting.enabled_events !== undefined) { fields.push('enabled_events = ?'); params.push(JSON.stringify(setting.enabled_events || [])); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
console.warn(`[NotificationRepo] update called for ID ${id} with no fields to update.`);
|
||||
return true; // Or false, depending on desired behavior for no-op update
|
||||
console.warn(`[通知仓库] 针对 ID ${id} 调用了更新,但没有要更新的字段。`);
|
||||
return true;
|
||||
}
|
||||
|
||||
fields.push('updated_at = strftime(\'%s\', \'now\')'); // Always update timestamp
|
||||
fields.push('updated_at = strftime(\'%s\', \'now\')');
|
||||
|
||||
const sql = `UPDATE notification_settings SET ${fields.join(', ')} WHERE id = ?`;
|
||||
params.push(id);
|
||||
@@ -117,8 +109,8 @@ export class NotificationSettingsRepository {
|
||||
const result = await runDb(db, sql, params);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`Error updating notification setting ID ${id}:`, err.message);
|
||||
throw new Error(`Error updating notification setting ID ${id}: ${err.message}`);
|
||||
console.error(`更新通知设置 ID ${id} 时出错:`, err.message);
|
||||
throw new Error(`更新通知设置 ID ${id} 时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +121,9 @@ export class NotificationSettingsRepository {
|
||||
const result = await runDb(db, sql, [id]);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`Error deleting notification setting ID ${id}:`, err.message);
|
||||
throw new Error(`Error deleting notification setting ID ${id}: ${err.message}`);
|
||||
console.error(`删除通知设置 ID ${id} 时出错:`, err.message);
|
||||
throw new Error(`删除通知设置 ID ${id} 时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export the class (Removed redundant export below as class is already exported)
|
||||
// export { NotificationSettingsRepository };
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// packages/backend/src/repositories/passkey.repository.ts
|
||||
import { Database } from 'sqlite3';
|
||||
// Import new async helpers and the instance getter
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
|
||||
// 定义 Passkey 数据库记录的接口
|
||||
@@ -9,18 +6,16 @@ export interface PasskeyRecord {
|
||||
credential_id: string; // Base64URL encoded
|
||||
public_key: string; // Base64URL encoded
|
||||
counter: number;
|
||||
transports: string | null; // JSON string or null
|
||||
transports: string | null;
|
||||
name: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// Define the expected row structure from the database if it matches PasskeyRecord
|
||||
|
||||
type DbPasskeyRow = PasskeyRecord;
|
||||
|
||||
export class PasskeyRepository {
|
||||
// Remove constructor or leave it empty, db instance will be fetched in each method
|
||||
// constructor() { }
|
||||
|
||||
/**
|
||||
* 保存新的 Passkey 凭证
|
||||
@@ -48,7 +43,6 @@ export class PasskeyRepository {
|
||||
return result.lastID;
|
||||
} catch (err: any) {
|
||||
console.error('保存 Passkey 时出错:', err.message);
|
||||
// Handle potential UNIQUE constraint errors on credential_id
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`Credential ID "${credentialId}" 已存在。`);
|
||||
}
|
||||
@@ -76,12 +70,10 @@ export class PasskeyRepository {
|
||||
* 获取所有已注册的 Passkey 记录 (仅选择必要字段)
|
||||
* @returns Promise<Partial<PasskeyRecord>[]> 所有记录的部分信息的数组
|
||||
*/
|
||||
// Adjust return type based on selected columns
|
||||
async getAllPasskeys(): Promise<Array<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>> {
|
||||
const sql = `SELECT id, credential_id, name, transports, created_at FROM passkeys ORDER BY created_at DESC`;
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// Adjust the generic type for allDb to match the selected columns
|
||||
const rows = await allDb<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>(db, sql);
|
||||
return rows;
|
||||
} catch (err: any) {
|
||||
@@ -173,11 +165,3 @@ export class PasskeyRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// Export an instance or the class itself depending on usage pattern
|
||||
// If used as a singleton service, export an instance:
|
||||
// export const passkeyRepository = new PasskeyRepository();
|
||||
// If instantiated elsewhere (e.g., dependency injection), export the class:
|
||||
// export { PasskeyRepository };
|
||||
// For now, let's assume it's used like other repositories (exporting functions/class)
|
||||
// Exporting the class seems more appropriate given its structure
|
||||
// Removed redundant export below as the class is already exported with 'export class'
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
// packages/backend/src/repositories/proxy.repository.ts
|
||||
import { Database, Statement } from 'sqlite3';
|
||||
// Import new async helpers and the instance getter
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
|
||||
// Remove top-level db instance
|
||||
// const db = getDb();
|
||||
|
||||
// 定义 Proxy 类型 (可以共享到 types 文件)
|
||||
export interface ProxyData {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -22,7 +15,6 @@ export interface ProxyData {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// Define the expected row structure from the database if it matches ProxyData
|
||||
type DbProxyRow = ProxyData;
|
||||
|
||||
/**
|
||||
@@ -59,14 +51,12 @@ export const createProxy = async (data: Omit<ProxyData, 'id' | 'created_at' | 'u
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, params);
|
||||
// Ensure lastID is valid before returning
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
throw new Error('创建代理后未能获取有效的 lastID');
|
||||
}
|
||||
return result.lastID;
|
||||
} catch (err: any) {
|
||||
console.error('Repository: 创建代理时出错:', err.message);
|
||||
// Handle potential UNIQUE constraint errors if needed (e.g., on name)
|
||||
throw new Error(`创建代理时出错: ${err.message}`);
|
||||
}
|
||||
};
|
||||
@@ -108,11 +98,8 @@ export const findProxyById = async (id: number): Promise<ProxyData | null> => {
|
||||
export const updateProxy = async (id: number, data: Partial<Omit<ProxyData, 'id' | 'created_at'>>): Promise<boolean> => {
|
||||
const fieldsToUpdate: { [key: string]: any } = { ...data };
|
||||
const params: any[] = [];
|
||||
|
||||
// Remove fields that should not be updated directly
|
||||
delete fieldsToUpdate.id;
|
||||
delete fieldsToUpdate.created_at;
|
||||
// updated_at will be set explicitly
|
||||
|
||||
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -121,10 +108,10 @@ export const updateProxy = async (id: number, data: Partial<Omit<ProxyData, 'id'
|
||||
|
||||
if (!setClauses) {
|
||||
console.warn(`[Repository] updateProxy called for ID ${id} with no fields to update.`);
|
||||
return false; // Nothing to update
|
||||
return false;
|
||||
}
|
||||
|
||||
params.push(id); // Add the ID for the WHERE clause
|
||||
params.push(id);
|
||||
|
||||
const sql = `UPDATE proxies SET ${setClauses} WHERE id = ?`;
|
||||
|
||||
@@ -134,7 +121,6 @@ export const updateProxy = async (id: number, data: Partial<Omit<ProxyData, 'id'
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`Repository: 更新代理 ${id} 时出错:`, err.message);
|
||||
// Handle potential UNIQUE constraint errors if needed
|
||||
throw new Error('更新代理记录失败');
|
||||
}
|
||||
};
|
||||
@@ -143,7 +129,6 @@ export const updateProxy = async (id: number, data: Partial<Omit<ProxyData, 'id'
|
||||
* 删除代理
|
||||
*/
|
||||
export const deleteProxy = async (id: number): Promise<boolean> => {
|
||||
// Note: connections table proxy_id foreign key has ON DELETE SET NULL.
|
||||
const sql = `DELETE FROM proxies WHERE id = ?`;
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// packages/backend/src/repositories/quick-commands.repository.ts
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; // Import new async helpers
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
|
||||
// 定义快捷指令的接口
|
||||
export interface QuickCommand {
|
||||
@@ -11,7 +10,6 @@ export interface QuickCommand {
|
||||
updated_at: number; // Unix 时间戳 (秒)
|
||||
}
|
||||
|
||||
// Define the expected row structure from the database if it matches QuickCommand
|
||||
type DbQuickCommandRow = QuickCommand;
|
||||
|
||||
/**
|
||||
@@ -25,7 +23,6 @@ export const addQuickCommand = async (name: string | null, command: string): Pro
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [name, command]);
|
||||
// Ensure lastID is valid before returning
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
throw new Error('添加快捷指令后未能获取有效的 lastID');
|
||||
}
|
||||
@@ -34,7 +31,7 @@ export const addQuickCommand = async (name: string | null, command: string): Pro
|
||||
console.error('添加快捷指令时出错:', err.message);
|
||||
throw new Error('无法添加快捷指令');
|
||||
}
|
||||
}; // End of addQuickCommand
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新指定的快捷指令
|
||||
@@ -53,7 +50,7 @@ export const updateQuickCommand = async (id: number, name: string | null, comman
|
||||
console.error('更新快捷指令时出错:', err.message);
|
||||
throw new Error('无法更新快捷指令');
|
||||
}
|
||||
}; // End of updateQuickCommand
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 删除指定的快捷指令
|
||||
@@ -70,7 +67,7 @@ export const deleteQuickCommand = async (id: number): Promise<boolean> => {
|
||||
console.error('删除快捷指令时出错:', err.message);
|
||||
throw new Error('无法删除快捷指令');
|
||||
}
|
||||
}; // End of deleteQuickCommand
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有快捷指令
|
||||
@@ -91,7 +88,7 @@ export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name
|
||||
console.error('获取快捷指令时出错:', err.message);
|
||||
throw new Error('无法获取快捷指令');
|
||||
}
|
||||
}; // End of getAllQuickCommands
|
||||
};
|
||||
|
||||
/**
|
||||
* 增加指定快捷指令的使用次数
|
||||
@@ -108,7 +105,7 @@ export const incrementUsageCount = async (id: number): Promise<boolean> => {
|
||||
console.error('增加快捷指令使用次数时出错:', err.message);
|
||||
throw new Error('无法增加快捷指令使用次数');
|
||||
}
|
||||
}; // End of incrementUsageCount
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 查找快捷指令 (用于编辑前获取数据)
|
||||
@@ -120,9 +117,9 @@ export const findQuickCommandById = async (id: number): Promise<QuickCommand | u
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const row = await getDbRow<DbQuickCommandRow>(db, sql, [id]);
|
||||
return row; // Returns undefined if not found
|
||||
return row;
|
||||
} catch (err: any) {
|
||||
console.error('查找快捷指令时出错:', err.message);
|
||||
throw new Error('无法查找快捷指令');
|
||||
}
|
||||
}; // End of findQuickCommandById
|
||||
};
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
// packages/backend/src/repositories/settings.repository.ts
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types'; // <-- Import LayoutNode and PaneName
|
||||
import { CaptchaSettings } from '../types/settings.types'; // <-- Import CaptchaSettings
|
||||
import * as sqlite3 from 'sqlite3'; // Import sqlite3 for Database type hint
|
||||
import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types';
|
||||
import { CaptchaSettings } from '../types/settings.types';
|
||||
import * as sqlite3 from 'sqlite3';
|
||||
|
||||
// Define keys for specific settings
|
||||
const SIDEBAR_CONFIG_KEY = 'sidebarConfig';
|
||||
const CAPTCHA_CONFIG_KEY = 'captchaConfig'; // <-- Add key for CAPTCHA settings
|
||||
const CAPTCHA_CONFIG_KEY = 'captchaConfig';
|
||||
|
||||
export interface Setting {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Define the expected row structure from the database if different from Setting
|
||||
// In this case, it seems Setting matches the SELECT columns.
|
||||
type DbSettingRow = Setting;
|
||||
|
||||
export const settingsRepository = {
|
||||
@@ -30,13 +26,12 @@ export const settingsRepository = {
|
||||
},
|
||||
|
||||
async getSetting(key: string): Promise<string | null> {
|
||||
console.log(`[Repository] Attempting to get setting with key: ${key}`);
|
||||
console.log(`[仓库] 尝试获取键为 ${key} 的设置`);
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// Use the correct type for the expected row structure
|
||||
const row = await getDbRow<{ value: string }>(db, 'SELECT value FROM settings WHERE key = ?', [key]);
|
||||
const value = row ? row.value : null;
|
||||
console.log(`[Repository] Found value for key ${key}:`, value);
|
||||
console.log(`[仓库] 找到键 ${key} 的值:`, value);
|
||||
return value;
|
||||
} catch (err: any) {
|
||||
console.error(`[Repository] 获取设置项 ${key} 时出错:`, err.message);
|
||||
@@ -45,7 +40,7 @@ export const settingsRepository = {
|
||||
},
|
||||
|
||||
async setSetting(key: string, value: string): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000); // Use seconds
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sql = `INSERT INTO settings (key, value, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
@@ -53,27 +48,27 @@ export const settingsRepository = {
|
||||
updated_at = excluded.updated_at`;
|
||||
const params = [key, value, now, now];
|
||||
|
||||
console.log(`[Repository] Attempting to set setting. Key: ${key}, Value: ${value}`);
|
||||
console.log(`[Repository] Executing SQL: ${sql} with params: ${JSON.stringify(params)}`);
|
||||
console.log(`[仓库] 尝试设置设置项。键: ${key}, 值: ${value}`);
|
||||
console.log(`[仓库] 执行 SQL: ${sql},参数: ${JSON.stringify(params)}`);
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, params);
|
||||
console.log(`[Repository] Successfully set setting for key: ${key}. Rows affected: ${result.changes}`);
|
||||
console.log(`[仓库] 成功设置键为 ${key} 的设置项。影响行数: ${result.changes}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[Repository] 设置设置项 ${key} 时出错:`, err.message);
|
||||
throw new Error(`设置设置项 ${key} 失败`);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSetting(key: string): Promise<boolean> { // Return boolean indicating success
|
||||
console.log(`[Repository] Attempting to delete setting with key: ${key}`);
|
||||
async deleteSetting(key: string): Promise<boolean> {
|
||||
console.log(`[仓库] 尝试删除键为 ${key} 的设置`);
|
||||
const sql = 'DELETE FROM settings WHERE key = ?';
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [key]);
|
||||
console.log(`[Repository] Successfully deleted setting for key: ${key}. Rows affected: ${result.changes}`);
|
||||
return result.changes > 0; // Return true if a row was deleted
|
||||
console.log(`[仓库] 成功删除键为 ${key} 的设置。影响行数: ${result.changes}`);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`[Repository] 删除设置项 ${key} 时出错:`, err.message);
|
||||
throw new Error(`删除设置项 ${key} 失败`);
|
||||
@@ -81,28 +76,23 @@ export const settingsRepository = {
|
||||
},
|
||||
|
||||
async setMultipleSettings(settings: Record<string, string>): Promise<void> {
|
||||
console.log('[Repository] setMultipleSettings called with:', JSON.stringify(settings));
|
||||
// Use Promise.all with the async setSetting method
|
||||
// Note: 'this' inside map refers to the settingsRepository object correctly here
|
||||
console.log('[仓库] 调用 setMultipleSettings,参数:', JSON.stringify(settings));
|
||||
const promises = Object.entries(settings).map(([key, value]) =>
|
||||
this.setSetting(key, value)
|
||||
);
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
console.log('[Repository] setMultipleSettings finished successfully.');
|
||||
console.log('[仓库] setMultipleSettings 成功完成。');
|
||||
} catch (error) {
|
||||
console.error('[Repository] setMultipleSettings failed:', error);
|
||||
// Re-throw the error or handle it as needed
|
||||
console.error('[仓库] setMultipleSettings 失败:', error);
|
||||
throw new Error('批量设置失败');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// --- Specific Setting Getters/Setters ---
|
||||
|
||||
/**
|
||||
* 获取侧栏配置
|
||||
* @returns Promise<SidebarConfig> - Returns the parsed config or default
|
||||
*/
|
||||
export const getSidebarConfig = async (): Promise<SidebarConfig> => {
|
||||
const defaultValue: SidebarConfig = { left: [], right: [] };
|
||||
@@ -111,44 +101,38 @@ export const getSidebarConfig = async (): Promise<SidebarConfig> => {
|
||||
if (jsonString) {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
// Basic validation
|
||||
if (config && Array.isArray(config.left) && Array.isArray(config.right)) {
|
||||
// TODO: Add deeper validation if needed (e.g., check if items are valid PaneName)
|
||||
// TODO: 如果需要,添加更深入的验证(例如,检查项目是否为有效的 PaneName)
|
||||
return config as SidebarConfig;
|
||||
}
|
||||
console.warn(`[SettingsRepo] Invalid sidebarConfig format found in DB: ${jsonString}. Returning default.`);
|
||||
console.warn(`[设置仓库] 在数据库中发现无效的 sidebarConfig 格式: ${jsonString}。返回默认值。`);
|
||||
} catch (parseError) {
|
||||
console.error(`[SettingsRepo] Failed to parse sidebarConfig JSON from DB: ${jsonString}`, parseError);
|
||||
console.error(`[设置仓库] 从数据库解析 sidebarConfig JSON 失败: ${jsonString}`, parseError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SettingsRepo] Error fetching sidebar config setting (key: ${SIDEBAR_CONFIG_KEY}):`, error);
|
||||
console.error(`[设置仓库] 获取侧边栏配置设置时出错 (键: ${SIDEBAR_CONFIG_KEY}):`, error);
|
||||
}
|
||||
// Return default if not found, invalid, or error occurred
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置侧栏配置
|
||||
* @param config - The sidebar configuration object
|
||||
*/
|
||||
export const setSidebarConfig = async (config: SidebarConfig): Promise<void> => {
|
||||
try {
|
||||
// Basic validation before stringifying
|
||||
if (!config || typeof config !== 'object' || !Array.isArray(config.left) || !Array.isArray(config.right)) {
|
||||
throw new Error('Invalid sidebar config object provided.');
|
||||
throw new Error('提供了无效的侧边栏配置对象。');
|
||||
}
|
||||
// TODO: Add deeper validation if needed (e.g., check PaneName validity)
|
||||
// TODO: 如果需要,添加更深入的验证(例如,检查 PaneName 的有效性)
|
||||
const jsonString = JSON.stringify(config);
|
||||
await settingsRepository.setSetting(SIDEBAR_CONFIG_KEY, jsonString);
|
||||
} catch (error) {
|
||||
console.error(`[SettingsRepo] Error setting sidebar config (key: ${SIDEBAR_CONFIG_KEY}):`, error);
|
||||
throw new Error('Failed to save sidebar configuration.');
|
||||
console.error(`[设置仓库] 设置侧边栏配置时出错 (键: ${SIDEBAR_CONFIG_KEY}):`, error);
|
||||
throw new Error('保存侧边栏配置失败。');
|
||||
}
|
||||
};
|
||||
|
||||
// --- CAPTCHA Settings ---
|
||||
|
||||
/**
|
||||
* 获取 CAPTCHA 配置
|
||||
* @returns Promise<CaptchaSettings> - 返回解析后的配置或默认值
|
||||
@@ -158,18 +142,16 @@ export const getCaptchaConfig = async (): Promise<CaptchaSettings> => {
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
hcaptchaSiteKey: '',
|
||||
hcaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible
|
||||
hcaptchaSecretKey: '',
|
||||
recaptchaSiteKey: '',
|
||||
recaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible
|
||||
recaptchaSecretKey: '',
|
||||
};
|
||||
try {
|
||||
const jsonString = await settingsRepository.getSetting(CAPTCHA_CONFIG_KEY);
|
||||
if (jsonString) {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
// Basic validation (add more specific checks if needed)
|
||||
if (config && typeof config.enabled === 'boolean' && typeof config.provider === 'string') {
|
||||
// Ensure all keys exist, even if undefined/null from older saves
|
||||
return {
|
||||
enabled: config.enabled ?? defaultValue.enabled,
|
||||
provider: config.provider ?? defaultValue.provider,
|
||||
@@ -179,29 +161,25 @@ export const getCaptchaConfig = async (): Promise<CaptchaSettings> => {
|
||||
recaptchaSecretKey: config.recaptchaSecretKey ?? defaultValue.recaptchaSecretKey,
|
||||
} as CaptchaSettings;
|
||||
}
|
||||
console.warn(`[SettingsRepo] Invalid captchaConfig format found in DB: ${jsonString}. Returning default.`);
|
||||
console.warn(`[设置仓库] 在数据库中发现无效的 captchaConfig 格式: ${jsonString}。返回默认值。`);
|
||||
} catch (parseError) {
|
||||
console.error(`[SettingsRepo] Failed to parse captchaConfig JSON from DB: ${jsonString}`, parseError);
|
||||
console.error(`[设置仓库] 从数据库解析 captchaConfig JSON 失败: ${jsonString}`, parseError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SettingsRepo] Error fetching captcha config setting (key: ${CAPTCHA_CONFIG_KEY}):`, error);
|
||||
console.error(`[设置仓库] 获取 CAPTCHA 配置设置时出错 (键: ${CAPTCHA_CONFIG_KEY}):`, error);
|
||||
}
|
||||
// Return default if not found, invalid, or error occurred
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置 CAPTCHA 配置
|
||||
* @param config - The CAPTCHA configuration object
|
||||
*/
|
||||
export const setCaptchaConfig = async (config: CaptchaSettings): Promise<void> => {
|
||||
try {
|
||||
// Basic validation before stringifying
|
||||
if (!config || typeof config !== 'object' || typeof config.enabled !== 'boolean' || typeof config.provider !== 'string') {
|
||||
throw new Error('Invalid CAPTCHA config object provided.');
|
||||
throw new Error('提供了无效的 CAPTCHA 配置对象。');
|
||||
}
|
||||
// Ensure secret keys are strings, even if empty
|
||||
config.hcaptchaSecretKey = config.hcaptchaSecretKey || '';
|
||||
config.recaptchaSecretKey = config.recaptchaSecretKey || '';
|
||||
config.hcaptchaSiteKey = config.hcaptchaSiteKey || '';
|
||||
@@ -210,22 +188,16 @@ export const setCaptchaConfig = async (config: CaptchaSettings): Promise<void> =
|
||||
const jsonString = JSON.stringify(config);
|
||||
await settingsRepository.setSetting(CAPTCHA_CONFIG_KEY, jsonString);
|
||||
} catch (error) {
|
||||
console.error(`[SettingsRepo] Error setting CAPTCHA config (key: ${CAPTCHA_CONFIG_KEY}):`, error);
|
||||
throw new Error('Failed to save CAPTCHA configuration.');
|
||||
console.error(`[设置仓库] 设置 CAPTCHA 配置时出错 (键: ${CAPTCHA_CONFIG_KEY}):`, error);
|
||||
throw new Error('保存 CAPTCHA 配置失败。');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
/**
|
||||
* Ensures default settings exist in the settings table.
|
||||
* This function should be called during database initialization.
|
||||
* @param db - The active database instance
|
||||
* 确保设置表中存在默认设置。
|
||||
* 此函数应在数据库初始化期间调用。
|
||||
*/
|
||||
export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<void> => {
|
||||
// --- Define Default Structures Here ---
|
||||
// Use OmitIdRecursive helper type if needed, or define structure without IDs
|
||||
type OmitIdRecursive<T> = T extends object
|
||||
? { [K in keyof Omit<T, 'id'>]: OmitIdRecursive<T[K]> }
|
||||
: T;
|
||||
@@ -279,36 +251,34 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
||||
recaptchaSecretKey: '',
|
||||
};
|
||||
|
||||
// --- Define All Default Settings ---
|
||||
const defaultSettings: Record<string, string> = {
|
||||
ipWhitelistEnabled: 'false',
|
||||
ipWhitelist: '',
|
||||
maxLoginAttempts: '5',
|
||||
loginBanDuration: '300', // 5 minutes in seconds
|
||||
focusSwitcherSequence: JSON.stringify(["quickCommandsSearch", "commandHistorySearch", "fileManagerSearch", "commandInput", "terminalSearch"]), // Default focus sequence
|
||||
navBarVisible: 'true', // Default nav bar visibility
|
||||
layoutTree: JSON.stringify(defaultLayoutTreeStructure), // Use the defined structure
|
||||
autoCopyOnSelect: 'false', // Default auto copy setting
|
||||
showPopupFileEditor: 'false', // Default popup editor setting
|
||||
shareFileEditorTabs: 'true', // Default editor tab sharing
|
||||
dockerStatusIntervalSeconds: '5', // Default Docker refresh interval
|
||||
dockerDefaultExpand: 'false', // Default Docker expand state
|
||||
statusMonitorIntervalSeconds: '3', // Default Status Monitor interval
|
||||
[SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure), // Use the defined structure
|
||||
[CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings), // Add default CAPTCHA settings
|
||||
// Add other default settings here
|
||||
loginBanDuration: '300',
|
||||
focusSwitcherSequence: JSON.stringify(["quickCommandsSearch", "commandHistorySearch", "fileManagerSearch", "commandInput", "terminalSearch"]),
|
||||
navBarVisible: 'true',
|
||||
layoutTree: JSON.stringify(defaultLayoutTreeStructure),
|
||||
autoCopyOnSelect: 'false',
|
||||
showPopupFileEditor: 'false',
|
||||
shareFileEditorTabs: 'true',
|
||||
dockerStatusIntervalSeconds: '5',
|
||||
dockerDefaultExpand: 'false',
|
||||
statusMonitorIntervalSeconds: '3',
|
||||
[SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure),
|
||||
[CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings),
|
||||
};
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const sqlInsertOrIgnore = `INSERT OR IGNORE INTO settings (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)`;
|
||||
|
||||
console.log('[SettingsRepo] Ensuring default settings exist...');
|
||||
console.log('[设置仓库] 确保默认设置存在...');
|
||||
try {
|
||||
for (const [key, value] of Object.entries(defaultSettings)) {
|
||||
await runDb(db, sqlInsertOrIgnore, [key, value, nowSeconds, nowSeconds]);
|
||||
}
|
||||
console.log('[SettingsRepo] Default settings check complete.');
|
||||
console.log('[设置仓库] 默认设置检查完成。');
|
||||
} catch (err: any) {
|
||||
console.error(`[SettingsRepo] Error ensuring default settings:`, err.message);
|
||||
throw new Error(`Failed to ensure default settings: ${err.message}`);
|
||||
console.error(`[设置仓库] 确保默认设置时出错:`, err.message);
|
||||
throw new Error(`确保默认设置失败: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// packages/backend/src/repositories/tag.repository.ts
|
||||
import { Database, Statement } from 'sqlite3'; // Keep Statement if using prepare directly, otherwise remove
|
||||
// Import new async helpers and the instance getter
|
||||
import { Database, Statement } from 'sqlite3';
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
|
||||
// Remove top-level db instance
|
||||
// const db = getDb();
|
||||
|
||||
// 定义 Tag 类型 (可以共享到 types 文件)
|
||||
// Let's assume TagData is the correct interface for a row from the 'tags' table
|
||||
export interface TagData {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -24,7 +19,7 @@ export const findAllTags = async (): Promise<TagData[]> => {
|
||||
const rows = await allDb<TagData>(db, `SELECT * FROM tags ORDER BY name ASC`);
|
||||
return rows;
|
||||
} catch (err: any) {
|
||||
console.error('Repository: 查询标签列表时出错:', err.message);
|
||||
console.error('[仓库] 查询标签列表时出错:', err.message);
|
||||
throw new Error('获取标签列表失败');
|
||||
}
|
||||
};
|
||||
@@ -38,7 +33,7 @@ export const findTagById = async (id: number): Promise<TagData | null> => {
|
||||
const row = await getDbRow<TagData>(db, `SELECT * FROM tags WHERE id = ?`, [id]);
|
||||
return row || null;
|
||||
} catch (err: any) {
|
||||
console.error(`Repository: 查询标签 ${id} 时出错:`, err.message);
|
||||
console.error(`[仓库] 查询标签 ${id} 时出错:`, err.message);
|
||||
throw new Error('获取标签信息失败');
|
||||
}
|
||||
};
|
||||
@@ -48,19 +43,17 @@ export const findTagById = async (id: number): Promise<TagData | null> => {
|
||||
* 创建新标签
|
||||
*/
|
||||
export const createTag = async (name: string): Promise<number> => {
|
||||
const now = Math.floor(Date.now() / 1000); // Use seconds for consistency? Check table definition
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sql = `INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)`;
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [name, now, now]);
|
||||
// Ensure lastID is valid before returning
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
throw new Error('创建标签后未能获取有效的 lastID');
|
||||
}
|
||||
return result.lastID;
|
||||
} catch (err: any) {
|
||||
console.error('Repository: 创建标签时出错:', err.message);
|
||||
// Handle unique constraint error specifically if needed
|
||||
console.error('[仓库] 创建标签时出错:', err.message);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`标签名称 "${name}" 已存在。`);
|
||||
}
|
||||
@@ -79,8 +72,7 @@ export const updateTag = async (id: number, name: string): Promise<boolean> => {
|
||||
const result = await runDb(db, sql, [name, now, id]);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`Repository: 更新标签 ${id} 时出错:`, err.message);
|
||||
// Handle unique constraint error specifically if needed
|
||||
console.error(`[仓库] 更新标签 ${id} 时出错:`, err.message);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`标签名称 "${name}" 已存在。`);
|
||||
}
|
||||
@@ -92,15 +84,13 @@ export const updateTag = async (id: number, name: string): Promise<boolean> => {
|
||||
* 删除标签
|
||||
*/
|
||||
export const deleteTag = async (id: number): Promise<boolean> => {
|
||||
// Note: connection_tags junction table has ON DELETE CASCADE for tag_id,
|
||||
// so related entries there will be deleted automatically.
|
||||
const sql = `DELETE FROM tags WHERE id = ?`;
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [id]);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) {
|
||||
console.error(`Repository: 删除标签 ${id} 时出错:`, err.message);
|
||||
console.error(`[仓库] 删除标签 ${id} 时出错:`, err.message);
|
||||
throw new Error('删除标签失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// packages/backend/src/repositories/terminal-theme.repository.ts
|
||||
import { Database } from 'sqlite3'; // Import Database type if needed for type hints
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection'; // Import new async helpers, including getDb
|
||||
// Remove the incorrect import of DbTerminalThemeRow
|
||||
import { Database } from 'sqlite3';
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
|
||||
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
import { defaultXtermTheme } from '../config/default-themes';
|
||||
|
||||
// Define the interface for the raw database row structure
|
||||
// Interface matching the schema in schema.ts
|
||||
interface DbTerminalThemeRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -36,23 +31,18 @@ interface DbTerminalThemeRow {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// SQL_CREATE_TABLE and createTableIfNotExists removed as initialization is handled in database/connection.ts
|
||||
|
||||
// 辅助函数:将数据库行转换为 TerminalTheme 对象
|
||||
// Add type annotation for the input row
|
||||
const mapRowToTerminalTheme = (row: DbTerminalThemeRow): TerminalTheme => {
|
||||
// Basic check if row exists and has id property
|
||||
if (!row || typeof row.id === 'undefined') {
|
||||
console.error("mapRowToTerminalTheme received invalid row:", row);
|
||||
// Return a default or throw an error, depending on desired behavior
|
||||
// For now, let's throw an error to make the issue visible
|
||||
throw new Error("Invalid database row provided to mapRowToTerminalTheme");
|
||||
}
|
||||
try {
|
||||
return {
|
||||
_id: row.id.toString(),
|
||||
name: row.name,
|
||||
// Reconstruct themeData from individual columns
|
||||
themeData: {
|
||||
foreground: row.foreground ?? undefined,
|
||||
background: row.background ?? undefined,
|
||||
@@ -77,16 +67,12 @@ const mapRowToTerminalTheme = (row: DbTerminalThemeRow): TerminalTheme => {
|
||||
brightWhite: row.bright_white ?? undefined,
|
||||
},
|
||||
isPreset: row.theme_type === 'preset',
|
||||
// isSystemDefault needs to be handled differently, maybe based on name 'default'?
|
||||
// For now, let's assume it's not directly mapped or needed here.
|
||||
isSystemDefault: row.name === 'default', // Tentative mapping based on name
|
||||
isSystemDefault: row.name === 'default',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
} catch (e: any) {
|
||||
// Log the entire row for debugging instead of the non-existent theme_data
|
||||
console.error(`Error mapping theme data for theme ID ${row.id}:`, e.message, "Raw row:", row);
|
||||
// Return a partially mapped object or throw error
|
||||
throw new Error(`Failed to map theme data for theme ID ${row.id}`);
|
||||
}
|
||||
};
|
||||
@@ -98,23 +84,20 @@ const mapRowToTerminalTheme = (row: DbTerminalThemeRow): TerminalTheme => {
|
||||
export const findAllThemes = async (): Promise<TerminalTheme[]> => {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// Specify the expected row type for allDb
|
||||
// Correct the ORDER BY clause to use theme_type and sort presets first
|
||||
const rows = await allDb<DbTerminalThemeRow>(db, 'SELECT * FROM terminal_themes ORDER BY CASE theme_type WHEN \'preset\' THEN 0 ELSE 1 END ASC, name ASC');
|
||||
// Filter out potential errors during mapping
|
||||
return rows.map(row => {
|
||||
try {
|
||||
return mapRowToTerminalTheme(row);
|
||||
} catch (mapError: any) {
|
||||
console.error(`Error mapping row ID ${row?.id}:`, mapError.message);
|
||||
return null; // Or handle differently
|
||||
return null;
|
||||
}
|
||||
}).filter((theme): theme is TerminalTheme => theme !== null);
|
||||
} catch (err: any) { // Add type annotation for err
|
||||
} catch (err: any) {
|
||||
console.error('查询所有终端主题失败:', err.message);
|
||||
// 添加详细错误日志
|
||||
console.error('详细错误:', err);
|
||||
throw new Error('查询终端主题失败'); // Re-throw or handle error appropriately
|
||||
throw new Error('查询终端主题失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,15 +109,13 @@ export const findAllThemes = async (): Promise<TerminalTheme[]> => {
|
||||
export const findThemeById = async (id: number): Promise<TerminalTheme | null> => {
|
||||
if (isNaN(id) || id <= 0) {
|
||||
console.error("findThemeById called with invalid ID:", id);
|
||||
return null; // Return null for invalid IDs
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// Specify the expected row type for getDbRow
|
||||
// Use getDb instead of the non-existent getDbRow
|
||||
const row = await getDb<DbTerminalThemeRow>(db, 'SELECT * FROM terminal_themes WHERE id = ?', [id]);
|
||||
return row ? mapRowToTerminalTheme(row) : null;
|
||||
} catch (err: any) { // Add type annotation for err
|
||||
} catch (err: any) {
|
||||
console.error(`查询 ID 为 ${id} 的终端主题失败:`, err.message);
|
||||
throw new Error('查询终端主题失败');
|
||||
}
|
||||
@@ -146,10 +127,10 @@ export const findThemeById = async (id: number): Promise<TerminalTheme | null> =
|
||||
* @returns Promise<TerminalTheme> 新创建的主题
|
||||
*/
|
||||
export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<TerminalTheme> => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000); // Use seconds for DB consistency
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const theme = themeDto.themeData;
|
||||
|
||||
// Define columns based on the DbTerminalThemeRow interface (excluding id, created_at, updated_at)
|
||||
|
||||
const columns = [
|
||||
'name', 'theme_type', 'foreground', 'background', 'cursor', 'cursor_accent',
|
||||
'selection_background', 'black', 'red', 'green', 'yellow', 'blue',
|
||||
@@ -157,14 +138,13 @@ export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<Ter
|
||||
'bright_yellow', 'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white',
|
||||
'created_at', 'updated_at'
|
||||
];
|
||||
// Map themeDto data to corresponding columns, using null for missing optional values
|
||||
const values = [
|
||||
themeDto.name, 'user', // theme_type is 'user' for created themes
|
||||
themeDto.name, 'user',
|
||||
theme?.foreground ?? null, theme?.background ?? null, theme?.cursor ?? null, theme?.cursorAccent ?? null,
|
||||
theme?.selectionBackground ?? null, theme?.black ?? null, theme?.red ?? null, theme?.green ?? null, theme?.yellow ?? null, theme?.blue ?? null,
|
||||
theme?.magenta ?? null, theme?.cyan ?? null, theme?.white ?? null, theme?.brightBlack ?? null, theme?.brightRed ?? null, theme?.brightGreen ?? null,
|
||||
theme?.brightYellow ?? null, theme?.brightBlue ?? null, theme?.brightMagenta ?? null, theme?.brightCyan ?? null, theme?.brightWhite ?? null,
|
||||
nowSeconds, nowSeconds // Use seconds for timestamps
|
||||
nowSeconds, nowSeconds
|
||||
];
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
|
||||
@@ -175,8 +155,7 @@ export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<Ter
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, values); // Use the mapped values array
|
||||
// Ensure lastID is valid before trying to find the theme
|
||||
const result = await runDb(db, sql, values);
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
throw new Error('创建主题后未能获取有效的 lastID');
|
||||
}
|
||||
@@ -184,10 +163,9 @@ export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<Ter
|
||||
if (newTheme) {
|
||||
return newTheme;
|
||||
} else {
|
||||
// This case might happen if findThemeById fails for some reason
|
||||
throw new Error(`创建主题后未能检索到 ID 为 ${result.lastID} 的主题`);
|
||||
}
|
||||
} catch (err: any) { // Add type annotation for err
|
||||
} catch (err: any) {
|
||||
console.error('创建新终端主题失败:', err.message);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`主题名称 "${themeDto.name}" 已存在。`);
|
||||
@@ -215,7 +193,7 @@ export const updateTheme = async (id: number, themeDto: UpdateTerminalThemeDto):
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [themeDto.name, themeDataJson, now, id]);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) { // Add type annotation for err
|
||||
} catch (err: any) {
|
||||
console.error(`更新 ID 为 ${id} 的终端主题失败:`, err.message);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`主题名称 "${themeDto.name}" 已存在。`);
|
||||
@@ -231,13 +209,12 @@ export const updateTheme = async (id: number, themeDto: UpdateTerminalThemeDto):
|
||||
* @returns Promise<boolean> 是否成功删除
|
||||
*/
|
||||
export const deleteTheme = async (id: number): Promise<boolean> => {
|
||||
// Correct the WHERE clause to use theme_type = 'user' instead of is_preset = 0
|
||||
const sql = 'DELETE FROM terminal_themes WHERE id = ? AND theme_type = \'user\'';
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [id]);
|
||||
return result.changes > 0;
|
||||
} catch (err: any) { // Add type annotation for err
|
||||
} catch (err: any) {
|
||||
console.error(`删除 ID 为 ${id} 的终端主题失败:`, err.message);
|
||||
throw new Error('删除终端主题失败');
|
||||
}
|
||||
@@ -252,20 +229,15 @@ export const initializePresetThemes = async (db: Database, presets: Array<Omit<T
|
||||
console.log('[DB Init] 开始检查并初始化预设主题...');
|
||||
// 在这里添加日志,显示总共要处理多少个预设主题
|
||||
console.log(`[DB Init] 发现 ${presets.length} 个预设主题定义。`);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000); // Use seconds for DB consistency
|
||||
// const db = await getDbInstance(); // Use the passed db instance
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (const preset of presets) {
|
||||
// 在循环开始时添加日志,显示正在处理哪个主题
|
||||
console.log(`[DB Init] 正在处理预设主题: "${preset.name}"`);
|
||||
try {
|
||||
// Check using name and theme_type
|
||||
const existing = await getDb<{ id: number }>(db, `SELECT id FROM terminal_themes WHERE name = ? AND theme_type = 'preset'`, [preset.name]);
|
||||
|
||||
if (!existing) {
|
||||
// 添加日志,表明正在插入新主题
|
||||
console.log(`[DB Init] 主题 "${preset.name}" 不存在,正在插入...`);
|
||||
// Map preset.themeData to individual columns
|
||||
const theme = preset.themeData;
|
||||
const columns = [
|
||||
'name', 'theme_type', 'foreground', 'background', 'cursor', 'cursor_accent',
|
||||
@@ -290,14 +262,10 @@ export const initializePresetThemes = async (db: Database, presets: Array<Omit<T
|
||||
await runDb(db, insertSql, values);
|
||||
console.log(`[DB Init] 预设主题 "${preset.name}" 已初始化到数据库。`);
|
||||
} else {
|
||||
// 取消注释并添加日志,表明主题已存在
|
||||
console.log(`[DB Init] 预设主题 "${preset.name}" 已存在,跳过初始化。`);
|
||||
}
|
||||
} catch (err: any) { // Add type annotation for err
|
||||
// Remove reference to non-existent preset_key
|
||||
} catch (err: any) {
|
||||
console.error(`[DB Init] 处理预设主题 "${preset.name}" 时出错:`, err.message);
|
||||
// Decide if one error should stop the whole process or just log and continue
|
||||
// throw err; // Uncomment to stop on first error
|
||||
}
|
||||
}
|
||||
console.log('[DB Init] 预设主题检查和初始化完成。');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as appearanceRepository from '../repositories/appearance.repository';
|
||||
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
||||
import * as terminalThemeRepository from '../repositories/terminal-theme.repository'; // 需要验证 activeTerminalThemeId
|
||||
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
|
||||
|
||||
/**
|
||||
* 获取外观设置
|
||||
@@ -67,4 +67,4 @@ export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise<
|
||||
return appearanceRepository.updateAppearanceSettings(settingsDto);
|
||||
};
|
||||
|
||||
// 注意:背景图片上传/处理逻辑需要根据最终决定(URL vs 上传)来添加。
|
||||
|
||||
|
||||
@@ -48,6 +48,3 @@ export class AuditLogService {
|
||||
return this.repository.getLogs(limit, offset, actionType, startDate, endDate, searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Export a singleton instance if needed throughout the backend
|
||||
// export const auditLogService = new AuditLogService();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { settingsService } from './settings.service';
|
||||
import { CaptchaSettings, CaptchaProvider } from '../types/settings.types';
|
||||
|
||||
// CAPTCHA 验证 API 端点
|
||||
const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify';
|
||||
@@ -95,7 +94,6 @@ export class CaptchaService {
|
||||
const params = new URLSearchParams();
|
||||
params.append('secret', secretKey);
|
||||
params.append('response', token);
|
||||
// params.append('remoteip', userIpAddress); // 如果需要传递用户 IP
|
||||
|
||||
const response = await axios.post(RECAPTCHA_VERIFY_URL, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
|
||||
@@ -11,9 +11,6 @@ export const addCommandHistory = async (command: string): Promise<number> => {
|
||||
if (!command || command.trim().length === 0) {
|
||||
throw new Error('命令不能为空');
|
||||
}
|
||||
// 可以在此添加去重逻辑,如果不想记录重复的命令
|
||||
// const existing = await CommandHistoryRepository.findCommand(command); // 如果需要更复杂的去重逻辑
|
||||
// if (existing) { ... }
|
||||
|
||||
// 调用 upsertCommand 来处理插入或更新时间戳
|
||||
return CommandHistoryRepository.upsertCommand(command.trim());
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import * as ConnectionRepository from '../repositories/connection.repository';
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
import {
|
||||
ConnectionBase,
|
||||
ConnectionWithTags,
|
||||
CreateConnectionInput,
|
||||
UpdateConnectionInput,
|
||||
FullConnectionData // Import FullConnectionData if needed internally or by repo
|
||||
FullConnectionData
|
||||
} from '../types/connection.types'; // 从集中类型文件导入
|
||||
|
||||
// Re-export types if they need to be available via this service module
|
||||
export type { ConnectionBase, ConnectionWithTags, CreateConnectionInput, UpdateConnectionInput };
|
||||
|
||||
|
||||
@@ -33,9 +32,8 @@ export const getConnectionById = async (id: number): Promise<ConnectionWithTags
|
||||
* 创建新连接
|
||||
*/
|
||||
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
|
||||
// 1. Validate input (basic validation, more complex validation can be added)
|
||||
// Removed name validation: if (!input.name || !input.host || !input.username || !input.auth_method) {
|
||||
if (!input.host || !input.username || !input.auth_method) { // Validate required fields except name
|
||||
// 1. 验证输入
|
||||
if (!input.host || !input.username || !input.auth_method) {
|
||||
throw new Error('缺少必要的连接信息 (host, username, auth_method)。');
|
||||
}
|
||||
if (input.auth_method === 'password' && !input.password) {
|
||||
@@ -44,9 +42,8 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
if (input.auth_method === 'key' && !input.private_key) {
|
||||
throw new Error('密钥认证方式需要提供 private_key。');
|
||||
}
|
||||
// Add more validation as needed (port range, proxy existence etc.)
|
||||
|
||||
// 2. Encrypt credentials
|
||||
// 2. 加密凭证
|
||||
let encryptedPassword = null;
|
||||
let encryptedPrivateKey = null;
|
||||
let encryptedPassphrase = null;
|
||||
@@ -60,11 +57,11 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Prepare data for repository
|
||||
// 3. 准备仓库数据
|
||||
const connectionData = {
|
||||
name: input.name || '', // Use empty string '' if name is empty or undefined
|
||||
name: input.name || '', // 如果 name 为空或 undefined,则使用空字符串 ''
|
||||
host: input.host,
|
||||
port: input.port ?? 22, // Default port
|
||||
port: input.port ?? 22, // 默认端口
|
||||
username: input.username,
|
||||
auth_method: input.auth_method,
|
||||
encrypted_password: encryptedPassword,
|
||||
@@ -73,26 +70,25 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
proxy_id: input.proxy_id ?? null,
|
||||
};
|
||||
|
||||
// 4. Create connection record in repository
|
||||
// 4. 在仓库中创建连接记录
|
||||
const newConnectionId = await ConnectionRepository.createConnection(connectionData);
|
||||
|
||||
// 5. Handle tags
|
||||
// 5. 处理标签
|
||||
const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? [];
|
||||
if (tagIds.length > 0) {
|
||||
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
|
||||
}
|
||||
|
||||
// 6. Log audit action
|
||||
// Fetch the created connection to get necessary details for logging
|
||||
// 6. 记录审计操作
|
||||
const newConnection = await getConnectionById(newConnectionId);
|
||||
if (!newConnection) {
|
||||
// This should ideally not happen if creation was successful
|
||||
// 如果创建成功,这理论上不应该发生
|
||||
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
|
||||
throw new Error('创建连接后无法检索到该连接。');
|
||||
}
|
||||
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host });
|
||||
|
||||
// 7. Return the newly created connection with tags
|
||||
// 7. 返回新创建的带标签的连接
|
||||
return newConnection;
|
||||
};
|
||||
|
||||
@@ -100,27 +96,27 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
* 更新连接信息
|
||||
*/
|
||||
export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise<ConnectionWithTags | null> => {
|
||||
// 1. Fetch current connection data (including encrypted fields) to compare
|
||||
// 1. 获取当前连接数据(包括加密字段)以进行比较
|
||||
const currentFullConnection = await ConnectionRepository.findFullConnectionById(id);
|
||||
if (!currentFullConnection) {
|
||||
return null; // Connection not found
|
||||
return null; // 未找到连接
|
||||
}
|
||||
|
||||
// 2. Prepare data for update
|
||||
// 2. 准备更新数据
|
||||
const dataToUpdate: Partial<ConnectionRepository.FullConnectionData> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
let newAuthMethod = input.auth_method || currentFullConnection.auth_method;
|
||||
|
||||
// Update non-credential fields
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name || ''; // Use empty string '' if name is empty string or null/undefined
|
||||
// 更新非凭证字段
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name || ''; // 如果 name 是空字符串或 null/undefined,则使用空字符串 ''
|
||||
if (input.host !== undefined) dataToUpdate.host = input.host;
|
||||
if (input.port !== undefined) dataToUpdate.port = input.port;
|
||||
if (input.username !== undefined) dataToUpdate.username = input.username;
|
||||
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // Allows setting to null
|
||||
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // 允许设置为 null
|
||||
|
||||
// Handle auth method change or credential update
|
||||
// 处理认证方法更改或凭证更新
|
||||
if (input.auth_method && input.auth_method !== currentFullConnection.auth_method) {
|
||||
// Auth method changed
|
||||
// 认证方法已更改
|
||||
dataToUpdate.auth_method = input.auth_method;
|
||||
needsCredentialUpdate = true;
|
||||
if (input.auth_method === 'password') {
|
||||
@@ -128,16 +124,16 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
dataToUpdate.encrypted_password = encrypt(input.password);
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
} else { // key
|
||||
} else { // 密钥
|
||||
if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。');
|
||||
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
|
||||
// Only encrypt if passphrase is a non-empty string
|
||||
// 仅当密码短语为非空字符串时才加密
|
||||
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
|
||||
dataToUpdate.encrypted_password = null;
|
||||
}
|
||||
} else {
|
||||
// Auth method did not change, check if credentials for the current method were provided
|
||||
// Only encrypt and update if a non-empty string is provided
|
||||
// 认证方法未更改,检查是否提供了当前方法的凭证
|
||||
// 仅当提供了非空字符串时才加密和更新
|
||||
if (newAuthMethod === 'password' && input.password && input.password.trim() !== '') {
|
||||
dataToUpdate.encrypted_password = encrypt(input.password);
|
||||
needsCredentialUpdate = true;
|
||||
@@ -145,51 +141,51 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
let passphraseChanged = false;
|
||||
if (input.private_key && input.private_key.trim() !== '') {
|
||||
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
|
||||
// Passphrase must be updated (or cleared) if private key is updated
|
||||
// Encrypt only if non-empty, otherwise set to null
|
||||
// 如果私钥更新,则必须更新(或清除)密码短语
|
||||
// 仅当非空时加密,否则设置为 null
|
||||
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
|
||||
needsCredentialUpdate = true;
|
||||
passphraseChanged = true; // Mark passphrase as handled if key changed
|
||||
passphraseChanged = true; // 如果密钥更改,则将密码短语标记为已处理
|
||||
}
|
||||
// Handle case where only passphrase is changed (and key wasn't)
|
||||
// Check if input.passphrase is defined (could be empty string to clear)
|
||||
// 处理仅更改密码短语(且密钥未更改)的情况
|
||||
// 检查 input.passphrase 是否已定义(可能是空字符串以清除)
|
||||
if (!passphraseChanged && input.passphrase !== undefined) {
|
||||
// Encrypt only if non-empty, otherwise set to null
|
||||
// 仅当非空时加密,否则设置为 null
|
||||
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
|
||||
needsCredentialUpdate = true; // Consider this a credential update
|
||||
needsCredentialUpdate = true; // 将此视为凭证更新
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update connection record if there are changes
|
||||
// 3. 如果有更改,则更新连接记录
|
||||
const hasNonTagChanges = Object.keys(dataToUpdate).length > 0;
|
||||
let updatedFieldsForAudit: string[] = []; // Track fields for audit log
|
||||
let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段
|
||||
if (hasNonTagChanges) {
|
||||
updatedFieldsForAudit = Object.keys(dataToUpdate); // Get fields before update call
|
||||
updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段
|
||||
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
|
||||
if (!updated) {
|
||||
// Should not happen if findFullConnectionById succeeded, but good practice
|
||||
// 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践
|
||||
throw new Error('更新连接记录失败。');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle tags update if tag_ids were provided
|
||||
// 4. 如果提供了 tag_ids,则处理标签更新
|
||||
if (input.tag_ids !== undefined) {
|
||||
const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0);
|
||||
await ConnectionRepository.updateConnectionTags(id, validTagIds);
|
||||
}
|
||||
// Add 'tag_ids' to audit log if they were updated
|
||||
// 如果 tag_ids 已更新,则将其添加到审计日志
|
||||
if (input.tag_ids !== undefined) {
|
||||
updatedFieldsForAudit.push('tag_ids');
|
||||
}
|
||||
|
||||
|
||||
// 5. Log audit action if any changes were made
|
||||
// 5. 如果进行了任何更改,则记录审计操作
|
||||
if (hasNonTagChanges || input.tag_ids !== undefined) {
|
||||
auditLogService.logAction('CONNECTION_UPDATED', { connectionId: id, updatedFields: updatedFieldsForAudit });
|
||||
}
|
||||
|
||||
// 6. Fetch and return the updated connection
|
||||
// 6. 获取并返回更新后的连接
|
||||
return getConnectionById(id);
|
||||
};
|
||||
|
||||
@@ -200,11 +196,11 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
export const deleteConnection = async (id: number): Promise<boolean> => {
|
||||
const deleted = await ConnectionRepository.deleteConnection(id);
|
||||
if (deleted) {
|
||||
// Log audit action after successful deletion
|
||||
// 删除成功后记录审计操作
|
||||
auditLogService.logAction('CONNECTION_DELETED', { connectionId: id });
|
||||
}
|
||||
return deleted;
|
||||
};
|
||||
|
||||
// Note: testConnection, importConnections, exportConnections logic
|
||||
// will be moved to SshService and ImportExportService respectively.
|
||||
// 注意:testConnection、importConnections、exportConnections 逻辑
|
||||
// 将分别移至 SshService 和 ImportExportService。
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
// import { Service } from 'typedi'; // Removed typedi import
|
||||
// import { logger } from '../utils/logger'; // Removed logger import
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -60,11 +58,10 @@ export class DockerService {
|
||||
try {
|
||||
// 尝试执行一个简单的 docker 命令,如 docker version
|
||||
await execAsync('docker version', { timeout: 2000 }); // 5秒超时
|
||||
console.log('[DockerService] Docker is available.'); // Use console.log
|
||||
this.isDockerAvailableCache = true;
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.warn('[DockerService] Docker check failed. Docker might not be installed or running.', { error: error.message }); // Use console.warn
|
||||
|
||||
this.isDockerAvailableCache = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// packages/backend/src/services/import-export.service.ts
|
||||
|
||||
import * as ConnectionRepository from '../repositories/connection.repository';
|
||||
import * as ProxyRepository from '../repositories/proxy.repository';
|
||||
// Import the instance getter and helpers
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import { Database } from 'sqlite3'; // Import Database type
|
||||
|
||||
// Remove top-level db instance
|
||||
|
||||
// --- Interface definitions remain the same ---
|
||||
|
||||
interface ImportedConnectionData {
|
||||
name: string;
|
||||
host: string;
|
||||
@@ -36,7 +33,6 @@ export interface ImportResult {
|
||||
failureCount: number;
|
||||
errors: { connectionName?: string; message: string }[];
|
||||
}
|
||||
// --- End Interface definitions ---
|
||||
|
||||
|
||||
/**
|
||||
@@ -44,9 +40,8 @@ export interface ImportResult {
|
||||
*/
|
||||
export const exportConnections = async (): Promise<ExportedConnectionData[]> => {
|
||||
try {
|
||||
const db = await getDbInstance(); // Get DB instance
|
||||
const db = await getDbInstance();
|
||||
|
||||
// Define a more specific type for the row structure
|
||||
type ExportRow = ConnectionRepository.FullConnectionData & {
|
||||
proxy_db_id: number | null;
|
||||
proxy_name: string | null;
|
||||
@@ -60,7 +55,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
||||
proxy_encrypted_passphrase?: string | null;
|
||||
};
|
||||
|
||||
// Fetch connections joined with proxies using await allDb
|
||||
|
||||
const connectionsWithProxies = await allDb<ExportRow>(db,
|
||||
`SELECT
|
||||
c.*,
|
||||
@@ -75,22 +70,22 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
||||
ORDER BY c.name ASC`
|
||||
);
|
||||
|
||||
// Fetch all tag associations using await allDb
|
||||
|
||||
const tagRows = await allDb<{ connection_id: number, tag_id: number }>(db,
|
||||
'SELECT connection_id, tag_id FROM connection_tags'
|
||||
);
|
||||
|
||||
// Create a map for easy tag lookup
|
||||
|
||||
const tagsMap: { [connId: number]: number[] } = {};
|
||||
tagRows.forEach(row => {
|
||||
if (!tagsMap[row.connection_id]) tagsMap[row.connection_id] = [];
|
||||
tagsMap[row.connection_id].push(row.tag_id);
|
||||
});
|
||||
|
||||
// Format data for export
|
||||
|
||||
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
|
||||
const connection: ExportedConnectionData = {
|
||||
name: row.name ?? 'Unnamed', // Provide default if name is null
|
||||
name: row.name ?? 'Unnamed',
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
username: row.username,
|
||||
@@ -104,12 +99,12 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
||||
|
||||
if (row.proxy_db_id) {
|
||||
connection.proxy = {
|
||||
name: row.proxy_name ?? 'Unnamed Proxy', // Provide default
|
||||
type: row.proxy_type ?? 'SOCKS5', // Provide default or handle error
|
||||
host: row.proxy_host ?? '', // Provide default or handle error
|
||||
port: row.proxy_port ?? 0, // Provide default or handle error
|
||||
name: row.proxy_name ?? 'Unnamed Proxy',
|
||||
type: row.proxy_type ?? 'SOCKS5',
|
||||
host: row.proxy_host ?? '',
|
||||
port: row.proxy_port ?? 0,
|
||||
username: row.proxy_username,
|
||||
auth_method: row.proxy_auth_method ?? 'none', // Provide default
|
||||
auth_method: row.proxy_auth_method ?? 'none',
|
||||
encrypted_password: row.proxy_encrypted_password,
|
||||
encrypted_private_key: row.proxy_encrypted_private_key,
|
||||
encrypted_passphrase: row.proxy_encrypted_passphrase,
|
||||
@@ -122,7 +117,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Service: 导出连接时出错:', err.message);
|
||||
throw new Error(`导出连接失败: ${err.message}`); // Re-throw for controller
|
||||
throw new Error(`导出连接失败: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,26 +142,25 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
const errors: { connectionName?: string; message: string }[] = [];
|
||||
const db = await getDbInstance(); // Get DB instance once for the transaction
|
||||
const db = await getDbInstance();
|
||||
|
||||
try {
|
||||
await runDb(db, 'BEGIN TRANSACTION'); // Start transaction using await runDb
|
||||
await runDb(db, 'BEGIN TRANSACTION');
|
||||
|
||||
const connectionsToInsert: Array<Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }> = [];
|
||||
const proxyCache: { [key: string]: number } = {}; // Cache for created/found proxy IDs
|
||||
const proxyCache: { [key: string]: number } = {};
|
||||
|
||||
|
||||
// --- Pass 1: Validate data and prepare for insertion ---
|
||||
for (const connData of importedData) {
|
||||
try {
|
||||
// Basic validation
|
||||
|
||||
if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) {
|
||||
throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。');
|
||||
}
|
||||
// ... (add other validation as before) ...
|
||||
|
||||
|
||||
let proxyIdToUse: number | null = null;
|
||||
|
||||
// Handle proxy (find or create) - uses async repository functions
|
||||
if (connData.proxy) {
|
||||
const proxyData = connData.proxy;
|
||||
if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port) {
|
||||
@@ -194,11 +188,10 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
proxyIdToUse = await ProxyRepository.createProxy(newProxyData);
|
||||
console.log(`Service: 导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`);
|
||||
}
|
||||
if (proxyIdToUse) proxyCache[cacheKey] = proxyIdToUse; // Cache the ID
|
||||
if (proxyIdToUse) proxyCache[cacheKey] = proxyIdToUse;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare connection data for bulk insert (add tag_ids here)
|
||||
connectionsToInsert.push({
|
||||
name: connData.name,
|
||||
host: connData.host,
|
||||
@@ -209,7 +202,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
encrypted_private_key: connData.encrypted_private_key || null,
|
||||
encrypted_passphrase: connData.encrypted_passphrase || null,
|
||||
proxy_id: proxyIdToUse,
|
||||
tag_ids: connData.tag_ids || [] // Include tag_ids
|
||||
tag_ids: connData.tag_ids || []
|
||||
});
|
||||
|
||||
} catch (connError: any) {
|
||||
@@ -217,25 +210,22 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
errors.push({ connectionName: connData.name || '未知连接', message: connError.message });
|
||||
console.warn(`Service: 处理导入连接 "${connData.name || '未知'}" 时出错: ${connError.message}`);
|
||||
}
|
||||
} // End for loop
|
||||
|
||||
// --- Pass 2: Bulk insert connections ---
|
||||
}
|
||||
let insertedResults: { connectionId: number, originalData: any }[] = [];
|
||||
if (connectionsToInsert.length > 0) {
|
||||
// Pass the transaction-aware db instance
|
||||
|
||||
insertedResults = await ConnectionRepository.bulkInsertConnections(db, connectionsToInsert);
|
||||
successCount = insertedResults.length;
|
||||
}
|
||||
|
||||
// --- Pass 3: Associate tags ---
|
||||
const insertTagSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`; // Use INSERT OR IGNORE
|
||||
const insertTagSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`;
|
||||
for (const result of insertedResults) {
|
||||
const originalTagIds = result.originalData?.tag_ids;
|
||||
if (Array.isArray(originalTagIds) && originalTagIds.length > 0) {
|
||||
const validTagIds = originalTagIds.filter((id: any) => typeof id === 'number' && id > 0);
|
||||
if (validTagIds.length > 0) {
|
||||
const tagPromises = validTagIds.map(tagId =>
|
||||
runDb(db, insertTagSql, [result.connectionId, tagId]).catch(tagError => { // Use await runDb
|
||||
runDb(db, insertTagSql, [result.connectionId, tagId]).catch(tagError => {
|
||||
console.warn(`Service: 导入连接 ${result.originalData.name}: 关联标签 ID ${tagId} 失败: ${tagError.message}`);
|
||||
})
|
||||
);
|
||||
@@ -245,20 +235,19 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
||||
}
|
||||
|
||||
|
||||
// Commit transaction using await runDb
|
||||
|
||||
await runDb(db, 'COMMIT');
|
||||
console.log(`Service: 导入事务提交。成功: ${successCount}, 失败: ${failureCount}`);
|
||||
return { successCount, failureCount, errors };
|
||||
|
||||
} catch (error: any) {
|
||||
// Rollback transaction on any error during the process
|
||||
|
||||
console.error('Service: 导入事务处理出错,正在回滚:', error);
|
||||
try {
|
||||
await runDb(db, 'ROLLBACK'); // Use await runDb
|
||||
await runDb(db, 'ROLLBACK');
|
||||
} catch (rollbackErr: any) {
|
||||
console.error("Service: 回滚事务失败:", rollbackErr);
|
||||
}
|
||||
// Adjust failure count and return error summary
|
||||
failureCount = importedData.length;
|
||||
successCount = 0;
|
||||
errors.push({ message: `事务处理失败: ${error.message}` });
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// packages/backend/src/services/ip-blacklist.service.ts
|
||||
// Import new async helpers and the instance getter
|
||||
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import { settingsService } from './settings.service';
|
||||
import { NotificationService } from './notification.service'; // 导入 NotificationService
|
||||
import * as sqlite3 from 'sqlite3'; // Keep for RunResult type if needed
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
|
||||
// Remove top-level db instance
|
||||
// const db = getDb();
|
||||
const notificationService = new NotificationService(); // 实例化 NotificationService
|
||||
|
||||
// 黑名单相关设置的 Key
|
||||
@@ -28,7 +25,7 @@ interface IpBlacklistEntry {
|
||||
blocked_until: number | null;
|
||||
}
|
||||
|
||||
// Define the expected row structure from the database if it matches IpBlacklistEntry
|
||||
|
||||
type DbIpBlacklistRow = IpBlacklistEntry;
|
||||
|
||||
export class IpBlacklistService {
|
||||
@@ -95,18 +92,16 @@ export class IpBlacklistService {
|
||||
const entry = await this.getEntry(ip);
|
||||
|
||||
if (entry) {
|
||||
// Update existing record
|
||||
const newAttempts = entry.attempts + 1;
|
||||
let blockedUntil = entry.blocked_until;
|
||||
let shouldNotify = false;
|
||||
|
||||
if (newAttempts >= maxAttempts && !entry.blocked_until) { // Only block and notify if not already blocked
|
||||
if (newAttempts >= maxAttempts && !entry.blocked_until) {
|
||||
blockedUntil = now + banDuration;
|
||||
shouldNotify = true;
|
||||
console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`);
|
||||
} else if (newAttempts >= maxAttempts && entry.blocked_until) {
|
||||
console.log(`[IP Blacklist] IP ${ip} 再次登录失败,当前已处于封禁状态。`);
|
||||
// Optionally extend ban duration here if needed
|
||||
}
|
||||
|
||||
await runDb(db,
|
||||
@@ -115,7 +110,6 @@ export class IpBlacklistService {
|
||||
);
|
||||
|
||||
if (shouldNotify && blockedUntil) {
|
||||
// Trigger notification after successful DB update
|
||||
notificationService.sendNotification('IP_BLOCKED', {
|
||||
ip: ip,
|
||||
attempts: newAttempts,
|
||||
@@ -142,7 +136,6 @@ export class IpBlacklistService {
|
||||
);
|
||||
|
||||
if (shouldNotify && blockedUntil) {
|
||||
// Trigger notification after successful DB insert
|
||||
notificationService.sendNotification('IP_BLOCKED', {
|
||||
ip: ip,
|
||||
attempts: attempts,
|
||||
@@ -153,7 +146,6 @@ export class IpBlacklistService {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[IP Blacklist] 记录 IP ${ip} 失败尝试时出错:`, error.message);
|
||||
// Avoid throwing error here to prevent login process failure due to blacklist issues
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +160,6 @@ export class IpBlacklistService {
|
||||
console.log(`[IP Blacklist] 已重置 IP ${ip} 的失败尝试记录。`);
|
||||
} catch (error: any) {
|
||||
console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error.message);
|
||||
// Avoid throwing error here
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +180,7 @@ export class IpBlacklistService {
|
||||
return { entries, total };
|
||||
} catch (error: any) {
|
||||
console.error('[IP Blacklist] 获取黑名单列表时出错:', error.message);
|
||||
// Return empty list on error? Or re-throw?
|
||||
// throw new Error('获取黑名单列表失败');
|
||||
return { entries: [], total: 0 }; // Return empty on error
|
||||
return { entries: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +202,7 @@ export class IpBlacklistService {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, error.message);
|
||||
throw new Error(`从黑名单删除 IP ${ip} 时出错`); // Re-throw error
|
||||
throw new Error(`从黑名单删除 IP ${ip} 时出错`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,20 @@ import {
|
||||
EmailConfig,
|
||||
TelegramConfig,
|
||||
NotificationChannelConfig,
|
||||
NotificationChannelType // Import the missing type
|
||||
NotificationChannelType
|
||||
} from '../types/notification.types';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter
|
||||
import i18next, { defaultLng, supportedLngs } from '../i18n'; // Import i18next instance and config
|
||||
import { settingsService } from './settings.service'; // Import settings service
|
||||
// Removed logger import
|
||||
import Mail from 'nodemailer/lib/mailer';
|
||||
import i18next, { defaultLng, supportedLngs } from '../i18n';
|
||||
import { settingsService } from './settings.service';
|
||||
|
||||
|
||||
// Define translation keys for test notifications for clarity
|
||||
const testSubjectKey = 'testNotification.subject';
|
||||
const testEmailBodyKey = 'testNotification.email.body';
|
||||
const testEmailBodyHtmlKey = 'testNotification.email.bodyHtml'; // Separate key for HTML version
|
||||
const testEmailBodyHtmlKey = 'testNotification.email.bodyHtml';
|
||||
const testWebhookDetailsKey = 'testNotification.webhook.detailsMessage';
|
||||
const testTelegramDetailsKey = 'testNotification.telegram.detailsMessage';
|
||||
const testTelegramBodyTemplateKey = 'testNotification.telegram.bodyTemplate'; // Key for the template itself
|
||||
const testTelegramBodyTemplateKey = 'testNotification.telegram.bodyTemplate';
|
||||
|
||||
export class NotificationService {
|
||||
private repository: NotificationSettingsRepository;
|
||||
@@ -41,14 +39,10 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
async createSetting(settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
|
||||
// Add validation if needed
|
||||
return this.repository.create(settingData);
|
||||
}
|
||||
|
||||
async updateSetting(id: number, settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> {
|
||||
// Add validation if needed
|
||||
// Ensure password is not overwritten if not provided explicitly? Or handle in controller/route.
|
||||
// For now, we assume the full config (including potentially sensitive fields) is passed for updates if needed.
|
||||
return this.repository.update(id, settingData);
|
||||
}
|
||||
|
||||
@@ -56,9 +50,7 @@ export class NotificationService {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
|
||||
// --- Test Notification Methods ---
|
||||
|
||||
// Generic test method dispatcher
|
||||
async testSetting(channelType: NotificationChannelType, config: NotificationChannelConfig): Promise<{ success: boolean; message: string }> {
|
||||
switch (channelType) {
|
||||
case 'email':
|
||||
@@ -68,114 +60,91 @@ export class NotificationService {
|
||||
case 'telegram':
|
||||
return this._testTelegramSetting(config as TelegramConfig);
|
||||
default:
|
||||
console.warn(`[Notification Test] Unsupported channel type for testing: ${channelType}`);
|
||||
console.warn(`[通知测试] 不支持的测试渠道类型: ${channelType}`);
|
||||
return { success: false, message: `不支持测试此渠道类型 (${channelType})` };
|
||||
}
|
||||
}
|
||||
|
||||
// Specific test method for Email
|
||||
private async _testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> {
|
||||
console.log('[Notification Test - Email] Starting test...'); // Added log
|
||||
console.log('[通知测试 - 邮件] 开始测试...');
|
||||
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
|
||||
console.error('[Notification Test - Email] Missing required config.'); // Added log
|
||||
console.error('[通知测试 - 邮件] 缺少必要的配置。');
|
||||
return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 主机, 端口, 发件人)。' };
|
||||
}
|
||||
|
||||
// --- Fetch User Language ---
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
const langSetting = await settingsService.getSetting('language');
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
console.log(`[Notification Test - Email] Using language: ${userLang}`); // Added log
|
||||
console.log(`[通知测试 - 邮件] 使用语言: ${userLang}`);
|
||||
} catch (error) {
|
||||
console.error(`[Notification Test - Email] Error fetching language setting, using default (${defaultLng}):`, error);
|
||||
console.error(`[通知测试 - 邮件] 获取语言设置时出错,使用默认 (${defaultLng}):`, error);
|
||||
}
|
||||
// --- End Fetch User Language ---
|
||||
|
||||
// Let TypeScript infer the options type for SMTP
|
||||
const transporterOptions = {
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure ?? true, // Default to true (TLS)
|
||||
secure: config.smtpSecure ?? true,
|
||||
auth: (config.smtpUser || config.smtpPass) ? {
|
||||
user: config.smtpUser,
|
||||
pass: config.smtpPass, // Ensure password is included if user is present
|
||||
pass: config.smtpPass,
|
||||
} : undefined,
|
||||
// Consider adding TLS options if needed, e.g., ignore self-signed certs
|
||||
// tls: {
|
||||
// rejectUnauthorized: false // Use with caution!
|
||||
// }
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
// Translate event display name first
|
||||
const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' }); // Hardcoding event for test email
|
||||
const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' });
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
from: config.from,
|
||||
to: config.to, // Use the 'to' from config for testing
|
||||
// Use i18next for subject and body, using fetched user language
|
||||
to: config.to,
|
||||
subject: i18next.t(testSubjectKey, { lng: userLang, defaultValue: 'Nexus Terminal Test Notification ({eventDisplay})', eventDisplay: eventDisplayName }),
|
||||
text: i18next.t(testEmailBodyKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `This is a test email from Nexus Terminal for event '{{eventDisplay}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}`, eventDisplay: eventDisplayName }),
|
||||
html: i18next.t(testEmailBodyHtmlKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `<p>This is a test email from <b>Nexus Terminal</b> for event '{{eventDisplay}}'.</p><p>If you received this, your SMTP configuration is working.</p><p>Timestamp: {{timestamp}}</p>`, eventDisplay: eventDisplayName }),
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[Notification Test - Email] Attempting to send test email via ${config.smtpHost}:${config.smtpPort} to ${config.to}`); // Updated log prefix
|
||||
console.log(`[通知测试 - 邮件] 尝试通过 ${config.smtpHost}:${config.smtpPort} 发送测试邮件至 ${config.to}`);
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log(`[Notification Test - Email] Test email sent successfully: ${info.messageId}`); // Updated log prefix
|
||||
// Verify connection if possible (optional)
|
||||
// await transporter.verify();
|
||||
// console.log('[Notification Test - Email] SMTP Connection verified.');
|
||||
console.log(`[通知测试 - 邮件] 测试邮件发送成功: ${info.messageId}`);
|
||||
return { success: true, message: '测试邮件发送成功!请检查收件箱。' };
|
||||
} catch (error: any) {
|
||||
console.error(`[Notification Test - Email] Error sending test email:`, error); // Updated log prefix
|
||||
console.error(`[通知测试 - 邮件] 发送测试邮件时出错:`, error);
|
||||
return { success: false, message: `测试邮件发送失败: ${error.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Specific test method for Webhook
|
||||
private async _testWebhookSetting(config: WebhookConfig): Promise<{ success: boolean; message: string }> {
|
||||
console.log('[Notification Test - Webhook] Starting test...'); // Added log
|
||||
console.log('[通知测试 - Webhook] 开始测试...');
|
||||
if (!config.url) {
|
||||
console.error('[Notification Test - Webhook] Missing URL.'); // Added log
|
||||
console.error('[通知测试 - Webhook] 缺少 URL。');
|
||||
return { success: false, message: '测试 Webhook 失败:缺少 URL。' };
|
||||
}
|
||||
|
||||
// --- Fetch User Language ---
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
const langSetting = await settingsService.getSetting('language');
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
console.log(`[Notification Test - Webhook] Using language: ${userLang}`); // Added log
|
||||
console.log(`[通知测试 - Webhook] 使用语言: ${userLang}`);
|
||||
} catch (error) {
|
||||
console.error(`[Notification Test - Webhook] Error fetching language setting, using default (${defaultLng}):`, error);
|
||||
console.error(`[通知测试 - Webhook] 获取语言设置时出错,使用默认 (${defaultLng}):`, error);
|
||||
}
|
||||
// --- End Fetch User Language ---
|
||||
|
||||
// Use a valid event type for the test payload
|
||||
const testPayload: NotificationPayload = {
|
||||
event: 'SETTINGS_UPDATED', // Use a valid event type
|
||||
event: 'SETTINGS_UPDATED',
|
||||
timestamp: Date.now(),
|
||||
// Use i18next for the details message, using fetched user language
|
||||
details: { message: i18next.t(testWebhookDetailsKey, { lng: userLang, defaultValue: 'This is a test notification from Nexus Terminal (Webhook).' }) }
|
||||
};
|
||||
// Log the translated message safely
|
||||
const translatedWebhookMessage = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details is not an object with message property';
|
||||
console.log(`[Notification Test - Webhook] Test payload created. Translated details.message:`, translatedWebhookMessage); // Added log with type check
|
||||
const translatedWebhookMessage = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details 不是带有 message 属性的对象';
|
||||
console.log(`[通知测试 - Webhook] 测试负载已创建。翻译后的 details.message:`, translatedWebhookMessage);
|
||||
|
||||
// Use the same rendering logic as actual sending
|
||||
// Translate event display name
|
||||
const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event });
|
||||
// Default body for webhook test, using single braces
|
||||
const defaultBody = JSON.stringify(testPayload, null, 2);
|
||||
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`; // Updated default template text
|
||||
// Pass eventDisplayName to renderTemplate
|
||||
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`;
|
||||
const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, testPayload, defaultBody, eventDisplayName);
|
||||
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
@@ -186,130 +155,107 @@ export class NotificationService {
|
||||
...(config.headers || {}),
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: 15000, // Slightly longer timeout for testing
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[Notification Test - Webhook] Sending test Webhook to ${config.url}`); // Updated log prefix
|
||||
console.log(`[通知测试 - Webhook] 发送测试 Webhook 到 ${config.url}`);
|
||||
const response = await axios(requestConfig);
|
||||
console.log(`[Notification Test - Webhook] Test Webhook sent successfully to ${config.url}. Status: ${response.status}`); // Updated log prefix
|
||||
console.log(`[通知测试 - Webhook] 测试 Webhook 成功发送到 ${config.url}。状态: ${response.status}`);
|
||||
return { success: true, message: `测试 Webhook 发送成功 (状态码: ${response.status})。` };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data || error.message || '未知错误';
|
||||
console.error(`[Notification Test - Webhook] Error sending test Webhook to ${config.url}:`, errorMessage); // Updated log prefix
|
||||
console.error(`[通知测试 - Webhook] 发送测试 Webhook 到 ${config.url} 时出错:`, errorMessage);
|
||||
return { success: false, message: `测试 Webhook 发送失败: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Specific test method for Telegram
|
||||
private async _testTelegramSetting(config: TelegramConfig): Promise<{ success: boolean; message: string }> {
|
||||
console.log('[Notification Test - Telegram] Starting test...');
|
||||
console.log('[通知测试 - Telegram] 开始测试...');
|
||||
if (!config.botToken || !config.chatId) {
|
||||
console.error('[Notification Test - Telegram] Missing botToken or chatId.');
|
||||
console.error('[通知测试 - Telegram] 缺少 botToken 或 chatId。');
|
||||
return { success: false, message: '测试 Telegram 失败:缺少机器人 Token 或聊天 ID。' };
|
||||
}
|
||||
|
||||
// --- Fetch User Language ---
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
const langSetting = await settingsService.getSetting('language');
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
console.log(`[Notification Test - Telegram] Using language: ${userLang}`); // Added log
|
||||
console.log(`[通知测试 - Telegram] 使用语言: ${userLang}`);
|
||||
} catch (error) {
|
||||
console.error(`[Notification Test - Telegram] Error fetching language setting, using default (${defaultLng}):`, error);
|
||||
console.error(`[通知测试 - Telegram] 获取语言设置时出错,使用默认 (${defaultLng}):`, error);
|
||||
}
|
||||
// --- End Fetch User Language ---
|
||||
|
||||
// Use a valid event type for the test payload
|
||||
// Declare payload first, details will be added after translation
|
||||
const testPayload: NotificationPayload = {
|
||||
event: 'SETTINGS_UPDATED',
|
||||
timestamp: Date.now(),
|
||||
details: undefined // Initialize details as undefined
|
||||
details: undefined
|
||||
};
|
||||
|
||||
// --- Translation Start ---
|
||||
// Log options before calling t() for details message
|
||||
const detailsOptions = { lng: userLang, defaultValue: 'Fallback: This is a test notification from Nexus Terminal (Telegram).' }; // Use userLang
|
||||
const keyWithNamespace = `notifications:${testTelegramDetailsKey}`; // Explicitly add namespace
|
||||
// console.log(`[Notification Test - Telegram] Calling i18next.t for key '${keyWithNamespace}' with options:`, detailsOptions);
|
||||
const translatedDetailsMessage = i18next.t(keyWithNamespace, detailsOptions); // Use key with namespace
|
||||
// console.log(`[Notification Test - Telegram] Result from i18next.t for key '${keyWithNamespace}':`, translatedDetailsMessage);
|
||||
// --- Translation End ---
|
||||
const detailsOptions = { lng: userLang, defaultValue: 'Fallback: This is a test notification from Nexus Terminal (Telegram).' };
|
||||
const keyWithNamespace = `notifications:${testTelegramDetailsKey}`;
|
||||
const translatedDetailsMessage = i18next.t(keyWithNamespace, detailsOptions);
|
||||
|
||||
// Assign the translated details to the existing payload object
|
||||
testPayload.details = { message: translatedDetailsMessage };
|
||||
|
||||
|
||||
// Log the translated message safely
|
||||
const messageFromPayload = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details is not an object with message property';
|
||||
console.log(`[Notification Test - Telegram] Test payload created. Final details.message in payload:`, messageFromPayload); // Updated log description
|
||||
console.log(`[Notification Test - Telegram] Test payload created. Final details.message in payload:`, messageFromPayload);
|
||||
|
||||
// Use the same rendering logic as actual sending
|
||||
// Get the default template from i18n, fallback to a hardcoded default if key not found
|
||||
// Also explicitly specify namespace and use userLang for the template key
|
||||
const templateKeyWithNamespace = `notifications:${testTelegramBodyTemplateKey}`;
|
||||
const defaultMessageTemplateFromI18n = i18next.t(templateKeyWithNamespace, {
|
||||
lng: userLang, // Use userLang
|
||||
defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\`` // Added Fallback prefix
|
||||
lng: userLang,
|
||||
defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\``
|
||||
});
|
||||
console.log(`[Notification Test - Telegram] Default template from i18n (using lang '${userLang}', key '${templateKeyWithNamespace}'):`, defaultMessageTemplateFromI18n); // Updated log
|
||||
console.log(`[通知测试 - Telegram] 来自 i18n 的默认模板 (使用语言 '${userLang}', 键 '${templateKeyWithNamespace}'):`, defaultMessageTemplateFromI18n);
|
||||
|
||||
// Determine which template to use (user's or default from i18n)
|
||||
const templateToUse = config.messageTemplate || defaultMessageTemplateFromI18n;
|
||||
console.log(`[Notification Test - Telegram] Template to render:`, templateToUse); // Added log
|
||||
console.log(`[通知测试 - Telegram] 要渲染的模板:`, templateToUse);
|
||||
|
||||
// Translate event display name
|
||||
const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event });
|
||||
// Render the template, passing eventDisplayName
|
||||
const messageText = this._renderTemplate(templateToUse, testPayload, '', eventDisplayName);
|
||||
console.log(`[Notification Test - Telegram] Rendered message text:`, messageText);
|
||||
console.log(`[通知测试 - Telegram] 渲染的消息文本:`, messageText);
|
||||
|
||||
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
console.log(`[Notification Test - Telegram] Sending test Telegram message to chat ID ${config.chatId}`); // Updated log prefix
|
||||
console.log(`[通知测试 - Telegram] 发送测试 Telegram 消息到聊天 ID ${config.chatId}`);
|
||||
const response = await axios.post(telegramApiUrl, {
|
||||
chat_id: config.chatId,
|
||||
text: messageText,
|
||||
parse_mode: 'Markdown' // Add parse_mode for testing consistency
|
||||
}, { timeout: 15000 }); // Slightly longer timeout for testing
|
||||
parse_mode: 'Markdown'
|
||||
}, { timeout: 15000 });
|
||||
|
||||
if (response.data?.ok) {
|
||||
console.log(`[Notification Test - Telegram] Test Telegram message sent successfully.`); // Updated log prefix
|
||||
console.log(`[通知测试 - Telegram] 测试 Telegram 消息发送成功。`);
|
||||
return { success: true, message: '测试 Telegram 消息发送成功!' };
|
||||
} else {
|
||||
console.error(`[Notification Test - Telegram] Telegram API returned error:`, response.data?.description); // Updated log prefix
|
||||
console.error(`[通知测试 - Telegram] Telegram API 返回错误:`, response.data?.description);
|
||||
return { success: false, message: `测试 Telegram 发送失败: ${response.data?.description || 'API 返回失败'}` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.description || error.response?.data || error.message || '未知错误';
|
||||
console.error(`[Notification Test - Telegram] Error sending test Telegram message:`, errorMessage); // Updated log prefix
|
||||
console.error(`[通知测试 - Telegram] 发送测试 Telegram 消息时出错:`, errorMessage);
|
||||
return { success: false, message: `测试 Telegram 发送失败: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Core Notification Sending Logic ---
|
||||
|
||||
async sendNotification(event: NotificationEvent, details?: Record<string, any> | string): Promise<void> {
|
||||
console.log(`[Notification] Event triggered: ${event}`, details || '');
|
||||
console.log(`[通知] 事件触发: ${event}`, details || '');
|
||||
|
||||
// 1. Get user's preferred language (or default)
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
// Assuming settingsService is available or needs instantiation if not singleton
|
||||
const langSetting = await settingsService.getSetting('language');
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Notification] Error fetching language setting for event ${event}:`, error);
|
||||
// Proceed with default language
|
||||
console.error(`[通知] 获取事件 ${event} 的语言设置时出错:`, error);
|
||||
}
|
||||
console.log(`[Notification] Using language '${userLang}' for event ${event}`);
|
||||
console.log(`[通知] 事件 ${event} 使用语言 '${userLang}'`);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
event,
|
||||
@@ -319,224 +265,186 @@ export class NotificationService {
|
||||
|
||||
try {
|
||||
const applicableSettings = await this.repository.getEnabledByEvent(event);
|
||||
console.log(`[Notification] Found ${applicableSettings.length} applicable setting(s) for event ${event}`);
|
||||
console.log(`[通知] 找到 ${applicableSettings.length} 个适用于事件 ${event} 的设置`);
|
||||
|
||||
if (applicableSettings.length === 0) {
|
||||
return; // No enabled settings for this event
|
||||
return; // 此事件没有启用的设置
|
||||
}
|
||||
|
||||
const sendPromises = applicableSettings.map(setting => {
|
||||
switch (setting.channel_type) {
|
||||
case 'webhook':
|
||||
return this._sendWebhook(setting, payload, userLang); // Pass userLang
|
||||
return this._sendWebhook(setting, payload, userLang);
|
||||
case 'email':
|
||||
return this._sendEmail(setting, payload, userLang); // Pass userLang
|
||||
return this._sendEmail(setting, payload, userLang);
|
||||
case 'telegram':
|
||||
return this._sendTelegram(setting, payload, userLang); // Pass userLang
|
||||
return this._sendTelegram(setting, payload, userLang);
|
||||
default:
|
||||
console.warn(`[Notification] Unknown channel type: ${setting.channel_type} for setting ID ${setting.id}`);
|
||||
return Promise.resolve(); // Don't fail all if one is unknown
|
||||
console.warn(`[通知] 未知渠道类型: ${setting.channel_type} (设置 ID: ${setting.id})`);
|
||||
return Promise.resolve(); // 如果有一个未知,不要让所有都失败
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all notifications to be attempted
|
||||
await Promise.allSettled(sendPromises);
|
||||
console.log(`[Notification] Finished attempting notifications for event ${event}`);
|
||||
console.log(`[通知] 完成尝试发送事件 ${event} 的通知`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Notification] Error fetching or processing settings for event ${event}:`, error);
|
||||
// Decide if this error itself should trigger a notification (e.g., SERVER_ERROR)
|
||||
// Be careful to avoid infinite loops
|
||||
console.error(`[通知] 获取或处理事件 ${event} 的设置时出错:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private Sending Helpers ---
|
||||
|
||||
// Updated to accept eventDisplayName
|
||||
private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string, eventDisplayName?: string): string {
|
||||
if (!template) return defaultText;
|
||||
let rendered = template;
|
||||
// Replace single-brace placeholders
|
||||
rendered = rendered.replace(/\{event\}/g, payload.event); // Keep original event code if needed
|
||||
rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event); // Use translated name, fallback to original code
|
||||
rendered = rendered.replace(/\{event\}/g, payload.event);
|
||||
rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event);
|
||||
rendered = rendered.replace(/\{timestamp\}/g, new Date(payload.timestamp).toISOString());
|
||||
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
|
||||
rendered = rendered.replace(/\{details\}/g, detailsString);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
// Updated to accept userLang
|
||||
private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
|
||||
const config = setting.config as WebhookConfig;
|
||||
if (!config.url) {
|
||||
console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`);
|
||||
console.error(`[通知] Webhook 设置 ID ${setting.id} 缺少 URL。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Translate event display name
|
||||
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event });
|
||||
|
||||
// Translate payload details if they match a known key structure
|
||||
const translatedDetails = this._translatePayloadDetails(payload.details, userLang);
|
||||
const translatedPayload = { ...payload, details: translatedDetails }; // Keep original payload structure for details translation
|
||||
const translatedPayload = { ...payload, details: translatedDetails };
|
||||
|
||||
const defaultBody = JSON.stringify(translatedPayload, null, 2); // Default body still uses the potentially translated details
|
||||
// Note: Webhook body templates might need adjustments if they expect specific structures
|
||||
// Use default template text if user hasn't provided one
|
||||
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`; // Updated placeholder
|
||||
// Pass eventDisplayName to renderTemplate
|
||||
const defaultBody = JSON.stringify(translatedPayload, null, 2);
|
||||
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`;
|
||||
const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, translatedPayload, defaultBody, eventDisplayName);
|
||||
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
method: config.method || 'POST',
|
||||
url: config.url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json', // Default, can be overridden by config.headers
|
||||
'Content-Type': 'application/json',
|
||||
...(config.headers || {}),
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: 10000, // Add a timeout (e.g., 10 seconds)
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[Notification] Sending Webhook to ${config.url} for event ${payload.event}`);
|
||||
console.log(`[通知] 发送 Webhook 到 ${config.url} (事件: ${payload.event})`);
|
||||
const response = await axios(requestConfig);
|
||||
console.log(`[Notification] Webhook sent successfully to ${config.url}. Status: ${response.status}`);
|
||||
console.log(`[通知] Webhook 成功发送到 ${config.url}。状态: ${response.status}`);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data || error.message;
|
||||
console.error(`[Notification] Error sending Webhook to ${config.url} for setting ID ${setting.id}:`, errorMessage);
|
||||
console.error(`[通知] 发送 Webhook 到 ${config.url} (设置 ID: ${setting.id}) 时出错:`, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Updated to accept userLang
|
||||
private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
|
||||
const config = setting.config as EmailConfig;
|
||||
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
|
||||
console.error(`[Notification] Email setting ID ${setting.id} is missing required SMTP configuration (to, smtpHost, smtpPort, from).`);
|
||||
console.error(`[通知] 邮件设置 ID ${setting.id} 缺少必要的 SMTP 配置 (to, smtpHost, smtpPort, from)。`);
|
||||
return;
|
||||
} // <-- Add missing closing brace here
|
||||
}
|
||||
|
||||
// Let TypeScript infer the options type for SMTP
|
||||
const transporterOptions = {
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure ?? true, // Default to true (TLS)
|
||||
secure: config.smtpSecure ?? true,
|
||||
auth: (config.smtpUser || config.smtpPass) ? {
|
||||
user: config.smtpUser,
|
||||
pass: config.smtpPass, // Ensure password is included if user is present
|
||||
pass: config.smtpPass,
|
||||
} : undefined,
|
||||
// tls: { rejectUnauthorized: false } // Add if needed for self-signed certs, USE WITH CAUTION
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
// Translate subject and body using i18next
|
||||
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
|
||||
const i18nOptions: Record<string, any> = { lng: userLang };
|
||||
if (payload.details && typeof payload.details === 'object') {
|
||||
Object.assign(i18nOptions, payload.details); // Merge details if it's an object
|
||||
Object.assign(i18nOptions, payload.details);
|
||||
} else if (payload.details !== undefined) {
|
||||
i18nOptions.details = payload.details; // Pass non-object details directly if needed
|
||||
i18nOptions.details = payload.details;
|
||||
}
|
||||
|
||||
// Translate event display name first
|
||||
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event });
|
||||
|
||||
// Try to translate the event itself for the subject, fallback to event name
|
||||
const defaultSubjectKey = `event.${payload.event}`; // This key might not exist, rely on template or default below
|
||||
const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`; // Use eventDisplay in fallback
|
||||
const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName }); // Pass eventDisplay for interpolation in fallback
|
||||
const defaultSubjectKey = `event.${payload.event}`;
|
||||
const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`;
|
||||
const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName });
|
||||
|
||||
// Use default subject template from i18n if user hasn't provided one
|
||||
const defaultSubjectTemplateKey = 'testNotification.subject'; // Reuse test subject key structure
|
||||
const defaultSubjectTemplateKey = 'testNotification.subject';
|
||||
const defaultSubjectTemplate = i18next.t(defaultSubjectTemplateKey, { lng: userLang, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName });
|
||||
// Render the subject template, passing the translated event display name
|
||||
const subject = this._renderTemplate(config.subjectTemplate || defaultSubjectTemplate, payload, subjectText, eventDisplayName);
|
||||
|
||||
|
||||
// Translate the main body content based on event type if a key exists
|
||||
const bodyKey = `eventBody.${payload.event}`;
|
||||
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
|
||||
// Use eventDisplay in the default body text
|
||||
const defaultBodyText = `Event: ${eventDisplayName}\nTimestamp: ${new Date(payload.timestamp).toISOString()}\nDetails:\n${detailsString}`;
|
||||
// Pass eventDisplay for interpolation if the translation key uses it
|
||||
const body = i18next.t(bodyKey, { ...i18nOptions, defaultValue: defaultBodyText, eventDisplay: eventDisplayName });
|
||||
// Note: Email body templates are not implemented. Using translated/default text.
|
||||
// If templates were implemented, we'd use _renderTemplate here too.
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
from: config.from,
|
||||
to: config.to,
|
||||
subject: subject,
|
||||
text: body,
|
||||
// html: `<p>${body.replace(/\n/g, '<br>')}</p>` // Simple HTML version
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[Notification] Sending Email via ${config.smtpHost}:${config.smtpPort} to ${config.to} for event ${payload.event}`);
|
||||
console.log(`[通知] 通过 ${config.smtpHost}:${config.smtpPort} 发送邮件至 ${config.to} (事件: ${payload.event})`);
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log(`[Notification] Email sent successfully to ${config.to} for setting ID ${setting.id}. Message ID: ${info.messageId}`);
|
||||
console.log(`[通知] 邮件成功发送至 ${config.to} (设置 ID: ${setting.id})。消息 ID: ${info.messageId}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[Notification] Error sending email for setting ID ${setting.id} via ${config.smtpHost}:`, error);
|
||||
console.error(`[通知] 通过 ${config.smtpHost} 发送邮件 (设置 ID: ${setting.id}) 时出错:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Updated to accept userLang
|
||||
private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
|
||||
const config = setting.config as TelegramConfig;
|
||||
if (!config.botToken || !config.chatId) {
|
||||
console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`);
|
||||
console.error(`[通知] Telegram 设置 ID ${setting.id} 缺少 botToken 或 chatId。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Translate message using i18next
|
||||
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
|
||||
const i18nOptions: Record<string, any> = { lng: userLang };
|
||||
if (payload.details && typeof payload.details === 'object') {
|
||||
Object.assign(i18nOptions, payload.details); // Merge details if it's an object
|
||||
Object.assign(i18nOptions, payload.details);
|
||||
} else if (payload.details !== undefined) {
|
||||
i18nOptions.details = payload.details; // Pass non-object details directly if needed
|
||||
i18nOptions.details = payload.details;
|
||||
}
|
||||
// Translate event display name first
|
||||
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event });
|
||||
|
||||
const messageKey = `eventBody.${payload.event}`; // Use same key as email body for consistency
|
||||
const messageKey = `eventBody.${payload.event}`;
|
||||
const detailsStr = payload.details ? `\nDetails: \`\`\`\n${typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details, null, 2)}\n\`\`\`` : '';
|
||||
// Use eventDisplay in the default message template fallback
|
||||
const defaultMessageTemplateFallback = `*Nexus Terminal Notification*\n\nEvent: \`{eventDisplay}\`\nTimestamp: {timestamp}${detailsStr}`;
|
||||
// Pass eventDisplay for interpolation if the translation key uses it
|
||||
const translatedBody = i18next.t(messageKey, { ...i18nOptions, defaultValue: defaultMessageTemplateFallback, eventDisplay: eventDisplayName });
|
||||
|
||||
// Get the default template from i18n (using the test key structure)
|
||||
const defaultTemplateKey = `notifications:${testTelegramBodyTemplateKey}`;
|
||||
const defaultMessageTemplateFromI18n = i18next.t(defaultTemplateKey, { lng: userLang, defaultValue: translatedBody, eventDisplay: eventDisplayName });
|
||||
|
||||
// Allow template override, use default template from i18n if user input is empty
|
||||
// Pass eventDisplayName to renderTemplate
|
||||
const messageText = this._renderTemplate(config.messageTemplate || defaultMessageTemplateFromI18n, payload, translatedBody, eventDisplayName);
|
||||
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
console.log(`[Notification] Sending Telegram message to chat ID ${config.chatId} for event ${payload.event}`);
|
||||
console.log(`[通知] 发送 Telegram 消息到聊天 ID ${config.chatId} (事件: ${payload.event})`);
|
||||
const response = await axios.post(telegramApiUrl, {
|
||||
chat_id: config.chatId,
|
||||
text: messageText,
|
||||
parse_mode: 'Markdown', // Keep Markdown for actual sending, user is responsible for valid syntax
|
||||
}, { timeout: 10000 }); // Add timeout
|
||||
console.log(`[Notification] Telegram message sent successfully. Response OK:`, response.data?.ok);
|
||||
parse_mode: 'Markdown',
|
||||
}, { timeout: 10000 });
|
||||
console.log(`[通知] Telegram 消息发送成功。响应 OK:`, response.data?.ok);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.description || error.response?.data || error.message;
|
||||
console.error(`[Notification] Error sending Telegram message for setting ID ${setting.id}:`, errorMessage);
|
||||
console.error(`[通知] 发送 Telegram 消息 (设置 ID: ${setting.id}) 时出错:`, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to attempt translation of known payload structures
|
||||
private _translatePayloadDetails(details: any, lng: string): any {
|
||||
if (!details || typeof details !== 'object') {
|
||||
return details; // Return as is if not an object or null/undefined
|
||||
return details;
|
||||
}
|
||||
|
||||
// Example: Translate connection test results
|
||||
if (details.testResult === 'success' && details.connectionName) {
|
||||
return {
|
||||
...details,
|
||||
@@ -550,21 +458,14 @@ export class NotificationService {
|
||||
};
|
||||
}
|
||||
|
||||
// Example: Translate settings update messages (can be expanded)
|
||||
if (details.updatedKeys && Array.isArray(details.updatedKeys)) {
|
||||
if (details.updatedKeys.includes('ipWhitelist')) {
|
||||
return { ...details, message: i18next.t('settings.ipWhitelistUpdated', { lng, defaultValue: 'IP Whitelist updated successfully.' }) };
|
||||
}
|
||||
// Generic settings update
|
||||
return { ...details, message: i18next.t('settings.updated', { lng, defaultValue: 'Settings updated successfully.' }) };
|
||||
}
|
||||
|
||||
|
||||
// Add more translation logic for other event details structures here...
|
||||
|
||||
return details; // Return original details if no specific translation logic matched
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Export a singleton instance if needed throughout the backend
|
||||
// export const notificationService = new NotificationService();
|
||||
|
||||
@@ -13,13 +13,11 @@ import type {
|
||||
VerifyAuthenticationResponseOpts,
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
// AuthenticatorDevice is not typically needed here
|
||||
} from '@simplewebauthn/server'; // Import types directly from the package
|
||||
} from '@simplewebauthn/server';
|
||||
import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository';
|
||||
import { settingsService } from './settings.service'; // Import the exported object
|
||||
|
||||
|
||||
// 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置
|
||||
// TODO: 从 SettingsService 或环境变量获取这些值
|
||||
const rpName = 'Nexus Terminal';
|
||||
// 重要: rpID 应该是你的网站域名 (不包含协议和端口)
|
||||
// 对于本地开发,通常是 'localhost'
|
||||
@@ -29,13 +27,11 @@ const expectedOrigin = process.env.FRONTEND_URL || 'http://localhost:5173'; //
|
||||
|
||||
export class PasskeyService {
|
||||
private passkeyRepository: PasskeyRepository;
|
||||
// No need to instantiate settingsService if it's an object export
|
||||
// private settingsService: typeof settingsService; // Use typeof for the object type
|
||||
|
||||
|
||||
constructor() {
|
||||
this.passkeyRepository = new PasskeyRepository();
|
||||
// this.settingsService = settingsService; // Assign the imported object if needed
|
||||
// TODO: Load rpID, rpName, expectedOrigin using settingsService.getSetting()
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,33 +39,24 @@ export class PasskeyService {
|
||||
*/
|
||||
async generateRegistrationOptions(userName: string = 'nexus-user') { // WebAuthn 需要一个用户名
|
||||
// 暂时不获取已存在的凭证,允许同一用户注册多个设备
|
||||
// const existingCredentials = await this.passkeyRepository.getAllPasskeys();
|
||||
|
||||
|
||||
const options: GenerateRegistrationOptionsOpts = {
|
||||
rpName,
|
||||
rpID,
|
||||
userID: Buffer.from(userName), // userID should be a Buffer/Uint8Array
|
||||
userName: userName,
|
||||
// 不建议排除已存在的凭证,除非有特定原因
|
||||
// excludeCredentials: existingCredentials.map(cred => ({
|
||||
// id: cred.credential_id, // 需要是 Base64URL 格式,存储时确保是这个格式
|
||||
// type: 'public-key',
|
||||
// transports: cred.transports ? JSON.parse(cred.transports) : undefined,
|
||||
// })),
|
||||
|
||||
authenticatorSelection: {
|
||||
// authenticatorAttachment: 'platform', // 倾向于平台认证器 (如 Windows Hello, Touch ID)
|
||||
userVerification: 'preferred', // 倾向于需要用户验证 (PIN, 生物识别)
|
||||
residentKey: 'preferred', // 倾向于创建可发现凭证 (存储在认证器上)
|
||||
},
|
||||
// 可选:增加超时时间
|
||||
timeout: 60000, // 60 秒
|
||||
// attestation: 'none', // Temporarily remove to resolve TS error, 'none' is often default
|
||||
};
|
||||
|
||||
const registrationOptions = await generateRegistrationOptions(options);
|
||||
|
||||
// TODO: 需要将生成的 challenge 临时存储起来 (例如在 session 或 内存缓存中),以便后续验证
|
||||
// 这里暂时返回 challenge,让 Controller 处理存储
|
||||
return registrationOptions;
|
||||
}
|
||||
|
||||
@@ -105,15 +92,10 @@ export class PasskeyService {
|
||||
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
// Use type assertion to bypass strict type checking for registrationInfo properties
|
||||
const registrationInfo = verification.registrationInfo as any;
|
||||
const { credentialPublicKey, credentialID, counter } = registrationInfo;
|
||||
// Optional: Access other potential properties if needed
|
||||
// const { credentialDeviceType, credentialBackedUp } = registrationInfo;
|
||||
|
||||
|
||||
// 将公钥和 ID 转换为 Base64URL 字符串存储 (如果它们还不是)
|
||||
// @simplewebauthn/server 返回的是 Buffer,需要转换
|
||||
const credentialIdBase64Url = Buffer.from(credentialID).toString('base64url');
|
||||
const publicKeyBase64Url = Buffer.from(credentialPublicKey).toString('base64url');
|
||||
|
||||
@@ -140,16 +122,11 @@ export class PasskeyService {
|
||||
* 生成 Passkey 认证选项 (挑战)
|
||||
*/
|
||||
async generateAuthenticationOptions(): Promise<ReturnType<typeof generateAuthenticationOptions>> {
|
||||
// 可选:可以只允许已注册的凭证进行认证
|
||||
// const allowedCredentials = (await this.passkeyRepository.getAllPasskeys()).map(cred => ({
|
||||
// id: cred.credential_id, // 确保是 Base64URL 格式
|
||||
// type: 'public-key',
|
||||
// transports: cred.transports ? JSON.parse(cred.transports) : undefined,
|
||||
// }));
|
||||
|
||||
|
||||
const options: GenerateAuthenticationOptionsOpts = {
|
||||
rpID,
|
||||
// allowCredentials: allowedCredentials, // 如果只想允许已注册的凭证
|
||||
|
||||
userVerification: 'preferred', // 倾向于需要用户验证
|
||||
timeout: 60000, // 60 秒
|
||||
};
|
||||
@@ -178,45 +155,31 @@ export class PasskeyService {
|
||||
throw new Error(`未找到 Credential ID 为 ${credentialIdBase64Url} 的认证器`);
|
||||
}
|
||||
|
||||
// 将存储的公钥从 Base64URL 转回 Buffer
|
||||
// const authenticatorPublicKeyBuffer = Buffer.from(authenticator.public_key, 'base64url'); // Moved lookup after verification
|
||||
|
||||
// Prepare the verification options object - authenticator is looked up internally by the library
|
||||
// based on the response's credential ID, or requires allowCredentials
|
||||
const verificationOptions: VerifyAuthenticationResponseOpts = {
|
||||
response: authenticationResponse,
|
||||
expectedChallenge: expectedChallenge,
|
||||
expectedOrigin: expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
// We need to provide a way for the library to get the authenticator details.
|
||||
// Option 1: Provide `allowCredentials` (if known beforehand)
|
||||
// Option 2: Let the library handle it (requires authenticator to be discoverable/resident key)
|
||||
// Option 3 (Most robust): Provide the authenticator directly after fetching it.
|
||||
// The library likely uses the credential ID from the response to find the authenticator,
|
||||
// especially with discoverable credentials, or requires `allowCredentials`.
|
||||
// Re-adding the authenticator property based on the new error message,
|
||||
// ensuring the structure matches what the library likely expects.
|
||||
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(authenticator.credential_id, 'base64url'),
|
||||
credentialPublicKey: Buffer.from(authenticator.public_key, 'base64url'),
|
||||
counter: authenticator.counter,
|
||||
transports: authenticator.transports ? JSON.parse(authenticator.transports) : undefined,
|
||||
},
|
||||
requireUserVerification: true, // simplewebauthn defaults this to true now
|
||||
} as any; // Use type assertion to bypass strict property check for 'authenticator'
|
||||
requireUserVerification: true,
|
||||
} as any;
|
||||
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse(verificationOptions);
|
||||
} catch (error: any) {
|
||||
// If verification fails, log the error but potentially re-throw a more generic one
|
||||
console.error('Passkey 认证验证时发生异常:', error);
|
||||
const err = error as Error;
|
||||
// Check if the error is due to the authenticator not being found (already handled)
|
||||
if (!err.message.includes(credentialIdBase64Url)) {
|
||||
throw new Error(`Passkey authentication verification failed: ${err.message || err}`);
|
||||
}
|
||||
// If error is related to authenticator not found, rethrow the original specific error
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import * as ProxyRepository from '../repositories/proxy.repository';
|
||||
import { encrypt, decrypt } from '../utils/crypto'; // Assuming crypto utils are needed
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
|
||||
// Re-export or define types (ideally from a shared types file)
|
||||
export interface ProxyData extends ProxyRepository.ProxyData {}
|
||||
|
||||
// Input type for creating a proxy
|
||||
export interface CreateProxyInput {
|
||||
name: string;
|
||||
type: 'SOCKS5' | 'HTTP';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | null;
|
||||
auth_method?: 'none' | 'password' | 'key'; // Optional, defaults to 'none'
|
||||
password?: string | null; // Plain text password
|
||||
private_key?: string | null; // Plain text private key
|
||||
passphrase?: string | null; // Plain text passphrase
|
||||
auth_method?: 'none' | 'password' | 'key';
|
||||
password?: string | null;
|
||||
private_key?: string | null;
|
||||
passphrase?: string | null;
|
||||
}
|
||||
|
||||
// Input type for updating a proxy
|
||||
export interface UpdateProxyInput {
|
||||
name?: string;
|
||||
type?: 'SOCKS5' | 'HTTP';
|
||||
@@ -25,9 +22,9 @@ export interface UpdateProxyInput {
|
||||
port?: number;
|
||||
username?: string | null;
|
||||
auth_method?: 'none' | 'password' | 'key';
|
||||
password?: string | null; // Use undefined for no change, null/empty to clear
|
||||
private_key?: string | null; // Use undefined for no change, null/empty to clear
|
||||
passphrase?: string | null; // Use undefined for no change, null/empty to clear
|
||||
password?: string | null;
|
||||
private_key?: string | null;
|
||||
passphrase?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +32,6 @@ export interface UpdateProxyInput {
|
||||
* 获取所有代理
|
||||
*/
|
||||
export const getAllProxies = async (): Promise<ProxyData[]> => {
|
||||
// Repository returns data with encrypted fields, which is fine for listing generally
|
||||
// If decryption is needed for display, it should happen closer to the presentation layer or selectively
|
||||
return ProxyRepository.findAllProxies();
|
||||
};
|
||||
|
||||
@@ -44,7 +39,6 @@ export const getAllProxies = async (): Promise<ProxyData[]> => {
|
||||
* 根据 ID 获取单个代理
|
||||
*/
|
||||
export const getProxyById = async (id: number): Promise<ProxyData | null> => {
|
||||
// Repository returns data with encrypted fields
|
||||
return ProxyRepository.findProxyById(id);
|
||||
};
|
||||
|
||||
@@ -52,7 +46,7 @@ export const getProxyById = async (id: number): Promise<ProxyData | null> => {
|
||||
* 创建新代理
|
||||
*/
|
||||
export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> => {
|
||||
// 1. Validate input
|
||||
// 1. 验证输入
|
||||
if (!input.name || !input.type || !input.host || !input.port) {
|
||||
throw new Error('缺少必要的代理信息 (name, type, host, port)。');
|
||||
}
|
||||
@@ -62,14 +56,13 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
|
||||
if (input.auth_method === 'key' && !input.private_key) {
|
||||
throw new Error('代理密钥认证方式需要提供 private_key。');
|
||||
}
|
||||
// Add more validation (port range, type check etc.)
|
||||
|
||||
// 2. Encrypt credentials if provided
|
||||
// 2. 如果提供,则加密凭证
|
||||
const encryptedPassword = input.password ? encrypt(input.password) : null;
|
||||
const encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null;
|
||||
const encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
|
||||
// 3. Prepare data for repository
|
||||
// 3. 准备仓库数据
|
||||
const proxyData: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'> = {
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
@@ -82,10 +75,10 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
|
||||
encrypted_passphrase: encryptedPassphrase,
|
||||
};
|
||||
|
||||
// 4. Create proxy record
|
||||
// 4. 创建代理记录
|
||||
const newProxyId = await ProxyRepository.createProxy(proxyData);
|
||||
|
||||
// 5. Fetch and return the newly created proxy
|
||||
// 5. 获取并返回新创建的代理
|
||||
const newProxy = await getProxyById(newProxyId);
|
||||
if (!newProxy) {
|
||||
throw new Error('创建代理后无法检索到该代理。');
|
||||
@@ -97,62 +90,62 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
|
||||
* 更新代理信息
|
||||
*/
|
||||
export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<ProxyData | null> => {
|
||||
// 1. Fetch current proxy data to compare if needed (e.g., for auth method change logic)
|
||||
// 1. 获取当前代理数据以进行比较(例如,用于认证方法更改逻辑)
|
||||
const currentProxy = await ProxyRepository.findProxyById(id);
|
||||
if (!currentProxy) {
|
||||
return null; // Proxy not found
|
||||
return null; // 未找到代理
|
||||
}
|
||||
|
||||
// 2. Prepare data for update
|
||||
// 2. 准备更新数据
|
||||
const dataToUpdate: Partial<Omit<ProxyData, 'id' | 'created_at'>> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
const newAuthMethod = input.auth_method || currentProxy.auth_method;
|
||||
|
||||
// Update standard fields
|
||||
// 更新标准字段
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name;
|
||||
if (input.type !== undefined) dataToUpdate.type = input.type;
|
||||
if (input.host !== undefined) dataToUpdate.host = input.host;
|
||||
if (input.port !== undefined) dataToUpdate.port = input.port;
|
||||
if (input.username !== undefined) dataToUpdate.username = input.username; // Allows clearing
|
||||
if (input.username !== undefined) dataToUpdate.username = input.username; // 允许清除
|
||||
|
||||
// Handle auth method change or credential update
|
||||
// 处理认证方法更改或凭证更新
|
||||
if (input.auth_method && input.auth_method !== currentProxy.auth_method) {
|
||||
dataToUpdate.auth_method = input.auth_method;
|
||||
needsCredentialUpdate = true;
|
||||
// Encrypt new credentials based on the *new* auth_method
|
||||
// 根据 *新* 认证方法加密新凭证
|
||||
if (input.auth_method === 'password') {
|
||||
if (input.password === undefined) throw new Error('切换到密码认证时需要提供 password。');
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
dataToUpdate.encrypted_private_key = null; // Clear old key info
|
||||
dataToUpdate.encrypted_private_key = null; // 清除旧密钥信息
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
} else if (input.auth_method === 'key') {
|
||||
if (input.private_key === undefined) throw new Error('切换到密钥认证时需要提供 private_key。');
|
||||
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
dataToUpdate.encrypted_password = null; // Clear old password info
|
||||
} else { // 'none'
|
||||
dataToUpdate.encrypted_password = null; // 清除旧密码信息
|
||||
} else { // '无'
|
||||
dataToUpdate.encrypted_password = null;
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
}
|
||||
} else {
|
||||
// Auth method unchanged, update credentials if provided for the current method
|
||||
// 认证方法未更改,如果为当前方法提供了凭证,则更新凭证
|
||||
if (newAuthMethod === 'password' && input.password !== undefined) {
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
} else if (newAuthMethod === 'key') {
|
||||
if (input.private_key !== undefined) {
|
||||
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; // Update passphrase together
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; // 一起更新密码短语
|
||||
needsCredentialUpdate = true;
|
||||
} else if (input.passphrase !== undefined) { // Only passphrase updated
|
||||
} else if (input.passphrase !== undefined) { // 仅更新密码短语
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update proxy record if there are changes
|
||||
// 3. 如果有更改,则更新代理记录
|
||||
const hasChanges = Object.keys(dataToUpdate).length > 0;
|
||||
if (hasChanges) {
|
||||
const updated = await ProxyRepository.updateProxy(id, dataToUpdate);
|
||||
@@ -161,7 +154,7 @@ export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch and return the updated proxy
|
||||
// 4. 获取并返回更新后的代理
|
||||
return getProxyById(id);
|
||||
};
|
||||
|
||||
@@ -169,6 +162,5 @@ export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<
|
||||
* 删除代理
|
||||
*/
|
||||
export const deleteProxy = async (id: number): Promise<boolean> => {
|
||||
// Repository handles setting foreign keys to NULL in connections table
|
||||
return ProxyRepository.deleteProxy(id);
|
||||
};
|
||||
|
||||
@@ -3,16 +3,16 @@ import {
|
||||
Setting,
|
||||
getSidebarConfig as getSidebarConfigFromRepo,
|
||||
setSidebarConfig as setSidebarConfigInRepo,
|
||||
getCaptchaConfig as getCaptchaConfigFromRepo, // <-- Import CAPTCHA repo getter
|
||||
setCaptchaConfig as setCaptchaConfigInRepo, // <-- Import CAPTCHA repo setter
|
||||
getCaptchaConfig as getCaptchaConfigFromRepo,
|
||||
setCaptchaConfig as setCaptchaConfigInRepo,
|
||||
} from '../repositories/settings.repository';
|
||||
import {
|
||||
SidebarConfig,
|
||||
PaneName,
|
||||
UpdateSidebarConfigDto,
|
||||
CaptchaSettings, // <-- Import CAPTCHA types
|
||||
UpdateCaptchaSettingsDto, // <-- Import CAPTCHA types
|
||||
CaptchaProvider, // <-- Import CAPTCHA types
|
||||
CaptchaSettings,
|
||||
UpdateCaptchaSettingsDto,
|
||||
CaptchaProvider,
|
||||
} from '../types/settings.types';
|
||||
|
||||
// +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++
|
||||
@@ -128,8 +128,6 @@ export const settingsService = {
|
||||
Object.values(config.shortcuts).every((sc: any) => typeof sc === 'object' && sc !== null && (sc.shortcut === undefined || typeof sc.shortcut === 'string'))
|
||||
) {
|
||||
console.log('[Service] Fetched and validated full focus switcher config:', JSON.stringify(config));
|
||||
// TODO: 可能需要进一步验证 sequence 中的 id 是否仍然有效 (存在于某个地方定义的可用 ID 列表)
|
||||
// TODO: 可能需要进一步验证 shortcuts 中的 key 是否是有效的 ID
|
||||
return config as FocusSwitcherFullConfig;
|
||||
} else {
|
||||
console.warn('[Service] Invalid full focus switcher config format found in settings. Returning default.');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SocksClient, SocksClientOptions } from 'socks';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import * as ConnectionRepository from '../repositories/connection.repository';
|
||||
import * as ProxyRepository from '../repositories/proxy.repository'; // 引入 ProxyRepository
|
||||
import * as ProxyRepository from '../repositories/proxy.repository';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
|
||||
const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒)
|
||||
@@ -123,7 +123,6 @@ export const establishSshConnection = (
|
||||
console.log(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 成功。`);
|
||||
sshClient.removeListener('error', errorHandler); // 成功后移除错误监听器
|
||||
|
||||
// --- 新增:更新 last_connected_at ---
|
||||
try {
|
||||
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
||||
await ConnectionRepository.updateLastConnected(connDetails.id, currentTimeSeconds);
|
||||
@@ -132,7 +131,6 @@ export const establishSshConnection = (
|
||||
// 更新失败不应阻止连接成功,但需要记录错误
|
||||
console.error(`SshService: 更新连接 ${connDetails.id} 的 last_connected_at 失败:`, updateError);
|
||||
}
|
||||
// --- 结束新增 ---
|
||||
|
||||
resolve(sshClient); // 返回 Client 实例
|
||||
};
|
||||
@@ -354,11 +352,3 @@ export const testUnsavedConnection = async (connectionConfig: {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 移除旧的函数 ---
|
||||
// - connectAndOpenShell
|
||||
// - sendInput
|
||||
// - resizeTerminal
|
||||
// - cleanupConnection
|
||||
// - activeSessions Map
|
||||
// - AuthenticatedWebSocket interface (如果仅在此文件使用)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Client } from 'ssh2';
|
||||
import { WebSocket } from 'ws';
|
||||
import { ClientState } from '../websocket'; // 导入统一的 ClientState
|
||||
import { settingsService } from './settings.service'; // +++ 导入 settingsService +++
|
||||
import { ClientState } from '../websocket';
|
||||
import { settingsService } from './settings.service';
|
||||
|
||||
|
||||
// 定义服务器状态的数据结构 (与前端 StatusMonitor.vue 匹配)
|
||||
interface ServerStatus {
|
||||
cpuPercent?: number;
|
||||
memPercent?: number;
|
||||
@@ -24,7 +24,7 @@ interface ServerStatus {
|
||||
timestamp: number; // 状态获取时间戳
|
||||
}
|
||||
|
||||
// Interface for parsed network stats
|
||||
|
||||
interface NetworkStats {
|
||||
[interfaceName: string]: {
|
||||
rx_bytes: number;
|
||||
@@ -32,7 +32,7 @@ interface NetworkStats {
|
||||
}
|
||||
}
|
||||
|
||||
// const DEFAULT_POLLING_INTERVAL = 3000; // --- 移除常量,将从 settingsService 获取 ---
|
||||
|
||||
// 用于存储上一次的网络统计信息以计算速率
|
||||
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
|
||||
|
||||
@@ -46,16 +46,14 @@ export class StatusMonitorService {
|
||||
/**
|
||||
* 启动指定会话的状态轮询
|
||||
* @param sessionId 会话 ID
|
||||
* @param interval 轮询间隔 (毫秒),可选,默认为 DEFAULT_POLLING_INTERVAL // --- 参数移除 ---
|
||||
* @param interval 轮询间隔 (毫秒),可选,默认为 DEFAULT_POLLING_INTERVAL
|
||||
*/
|
||||
async startStatusPolling(sessionId: string): Promise<void> { // --- 改为 async, 移除 interval 参数 ---
|
||||
async startStatusPolling(sessionId: string): Promise<void> {
|
||||
const state = this.clientStates.get(sessionId);
|
||||
if (!state || !state.sshClient) {
|
||||
//console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`);
|
||||
return;
|
||||
}
|
||||
if (state.statusIntervalId) {
|
||||
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,7 +68,6 @@ export class StatusMonitorService {
|
||||
intervalMs = 3000; // 出错时回退到 3 秒
|
||||
}
|
||||
|
||||
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${intervalMs}ms`);
|
||||
// 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间
|
||||
state.statusIntervalId = setInterval(() => {
|
||||
this.fetchAndSendServerStatus(sessionId);
|
||||
@@ -130,35 +127,31 @@ export class StatusMonitorService {
|
||||
const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release');
|
||||
const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m);
|
||||
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
|
||||
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} catch (err) { }
|
||||
|
||||
// --- CPU Model (Try /proc/cpuinfo first, fallback to lscpu) ---
|
||||
try {
|
||||
let cpuModelOutput = '';
|
||||
try {
|
||||
// Try /proc/cpuinfo first, common on many systems including Alpine
|
||||
cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1");
|
||||
status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim();
|
||||
} catch (procErr) {
|
||||
// console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model from /proc/cpuinfo, trying lscpu...`, procErr); // --- 移除 console.warn ---
|
||||
// Fallback to lscpu if /proc/cpuinfo fails
|
||||
|
||||
try {
|
||||
cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
|
||||
status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim();
|
||||
} catch (lscpuErr) {
|
||||
// console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model from lscpu as well:`, lscpuErr); // --- 移除 console.warn ---
|
||||
|
||||
}
|
||||
}
|
||||
// If still no model found after both attempts
|
||||
if (!status.cpuModel) {
|
||||
status.cpuModel = 'Unknown';
|
||||
}
|
||||
} catch (err) { // Catch any unexpected error during the process
|
||||
// console.warn(`[StatusMonitor ${sessionId}] Error getting CPU model:`, err); // --- 移除 console.warn ---
|
||||
} catch (err) {
|
||||
|
||||
status.cpuModel = 'Unknown';
|
||||
}
|
||||
|
||||
// --- Memory and Swap ---
|
||||
|
||||
try {
|
||||
const freeOutput = await this.executeSshCommand(sshClient, 'free -m');
|
||||
const lines = freeOutput.split('\n');
|
||||
@@ -186,17 +179,15 @@ export class StatusMonitorService {
|
||||
}
|
||||
}
|
||||
} else { status.swapTotal = 0; status.swapUsed = 0; status.swapPercent = 0; }
|
||||
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} catch (err) { /* 静默处理 */ }
|
||||
|
||||
// --- Disk Usage (Root Partition, POSIX format for compatibility) ---
|
||||
|
||||
try {
|
||||
// 使用 df -kP / 获取 POSIX 标准格式输出,更稳定
|
||||
const dfOutput = await this.executeSshCommand(sshClient, "df -kP /");
|
||||
const lines = dfOutput.split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const parts = lines[1].split(/\s+/); // 解析第二行 (数据行)
|
||||
// POSIX 格式: Filesystem 1024-blocks Used Available Capacity Mounted on
|
||||
// parts[1]=Total(KB), parts[2]=Used(KB), parts[4]=Capacity(%)
|
||||
const parts = lines[1].split(/\s+/);
|
||||
if (parts.length >= 5) {
|
||||
const total = parseInt(parts[1], 10);
|
||||
const used = parseInt(parts[2], 10);
|
||||
@@ -207,9 +198,8 @@ export class StatusMonitorService {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} catch (err) { /* 静默处理 */ }
|
||||
|
||||
// --- CPU Usage (Simplified from top) ---
|
||||
try {
|
||||
const topOutput = await this.executeSshCommand(sshClient, "top -bn1 | grep '%Cpu(s)' | head -n 1");
|
||||
const idleMatch = topOutput.match(/(\d+\.?\d*)\s+id/); // Adjusted regex for float
|
||||
@@ -217,16 +207,15 @@ export class StatusMonitorService {
|
||||
const idlePercent = parseFloat(idleMatch[1]);
|
||||
status.cpuPercent = parseFloat((100 - idlePercent).toFixed(1));
|
||||
}
|
||||
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} catch (err) { /* 静默处理 */ } //
|
||||
|
||||
// --- Load Average ---
|
||||
try {
|
||||
const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime');
|
||||
const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/);
|
||||
if (match) status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])];
|
||||
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} catch (err) { /* 静默处理 */ }
|
||||
|
||||
|
||||
// --- Network Rates ---
|
||||
try {
|
||||
const currentStats = await this.parseProcNetDev(sshClient);
|
||||
if (currentStats) {
|
||||
@@ -238,18 +227,18 @@ export class StatusMonitorService {
|
||||
const currentTx = currentStats[defaultInterface].tx_bytes;
|
||||
const prevStats = previousNetStats.get(sessionId);
|
||||
|
||||
if (prevStats && prevStats.timestamp < timestamp) { // Ensure time has passed
|
||||
if (prevStats && prevStats.timestamp < timestamp) {
|
||||
const timeDiffSeconds = (timestamp - prevStats.timestamp) / 1000;
|
||||
if (timeDiffSeconds > 0.1) { // Avoid division by zero or tiny intervals
|
||||
if (timeDiffSeconds > 0.1) {
|
||||
status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds));
|
||||
status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds));
|
||||
} else { status.netRxRate = 0; status.netTxRate = 0; } // Rate is 0 if interval too small
|
||||
} else { status.netRxRate = 0; status.netTxRate = 0; } // First run or no time diff
|
||||
} else { status.netRxRate = 0; status.netTxRate = 0; }
|
||||
} else { status.netRxRate = 0; status.netTxRate = 0; }
|
||||
|
||||
previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp });
|
||||
} else { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} else { /* 静默处理 */ }
|
||||
}
|
||||
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn ---
|
||||
} catch (err) { /* 静默处理 */ }
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error);
|
||||
@@ -270,7 +259,7 @@ export class StatusMonitorService {
|
||||
output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
|
||||
} catch (error) {
|
||||
// 如果命令失败,记录警告并返回 null
|
||||
// console.warn("[StatusMonitor] Failed to execute 'cat /proc/net/dev':", error); // --- 移除 console.warn ---
|
||||
|
||||
return null;
|
||||
}
|
||||
// 如果命令成功,继续解析
|
||||
@@ -279,18 +268,16 @@ export class StatusMonitorService {
|
||||
const stats: NetworkStats = {};
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/:\s+|\s+/);
|
||||
if (parts.length < 17) continue; // Need at least interface name + 16 stats
|
||||
if (parts.length < 17) continue;
|
||||
const interfaceName = parts[0];
|
||||
const rx_bytes = parseInt(parts[1], 10);
|
||||
const tx_bytes = parseInt(parts[9], 10); // TX bytes is the 10th field (index 9)
|
||||
const tx_bytes = parseInt(parts[9], 10);
|
||||
if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) {
|
||||
stats[interfaceName] = { rx_bytes, tx_bytes };
|
||||
}
|
||||
}
|
||||
return Object.keys(stats).length > 0 ? stats : null;
|
||||
} catch (parseError) {
|
||||
// 如果解析失败,记录错误并返回 null
|
||||
// console.error("[StatusMonitor] Error parsing /proc/net/dev output:", parseError); // --- 移除 console.error ---
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -307,11 +294,10 @@ export class StatusMonitorService {
|
||||
const interfaceName = output.trim();
|
||||
if (interfaceName) return interfaceName;
|
||||
// 如果 ip route 没返回有效接口名,也尝试 fallback
|
||||
// console.warn("[StatusMonitor] 'ip route' did not return a valid interface name. Falling back..."); // --- 移除 console.warn ---
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// console.warn("[StatusMonitor] Failed to get default interface using 'ip route', falling back:", error); // --- 移除 console.warn ---
|
||||
// Fallback: 尝试查找第一个非 lo 接口
|
||||
|
||||
try {
|
||||
const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
|
||||
const lines = netDevOutput.split('\n').slice(2);
|
||||
@@ -322,13 +308,12 @@ export class StatusMonitorService {
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// console.error("[StatusMonitor] Failed to fallback to /proc/net/dev for interface:", fallbackError); // --- 移除 console.error ---
|
||||
|
||||
}
|
||||
// Ensure null is returned if both primary and fallback fail within the outer catch
|
||||
|
||||
return null;
|
||||
}
|
||||
// This part should ideally not be reached if the first try succeeded or the catch block returned.
|
||||
// Adding a final return null for safety and to satisfy TS if logic paths are complex.
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -347,16 +332,10 @@ export class StatusMonitorService {
|
||||
return reject(new Error(`执行命令 '${command}' 失败: ${err.message}`));
|
||||
}
|
||||
stream.on('close', (code: number, signal?: string) => {
|
||||
// Don't reject on non-zero exit code, as some commands might return non-zero normally
|
||||
// if (code !== 0) {
|
||||
// //console.warn(`[StatusMonitor] Command '${command}' exited with code ${code}`);
|
||||
// }
|
||||
resolve(output.trim());
|
||||
}).on('data', (data: Buffer) => {
|
||||
output += data.toString('utf8');
|
||||
}).stderr.on('data', (data: Buffer) => {
|
||||
// --- 移除 console.warn ---
|
||||
// console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,16 +21,15 @@ export const getTagById = async (id: number): Promise<TagData | null> => {
|
||||
* 创建新标签
|
||||
*/
|
||||
export const createTag = async (name: string): Promise<TagData> => {
|
||||
// 1. Validate input
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error('标签名称不能为空。');
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// 2. Create tag record
|
||||
|
||||
try {
|
||||
const newTagId = await TagRepository.createTag(trimmedName);
|
||||
// 3. Fetch and return the newly created tag
|
||||
const newTag = await getTagById(newTagId);
|
||||
if (!newTag) {
|
||||
throw new Error('创建标签后无法检索到该标签。');
|
||||
@@ -40,7 +39,7 @@ export const createTag = async (name: string): Promise<TagData> => {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`创建标签失败:标签名称 "${trimmedName}" 已存在。`);
|
||||
}
|
||||
throw error; // Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,25 +47,25 @@ export const createTag = async (name: string): Promise<TagData> => {
|
||||
* 更新标签名称
|
||||
*/
|
||||
export const updateTag = async (id: number, name: string): Promise<TagData | null> => {
|
||||
// 1. Validate input
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error('标签名称不能为空。');
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// 2. Update tag record
|
||||
|
||||
try {
|
||||
const updated = await TagRepository.updateTag(id, trimmedName);
|
||||
if (!updated) {
|
||||
return null; // Tag not found or not updated
|
||||
return null;
|
||||
}
|
||||
// 3. Fetch and return the updated tag
|
||||
|
||||
return getTagById(id);
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`更新标签失败:标签名称 "${trimmedName}" 已存在。`);
|
||||
}
|
||||
throw error; // Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,6 +73,5 @@ export const updateTag = async (id: number, name: string): Promise<TagData | nul
|
||||
* 删除标签
|
||||
*/
|
||||
export const deleteTag = async (id: number): Promise<boolean> => {
|
||||
// Repository handles cascading deletes in connection_tags
|
||||
return TagRepository.deleteTag(id);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
|
||||
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
// import { validate } from 'class-validator'; // 移除导入
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
/**
|
||||
@@ -85,4 +84,3 @@ export const importTheme = async (themeData: ITheme, name: string): Promise<Term
|
||||
return createNewTheme(dto);
|
||||
};
|
||||
|
||||
// 注意:导出功能通常在 Controller 层处理,根据 ID 获取主题数据后,设置响应头并发送 JSON 文件。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { settingsService } from '../services/settings.service';
|
||||
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
import { ipBlacklistService } from '../services/ip-blacklist.service';
|
||||
import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types
|
||||
|
||||
@@ -41,7 +41,6 @@ export const settingsController = {
|
||||
'fileManagerRowSizeMultiplier', // +++ 添加文件管理器行大小键 +++
|
||||
'fileManagerColWidths', // +++ 添加文件管理器列宽键 +++
|
||||
'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++
|
||||
// --- REMOVED old width keys ---
|
||||
];
|
||||
const filteredSettings: Record<string, string> = {};
|
||||
for (const key in settingsToUpdate) {
|
||||
@@ -74,12 +73,12 @@ export const settingsController = {
|
||||
*/
|
||||
async getFocusSwitcherSequence(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
console.log('[Controller] Received request to get focus switcher sequence.');
|
||||
console.log('[控制器] 收到获取焦点切换顺序的请求。');
|
||||
const sequence = await settingsService.getFocusSwitcherSequence();
|
||||
console.log('[Controller] Sending focus switcher sequence to client:', JSON.stringify(sequence));
|
||||
console.log('[控制器] 向客户端发送焦点切换顺序:', JSON.stringify(sequence));
|
||||
res.json(sequence);
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 获取焦点切换顺序时出错:', error);
|
||||
console.error('[控制器] 获取焦点切换顺序时出错:', error);
|
||||
res.status(500).json({ message: '获取焦点切换顺序失败', error: error.message });
|
||||
}
|
||||
},
|
||||
@@ -88,11 +87,11 @@ export const settingsController = {
|
||||
* 设置焦点切换顺序
|
||||
*/
|
||||
async setFocusSwitcherSequence(req: Request, res: Response): Promise<void> {
|
||||
console.log('[Controller] Received request to set focus switcher sequence.');
|
||||
console.log('[控制器] 收到设置焦点切换顺序的请求。');
|
||||
try {
|
||||
// +++ 修改:获取请求体并验证其是否符合 FocusSwitcherFullConfig 结构 +++
|
||||
const fullConfig = req.body;
|
||||
console.log('[Controller] Request body fullConfig:', JSON.stringify(fullConfig));
|
||||
console.log('[控制器] 请求体 fullConfig:', JSON.stringify(fullConfig));
|
||||
|
||||
// +++ 验证 FocusSwitcherFullConfig 结构 +++
|
||||
if (
|
||||
@@ -101,23 +100,22 @@ export const settingsController = {
|
||||
typeof fullConfig.shortcuts === 'object' && fullConfig.shortcuts !== null &&
|
||||
Object.values(fullConfig.shortcuts).every((sc: any) => typeof sc === 'object' && sc !== null && (sc.shortcut === undefined || typeof sc.shortcut === 'string')))
|
||||
) {
|
||||
console.warn('[Controller] Invalid full focus config format received:', fullConfig);
|
||||
console.warn('[控制器] 收到无效的完整焦点配置格式:', fullConfig);
|
||||
res.status(400).json({ message: '无效的请求体,必须是包含 sequence (string[]) 和 shortcuts (Record<string, {shortcut?: string}>) 的对象' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Controller] Calling settingsService.setFocusSwitcherSequence with validated full config...');
|
||||
console.log('[控制器] 使用验证后的完整配置调用 settingsService.setFocusSwitcherSequence...');
|
||||
// +++ 传递验证后的 fullConfig 给服务层 +++
|
||||
await settingsService.setFocusSwitcherSequence(fullConfig);
|
||||
console.log('[Controller] settingsService.setFocusSwitcherSequence completed successfully.');
|
||||
console.log('[控制器] settingsService.setFocusSwitcherSequence 成功完成。');
|
||||
|
||||
console.log('[Controller] Logging audit action: FOCUS_SWITCHER_SEQUENCE_UPDATED'); // Keep console log for now if needed
|
||||
// auditLogService.logAction('FOCUS_SWITCHER_SEQUENCE_UPDATED', { config: fullConfig }); // Removed specific log
|
||||
|
||||
console.log('[Controller] Sending success response.');
|
||||
console.log('[控制器] 记录审计操作: FOCUS_SWITCHER_SEQUENCE_UPDATED');
|
||||
|
||||
console.log('[控制器] 发送成功响应。');
|
||||
res.status(200).json({ message: '焦点切换顺序已成功更新' });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 设置焦点切换顺序时出错:', error);
|
||||
console.error('[控制器] 设置焦点切换顺序时出错:', error);
|
||||
if (error.message === 'Invalid sequence format provided.') {
|
||||
res.status(400).json({ message: '设置焦点切换顺序失败: 无效的格式', error: error.message });
|
||||
} else {
|
||||
@@ -131,12 +129,12 @@ export const settingsController = {
|
||||
*/
|
||||
async getNavBarVisibility(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
console.log('[Controller] Received request to get nav bar visibility.');
|
||||
console.log('[控制器] 收到获取导航栏可见性的请求。');
|
||||
const isVisible = await settingsService.getNavBarVisibility();
|
||||
console.log(`[Controller] Sending nav bar visibility to client: ${isVisible}`);
|
||||
console.log(`[控制器] 向客户端发送导航栏可见性: ${isVisible}`);
|
||||
res.json({ visible: isVisible });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 获取导航栏可见性时出错:', error);
|
||||
console.error('[控制器] 获取导航栏可见性时出错:', error);
|
||||
res.status(500).json({ message: '获取导航栏可见性失败', error: error.message });
|
||||
}
|
||||
},
|
||||
@@ -145,27 +143,26 @@ export const settingsController = {
|
||||
* 设置导航栏可见性
|
||||
*/
|
||||
async setNavBarVisibility(req: Request, res: Response): Promise<void> {
|
||||
console.log('[Controller] Received request to set nav bar visibility.');
|
||||
console.log('[控制器] 收到设置导航栏可见性的请求。');
|
||||
try {
|
||||
const { visible } = req.body;
|
||||
console.log('[Controller] Request body visible:', visible);
|
||||
console.log('[控制器] 请求体 visible:', visible);
|
||||
|
||||
if (typeof visible !== 'boolean') {
|
||||
console.warn('[Controller] Invalid visible format received:', visible);
|
||||
console.warn('[控制器] 收到无效的 visible 格式:', visible);
|
||||
res.status(400).json({ message: '无效的请求体,"visible" 必须是一个布尔值' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Controller] Calling settingsService.setNavBarVisibility...');
|
||||
console.log('[控制器] 调用 settingsService.setNavBarVisibility...');
|
||||
await settingsService.setNavBarVisibility(visible);
|
||||
console.log('[Controller] settingsService.setNavBarVisibility completed successfully.');
|
||||
console.log('[控制器] settingsService.setNavBarVisibility 成功完成。');
|
||||
|
||||
// auditLogService.logAction('NAV_BAR_VISIBILITY_UPDATED', { visible });
|
||||
|
||||
console.log('[Controller] Sending success response.');
|
||||
console.log('[控制器] 发送成功响应。');
|
||||
res.status(200).json({ message: '导航栏可见性已成功更新' });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 设置导航栏可见性时出错:', error);
|
||||
console.error('[控制器] 设置导航栏可见性时出错:', error);
|
||||
res.status(500).json({ message: '设置导航栏可见性失败', error: error.message });
|
||||
}
|
||||
},
|
||||
@@ -175,23 +172,23 @@ export const settingsController = {
|
||||
*/
|
||||
async getLayoutTree(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
console.log('[Controller] Received request to get layout tree.');
|
||||
console.log('[控制器] 收到获取布局树的请求。');
|
||||
const layoutJson = await settingsService.getLayoutTree();
|
||||
if (layoutJson) {
|
||||
try {
|
||||
const layout = JSON.parse(layoutJson);
|
||||
console.log('[Controller] Sending layout tree to client.');
|
||||
console.log('[控制器] 向客户端发送布局树。');
|
||||
res.json(layout);
|
||||
} catch (parseError) {
|
||||
console.error('[Controller] Failed to parse layout tree JSON from DB:', parseError);
|
||||
console.error('[控制器] 从数据库解析布局树 JSON 失败:', parseError);
|
||||
res.status(500).json({ message: '获取布局树失败:存储的数据格式无效' });
|
||||
}
|
||||
} else {
|
||||
console.log('[Controller] No layout tree found in settings, sending null.');
|
||||
console.log('[控制器] 在设置中未找到布局树,发送 null。');
|
||||
res.json(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 获取布局树时出错:', error);
|
||||
console.error('[控制器] 获取布局树时出错:', error);
|
||||
res.status(500).json({ message: '获取布局树失败', error: error.message });
|
||||
}
|
||||
},
|
||||
@@ -200,28 +197,28 @@ export const settingsController = {
|
||||
* 设置布局树
|
||||
*/
|
||||
async setLayoutTree(req: Request, res: Response): Promise<void> {
|
||||
console.log('[Controller] Received request to set layout tree.');
|
||||
console.log('[控制器] 收到设置布局树的请求。');
|
||||
try {
|
||||
const layoutTree = req.body;
|
||||
|
||||
if (typeof layoutTree !== 'object' || layoutTree === null) {
|
||||
console.warn('[Controller] Invalid layout tree format received (not an object):', layoutTree);
|
||||
console.warn('[控制器] 收到无效的布局树格式 (非对象):', layoutTree);
|
||||
res.status(400).json({ message: '无效的请求体,应为 JSON 对象格式的布局树' });
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutJson = JSON.stringify(layoutTree);
|
||||
|
||||
console.log('[Controller] Calling settingsService.setLayoutTree...');
|
||||
console.log('[控制器] 调用 settingsService.setLayoutTree...');
|
||||
await settingsService.setLayoutTree(layoutJson);
|
||||
console.log('[Controller] settingsService.setLayoutTree completed successfully.');
|
||||
console.log('[控制器] settingsService.setLayoutTree 成功完成。');
|
||||
|
||||
// auditLogService.logAction('LAYOUT_TREE_UPDATED');
|
||||
|
||||
console.log('[Controller] Sending success response.');
|
||||
console.log('[控制器] 发送成功响应。');
|
||||
res.status(200).json({ message: '布局树已成功更新' });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 设置布局树时出错:', error);
|
||||
console.error('[控制器] 设置布局树时出错:', error);
|
||||
if (error.message === 'Invalid layout tree JSON format.') {
|
||||
res.status(400).json({ message: '设置布局树失败: 无效的 JSON 格式', error: error.message });
|
||||
} else {
|
||||
@@ -256,7 +253,6 @@ export const settingsController = {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -269,12 +265,12 @@ export const settingsController = {
|
||||
*/
|
||||
async getAutoCopyOnSelect(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
console.log('[Controller] Received request to get auto copy on select setting.');
|
||||
console.log('[控制器] 收到获取“选中时自动复制”设置的请求。');
|
||||
const isEnabled = await settingsService.getAutoCopyOnSelect();
|
||||
console.log(`[Controller] Sending auto copy on select setting to client: ${isEnabled}`);
|
||||
console.log(`[控制器] 向客户端发送“选中时自动复制”设置: ${isEnabled}`);
|
||||
res.json({ enabled: isEnabled });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 获取终端选中自动复制设置时出错:', error);
|
||||
console.error('[控制器] 获取终端选中自动复制设置时出错:', error);
|
||||
res.status(500).json({ message: '获取终端选中自动复制设置失败', error: error.message });
|
||||
}
|
||||
}, // *** 确保这里有逗号 ***
|
||||
@@ -283,44 +279,42 @@ export const settingsController = {
|
||||
* 设置终端选中自动复制
|
||||
*/
|
||||
async setAutoCopyOnSelect(req: Request, res: Response): Promise<void> {
|
||||
console.log('[Controller] Received request to set auto copy on select setting.');
|
||||
console.log('[控制器] 收到设置“选中时自动复制”设置的请求。');
|
||||
try {
|
||||
const { enabled } = req.body;
|
||||
console.log('[Controller] Request body enabled:', enabled);
|
||||
console.log('[控制器] 请求体 enabled:', enabled);
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
console.warn('[Controller] Invalid enabled format received:', enabled);
|
||||
console.warn('[控制器] 收到无效的 enabled 格式:', enabled);
|
||||
res.status(400).json({ message: '无效的请求体,"enabled" 必须是一个布尔值' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Controller] Calling settingsService.setAutoCopyOnSelect...');
|
||||
console.log('[控制器] 调用 settingsService.setAutoCopyOnSelect...');
|
||||
await settingsService.setAutoCopyOnSelect(enabled);
|
||||
console.log('[Controller] settingsService.setAutoCopyOnSelect completed successfully.');
|
||||
console.log('[控制器] settingsService.setAutoCopyOnSelect 成功完成。');
|
||||
|
||||
// auditLogService.logAction('AUTO_COPY_ON_SELECT_UPDATED', { enabled }); // 可选:添加审计日志
|
||||
|
||||
console.log('[Controller] Sending success response.');
|
||||
console.log('[控制器] 发送成功响应。');
|
||||
res.status(200).json({ message: '终端选中自动复制设置已成功更新' });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 设置终端选中自动复制时出错:', error);
|
||||
console.error('[控制器] 设置终端选中自动复制时出错:', error);
|
||||
res.status(500).json({ message: '设置终端选中自动复制失败', error: error.message });
|
||||
}
|
||||
}, // *** 确保这里有逗号 ***
|
||||
},
|
||||
|
||||
// --- Sidebar Config Controller Methods ---
|
||||
|
||||
/**
|
||||
* 获取侧栏配置
|
||||
*/
|
||||
async getSidebarConfig(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
console.log('[Controller] Received request to get sidebar config.');
|
||||
console.log('[控制器] 收到获取侧边栏配置的请求。');
|
||||
const config = await settingsService.getSidebarConfig();
|
||||
console.log('[Controller] Sending sidebar config to client:', config);
|
||||
console.log('[控制器] 向客户端发送侧边栏配置:', config);
|
||||
res.json(config);
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 获取侧栏配置时出错:', error);
|
||||
console.error('[控制器] 获取侧栏配置时出错:', error);
|
||||
res.status(500).json({ message: '获取侧栏配置失败', error: error.message });
|
||||
}
|
||||
},
|
||||
@@ -329,29 +323,28 @@ export const settingsController = {
|
||||
* 设置侧栏配置
|
||||
*/
|
||||
async setSidebarConfig(req: Request, res: Response): Promise<void> {
|
||||
console.log('[Controller] Received request to set sidebar config.');
|
||||
console.log('[控制器] 收到设置侧边栏配置的请求。');
|
||||
try {
|
||||
const configDto: UpdateSidebarConfigDto = req.body;
|
||||
console.log('[Controller] Request body:', configDto);
|
||||
console.log('[控制器] 请求体:', configDto);
|
||||
|
||||
// --- DTO Validation (Basic) ---
|
||||
// More specific validation happens in the service layer
|
||||
if (!configDto || typeof configDto !== 'object' || !Array.isArray(configDto.left) || !Array.isArray(configDto.right)) {
|
||||
console.warn('[Controller] Invalid sidebar config format received:', configDto);
|
||||
console.warn('[控制器] 收到无效的侧边栏配置格式:', configDto);
|
||||
res.status(400).json({ message: '无效的请求体,应为包含 left 和 right 数组的 JSON 对象' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Controller] Calling settingsService.setSidebarConfig...');
|
||||
console.log('[控制器] 调用 settingsService.setSidebarConfig...');
|
||||
await settingsService.setSidebarConfig(configDto);
|
||||
console.log('[Controller] settingsService.setSidebarConfig completed successfully.');
|
||||
console.log('[控制器] settingsService.setSidebarConfig 成功完成。');
|
||||
|
||||
// auditLogService.logAction('SIDEBAR_CONFIG_UPDATED'); // Optional: Add audit log
|
||||
|
||||
console.log('[Controller] Sending success response.');
|
||||
console.log('[控制器] 发送成功响应。');
|
||||
res.status(200).json({ message: '侧栏配置已成功更新' });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 设置侧栏配置时出错:', error);
|
||||
console.error('[控制器] 设置侧栏配置时出错:', error);
|
||||
// Handle specific validation errors from the service
|
||||
if (error.message.includes('无效的面板名称') || error.message.includes('无效的侧栏配置格式')) {
|
||||
res.status(400).json({ message: `设置侧栏配置失败: ${error.message}` });
|
||||
@@ -359,19 +352,16 @@ export const settingsController = {
|
||||
res.status(500).json({ message: '设置侧栏配置失败', error: error.message });
|
||||
}
|
||||
}
|
||||
}, // <-- Add comma here
|
||||
|
||||
// --- CAPTCHA Settings Controller Methods ---
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取公共 CAPTCHA 配置 (不含密钥)
|
||||
*/
|
||||
async getCaptchaConfig(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
console.log('[Controller] Received request to get CAPTCHA config.');
|
||||
console.log('[控制器] 收到获取 CAPTCHA 配置的请求。');
|
||||
const fullConfig = await settingsService.getCaptchaConfig();
|
||||
|
||||
// *** IMPORTANT: Filter out secret keys before sending to frontend ***
|
||||
const publicConfig = {
|
||||
enabled: fullConfig.enabled,
|
||||
provider: fullConfig.provider,
|
||||
@@ -379,10 +369,10 @@ async getCaptchaConfig(req: Request, res: Response): Promise<void> {
|
||||
recaptchaSiteKey: fullConfig.recaptchaSiteKey,
|
||||
};
|
||||
|
||||
console.log('[Controller] Sending public CAPTCHA config to client:', publicConfig);
|
||||
console.log('[控制器] 向客户端发送公共 CAPTCHA 配置:', publicConfig);
|
||||
res.json(publicConfig);
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 获取 CAPTCHA 配置时出错:', error);
|
||||
console.error('[控制器] 获取 CAPTCHA 配置时出错:', error);
|
||||
res.status(500).json({ message: '获取 CAPTCHA 配置失败', error: error.message });
|
||||
}
|
||||
},
|
||||
@@ -391,37 +381,35 @@ async getCaptchaConfig(req: Request, res: Response): Promise<void> {
|
||||
* 设置 CAPTCHA 配置
|
||||
*/
|
||||
async setCaptchaConfig(req: Request, res: Response): Promise<void> {
|
||||
console.log('[Controller] Received request to set CAPTCHA config.');
|
||||
console.log('[控制器] 收到设置 CAPTCHA 配置的请求。');
|
||||
try {
|
||||
const configDto: UpdateCaptchaSettingsDto = req.body;
|
||||
// Mask secrets immediately if logging the DTO
|
||||
console.log('[Controller] Request body (DTO, secrets masked):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' });
|
||||
console.log('[控制器] 请求体 (DTO, 密钥已屏蔽):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' });
|
||||
|
||||
// --- DTO Validation (Basic) ---
|
||||
if (!configDto || typeof configDto !== 'object') {
|
||||
console.warn('[Controller] Invalid CAPTCHA config format received (not an object):', configDto);
|
||||
console.warn('[控制器] 收到无效的 CAPTCHA 配置格式 (非对象):', configDto);
|
||||
res.status(400).json({ message: '无效的请求体,应为 JSON 对象' });
|
||||
return;
|
||||
}
|
||||
// More specific validation happens in the service layer
|
||||
|
||||
console.log('[Controller] Calling settingsService.setCaptchaConfig...');
|
||||
|
||||
console.log('[控制器] 调用 settingsService.setCaptchaConfig...');
|
||||
await settingsService.setCaptchaConfig(configDto);
|
||||
console.log('[Controller] settingsService.setCaptchaConfig completed successfully.');
|
||||
console.log('[控制器] settingsService.setCaptchaConfig 成功完成。');
|
||||
|
||||
auditLogService.logAction('CAPTCHA_SETTINGS_UPDATED'); // Add audit log
|
||||
auditLogService.logAction('CAPTCHA_SETTINGS_UPDATED');
|
||||
|
||||
console.log('[Controller] Sending success response.');
|
||||
console.log('[控制器] 发送成功响应。');
|
||||
res.status(200).json({ message: 'CAPTCHA 配置已成功更新' });
|
||||
} catch (error: any) {
|
||||
console.error('[Controller] 设置 CAPTCHA 配置时出错:', error);
|
||||
console.error('[控制器] 设置 CAPTCHA 配置时出错:', error);
|
||||
// Handle specific validation errors from the service
|
||||
if (error.message.includes('无效的') || error.message.includes('必须是')) { // Basic check for validation errors
|
||||
if (error.message.includes('无效的') || error.message.includes('必须是')) {
|
||||
res.status(400).json({ message: `设置 CAPTCHA 配置失败: ${error.message}` });
|
||||
} else {
|
||||
res.status(500).json({ message: '设置 CAPTCHA 配置失败', error: error.message });
|
||||
}
|
||||
}
|
||||
} // <-- No comma after the last method
|
||||
}
|
||||
|
||||
}; // <-- End of settingsController object
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import express from 'express';
|
||||
import { settingsController } from './settings.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 导入认证中间件
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// +++ 新增:CAPTCHA 配置路由 (公开获取) +++
|
||||
|
||||
// GET /api/v1/settings/captcha - 获取公共 CAPTCHA 配置 (不含密钥)
|
||||
router.get('/captcha', settingsController.getCaptchaConfig);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path'; // 需要 path 用于处理文件名
|
||||
import { clientStates } from '../websocket'; // Import the exported clientStates map
|
||||
import path from 'path';
|
||||
import { clientStates } from '../websocket';
|
||||
|
||||
/**
|
||||
* 处理文件下载请求 (GET /api/v1/sftp/download)
|
||||
@@ -25,13 +25,11 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
|
||||
// 查找与当前用户会话关联的活动 WebSocket 连接和 SFTP 会话
|
||||
let userSftpSession = null;
|
||||
// 注意:这种查找方式效率不高,实际应用中可能需要更优化的结构来按 userId 查找连接
|
||||
// TODO: Refactor this to use SftpService instead of directly accessing clientStates
|
||||
// This direct access is not ideal and couples the controller to websocket internals.
|
||||
for (const [sessionId, state] of clientStates.entries()) {
|
||||
const ws = state.ws; // Get the WebSocket instance from the state
|
||||
const connData = state; // Use the entire state object
|
||||
const ws = state.ws;
|
||||
const connData = state;
|
||||
// 假设 AuthenticatedWebSocket 上存储了 userId
|
||||
if (ws.userId === userId && connData.sftp) { // Access userId directly from AuthenticatedWebSocket
|
||||
if (ws.userId === userId && connData.sftp) {
|
||||
// 这里简单地取第一个找到的匹配连接,没有处理 connectionId 的匹配
|
||||
// TODO: 需要一种方式将 HTTP 请求与特定的 WebSocket/SSH/SFTP 会话关联起来
|
||||
// 临时方案:假设一个用户只有一个活动的 SSH/SFTP 会话
|
||||
@@ -87,7 +85,7 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
|
||||
// 监听响应对象的 close 事件,确保流被正确关闭 (虽然 pipe 通常会处理)
|
||||
res.on('close', () => {
|
||||
console.log(`SFTP 下载流关闭 (用户 ${userId}, 路径 ${remotePath})`);
|
||||
// readStream.destroy(); // 可选:显式销毁流
|
||||
|
||||
});
|
||||
|
||||
console.log(`SFTP 开始下载 (用户 ${userId}, 路径 ${remotePath})`);
|
||||
@@ -104,5 +102,4 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
};
|
||||
|
||||
// 其他 SFTP 控制器函数 (例如上传)
|
||||
// export const uploadFile = ...
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import { downloadFile } from './sftp.controller'; // 稍后创建
|
||||
import { downloadFile } from './sftp.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,6 +10,5 @@ router.use(isAuthenticated);
|
||||
// GET /api/v1/sftp/download?connectionId=...&remotePath=...
|
||||
router.get('/download', downloadFile);
|
||||
|
||||
// 未来可以添加其他 SFTP 相关 REST API (如果需要,例如上传的大文件断点续传初始化)
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as TagService from '../services/tag.service';
|
||||
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
|
||||
import { AuditLogService } from '../services/audit.service';
|
||||
|
||||
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
/**
|
||||
* 创建新标签 (POST /api/v1/tags)
|
||||
@@ -23,7 +23,7 @@ export const createTag = async (req: Request, res: Response): Promise<void> => {
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 创建标签时发生错误:', error);
|
||||
if (error.message.includes('已存在')) {
|
||||
res.status(409).json({ message: error.message }); // Conflict
|
||||
res.status(409).json({ message: error.message });
|
||||
} else {
|
||||
res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' });
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export const updateTag = async (req: Request, res: Response): Promise<void> => {
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 更新标签 ${tagId} 时发生错误:`, error);
|
||||
if (error.message.includes('已存在')) {
|
||||
res.status(409).json({ message: error.message }); // Conflict
|
||||
res.status(409).json({ message: error.message });
|
||||
} else if (error.message.includes('不能为空')) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import {
|
||||
createTag,
|
||||
getTags,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
|
||||
import * as terminalThemeService from '../services/terminal-theme.service';
|
||||
import { CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
import type { ITheme } from 'xterm';
|
||||
import multer from 'multer'; // 用于处理文件上传
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import * as themeController from './terminal-theme.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 修正导入名称
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ export type AuditLogActionType =
|
||||
| '2FA_ENABLED'
|
||||
| '2FA_DISABLED'
|
||||
| 'PASSKEY_REGISTERED'
|
||||
| 'PASSKEY_DELETED' // Assuming deletion is possible later
|
||||
| 'PASSKEY_DELETED'
|
||||
|
||||
// Connections
|
||||
| 'CONNECTION_CREATED'
|
||||
| 'CONNECTION_UPDATED'
|
||||
| 'CONNECTION_DELETED'
|
||||
| 'CONNECTION_TESTED' // Maybe log test attempts?
|
||||
| 'CONNECTION_TESTED'
|
||||
| 'CONNECTIONS_IMPORTED'
|
||||
| 'CONNECTIONS_EXPORTED'
|
||||
|
||||
@@ -31,8 +31,8 @@ export type AuditLogActionType =
|
||||
// Settings
|
||||
| 'SETTINGS_UPDATED' // General settings update
|
||||
| 'IP_WHITELIST_UPDATED' // Specific setting update
|
||||
| 'CAPTCHA_SETTINGS_UPDATED' // Specific setting update for CAPTCHA
|
||||
// | 'FOCUS_SWITCHER_SEQUENCE_UPDATED' // Removed
|
||||
| 'CAPTCHA_SETTINGS_UPDATED'
|
||||
|
||||
|
||||
// Notifications
|
||||
| 'NOTIFICATION_SETTING_CREATED'
|
||||
@@ -53,9 +53,9 @@ export type AuditLogActionType =
|
||||
|
||||
// System/Error
|
||||
| 'SERVER_STARTED'
|
||||
| 'SERVER_ERROR' // Log significant backend errors
|
||||
| 'SERVER_ERROR'
|
||||
| 'DATABASE_MIGRATION'
|
||||
| 'ADMIN_SETUP_COMPLETE'; // *** 新增:初始管理员设置完成 ***
|
||||
| 'ADMIN_SETUP_COMPLETE';
|
||||
|
||||
// 审计日志条目的结构 (从数据库读取时)
|
||||
export interface AuditLogEntry {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Centralized types for Connection feature
|
||||
|
||||
|
||||
export interface ConnectionBase {
|
||||
id: number;
|
||||
name: string | null; // Allow name to be null
|
||||
name: string | null;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -17,22 +17,21 @@ export interface ConnectionWithTags extends ConnectionBase {
|
||||
tag_ids: number[];
|
||||
}
|
||||
|
||||
// Input type for creating a connection (from controller)
|
||||
|
||||
export interface CreateConnectionInput {
|
||||
name?: string; // Name is now optional
|
||||
name?: string;
|
||||
host: string;
|
||||
port?: number; // Optional, defaults in service/repo
|
||||
port?: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
password?: string; // Optional depending on auth_method
|
||||
private_key?: string; // Optional depending on auth_method
|
||||
passphrase?: string; // Optional for key auth
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
proxy_id?: number | null;
|
||||
tag_ids?: number[];
|
||||
}
|
||||
|
||||
// Input type for updating a connection (from controller)
|
||||
// All fields are optional except potentially auth_method related ones
|
||||
|
||||
export interface UpdateConnectionInput {
|
||||
name?: string;
|
||||
host?: string;
|
||||
@@ -41,13 +40,12 @@ export interface UpdateConnectionInput {
|
||||
auth_method?: 'password' | 'key';
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string; // Use undefined to signal no change, null/empty string to clear
|
||||
passphrase?: string;
|
||||
proxy_id?: number | null;
|
||||
tag_ids?: number[];
|
||||
}
|
||||
|
||||
// Type used within the repository (includes encrypted fields)
|
||||
// This might stay in the repository or be defined here if needed elsewhere
|
||||
|
||||
export interface FullConnectionData {
|
||||
id: number;
|
||||
name: string | null;
|
||||
|
||||
@@ -8,15 +8,11 @@ export type NotificationEvent =
|
||||
| 'CONNECTIONS_IMPORTED' | 'CONNECTIONS_EXPORTED'
|
||||
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
|
||||
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
|
||||
| 'SETTINGS_UPDATED' | 'IP_WHITELIST_UPDATED' | 'IP_BLOCKED' // Added IP_BLOCKED event
|
||||
| 'SETTINGS_UPDATED' | 'IP_WHITELIST_UPDATED' | 'IP_BLOCKED'
|
||||
| 'NOTIFICATION_SETTING_CREATED' | 'NOTIFICATION_SETTING_UPDATED' | 'NOTIFICATION_SETTING_DELETED'
|
||||
| 'SFTP_ACTION'
|
||||
// SSH Actions
|
||||
| 'SSH_CONNECT_SUCCESS' | 'SSH_CONNECT_FAILURE' | 'SSH_SHELL_FAILURE'
|
||||
// System/Error
|
||||
| 'SERVER_STARTED' | 'SERVER_ERROR' | 'DATABASE_MIGRATION' | 'ADMIN_SETUP_COMPLETE';
|
||||
// Settings (Specific) - Keep aligned with AuditLogActionType
|
||||
// Note: IP_BLACKLISTED was in NotificationEvent but not AuditLogActionType, removed for consistency based on user request
|
||||
|
||||
export interface WebhookConfig {
|
||||
url: string;
|
||||
|
||||
+110
-116
@@ -2,16 +2,14 @@ import WebSocket, { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import { Request, RequestHandler } from 'express';
|
||||
import { Client, ClientChannel } from 'ssh2';
|
||||
import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一的会话 ID
|
||||
import { getDbInstance } from './database/connection'; // Updated import path, use getDbInstance
|
||||
import { decrypt } from './utils/crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDbInstance } from './database/connection';
|
||||
import { SftpService } from './services/sftp.service';
|
||||
import { StatusMonitorService } from './services/status-monitor.service';
|
||||
import * as SshService from './services/ssh.service';
|
||||
import { DockerService } from './services/docker.service'; // 导入 DockerService
|
||||
import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService
|
||||
import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType
|
||||
import { settingsService } from './services/settings.service'; // +++ 修正导入路径 +++
|
||||
import { DockerService } from './services/docker.service';
|
||||
import { AuditLogService } from './services/audit.service';
|
||||
import { settingsService } from './services/settings.service';
|
||||
|
||||
// 扩展 WebSocket 类型以包含会话 ID
|
||||
interface AuthenticatedWebSocket extends WebSocket {
|
||||
@@ -35,15 +33,14 @@ export interface ClientState { // 导出以便 Service 可以导入
|
||||
ipAddress?: string; // 添加 IP 地址字段
|
||||
}
|
||||
|
||||
// --- Interfaces (保持与前端一致) ---
|
||||
// --- FIX: Move PortInfo definition before its usage ---
|
||||
|
||||
interface PortInfo {
|
||||
IP?: string;
|
||||
PrivatePort: number;
|
||||
PublicPort?: number;
|
||||
Type: 'tcp' | 'udp' | string;
|
||||
}
|
||||
// --- End FIX ---
|
||||
|
||||
|
||||
// --- Docker Interfaces (Ensure this matches frontend and DockerService) ---
|
||||
// Stats 接口
|
||||
@@ -72,16 +69,14 @@ interface DockerContainer {
|
||||
Labels: Record<string, string>;
|
||||
stats?: DockerStats | null; // 可选的 stats 字段
|
||||
}
|
||||
// --- End Docker Interfaces ---
|
||||
|
||||
|
||||
// --- 新增:解析 Ports 字符串的辅助函数 ---
|
||||
function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined
|
||||
function parsePortsString(portsString: string | undefined | null): PortInfo[] {
|
||||
if (!portsString) {
|
||||
return [];
|
||||
}
|
||||
const ports: PortInfo[] = []; // Now PortInfo is defined
|
||||
// 示例格式: "0.0.0.0:8080->80/tcp, :::8080->80/tcp", "127.0.0.1:5432->5432/tcp", "6379/tcp"
|
||||
const entries = portsString.split(', ');
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -89,17 +84,16 @@ function parsePortsString(portsString: string | undefined | null): PortInfo[] {
|
||||
let publicPart = '';
|
||||
let privatePart = '';
|
||||
|
||||
if (parts.length === 2) { // Format like "IP:PublicPort->PrivatePort/Type" or "PublicPort->PrivatePort/Type"
|
||||
if (parts.length === 2) {
|
||||
publicPart = parts[0];
|
||||
privatePart = parts[1];
|
||||
} else if (parts.length === 1) { // Format like "PrivatePort/Type"
|
||||
} else if (parts.length === 1) {
|
||||
privatePart = parts[0];
|
||||
} else {
|
||||
console.warn(`[WebSocket] Skipping unparsable port entry: ${entry}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse Private Part (e.g., "80/tcp")
|
||||
const privateMatch = privatePart.match(/^(\d+)\/(tcp|udp|\w+)$/);
|
||||
if (!privateMatch) {
|
||||
console.warn(`[WebSocket] Skipping unparsable private port part: ${privatePart}`);
|
||||
@@ -111,15 +105,15 @@ function parsePortsString(portsString: string | undefined | null): PortInfo[] {
|
||||
let ip: string | undefined = undefined;
|
||||
let publicPort: number | undefined = undefined;
|
||||
|
||||
// Parse Public Part (e.g., "0.0.0.0:8080" or ":::8080" or just "8080")
|
||||
|
||||
if (publicPart) {
|
||||
const publicMatch = publicPart.match(/^(?:([\d.:a-fA-F]+):)?(\d+)$/); // Supports IPv4, IPv6, or just port
|
||||
const publicMatch = publicPart.match(/^(?:([\d.:a-fA-F]+):)?(\d+)$/);
|
||||
if (publicMatch) {
|
||||
ip = publicMatch[1] || undefined; // IP might be undefined if only port is specified
|
||||
ip = publicMatch[1] || undefined;
|
||||
publicPort = parseInt(publicMatch[2], 10);
|
||||
} else {
|
||||
console.warn(`[WebSocket] Skipping unparsable public port part: ${publicPart}`);
|
||||
// Continue processing with only private port info if public part is weird
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +128,10 @@ function parsePortsString(portsString: string | undefined | null): PortInfo[] {
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
// --- 结束辅助函数 ---
|
||||
|
||||
|
||||
// 存储所有活动客户端的状态 (key: sessionId)
|
||||
export const clientStates = new Map<string, ClientState>(); // Export clientStates
|
||||
export const clientStates = new Map<string, ClientState>();
|
||||
|
||||
// --- 服务实例化 ---
|
||||
// 将 clientStates 传递给需要访问共享状态的服务
|
||||
@@ -183,11 +177,11 @@ const cleanupClientConnection = (sessionId: string | undefined) => {
|
||||
|
||||
console.log(`WebSocket: 会话 ${sessionId} 已清理。`);
|
||||
} else {
|
||||
// console.log(`WebSocket: 清理时未找到会话 ${sessionId} 的状态。`);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
// --- NEW: Reusable function to fetch remote Docker status with stats ---
|
||||
|
||||
const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available: boolean; containers: DockerContainer[] }> => {
|
||||
if (!state || !state.sshClient) {
|
||||
throw new Error('SSH client is not available in the current state.');
|
||||
@@ -195,9 +189,9 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
|
||||
let allContainers: DockerContainer[] = [];
|
||||
const statsMap = new Map<string, DockerStats>();
|
||||
let isDockerCmdAvailable = false; // Start assuming unavailable until version check passes
|
||||
let isDockerCmdAvailable = false;
|
||||
|
||||
// --- 1. Check Docker Availability with 'docker version' ---
|
||||
|
||||
try {
|
||||
const versionCommand = "docker version --format '{{.Server.Version}}'";
|
||||
console.log(`[fetchRemoteDockerStatus] Executing: ${versionCommand} on session ${state.ws.sessionId}`);
|
||||
@@ -209,43 +203,43 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
stream.on('close', (code: number | null) => {
|
||||
// Resolve even if code is non-zero, check stderr
|
||||
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
stream.on('error', (execErr: Error) => reject(execErr));
|
||||
});
|
||||
});
|
||||
|
||||
// Check stderr for common errors indicating Docker is unavailable or inaccessible
|
||||
|
||||
if (versionStderr.includes('command not found') ||
|
||||
versionStderr.includes('permission denied') ||
|
||||
versionStderr.includes('Cannot connect to the Docker daemon')) {
|
||||
console.warn(`[fetchRemoteDockerStatus] Docker version check failed on session ${state.ws.sessionId}. Docker unavailable or inaccessible. Stderr: ${versionStderr.trim()}`);
|
||||
return { available: false, containers: [] }; // Docker not available
|
||||
return { available: false, containers: [] };
|
||||
} else if (versionStderr) {
|
||||
// Log other stderr outputs as warnings but proceed
|
||||
|
||||
console.warn(`[fetchRemoteDockerStatus] Docker version command stderr on session ${state.ws.sessionId}: ${versionStderr.trim()}`);
|
||||
}
|
||||
|
||||
// If stdout has content (version number), Docker is likely available
|
||||
|
||||
if (versionStdout.trim()) {
|
||||
console.log(`[fetchRemoteDockerStatus] Docker version check successful on session ${state.ws.sessionId}. Version: ${versionStdout.trim()}`);
|
||||
isDockerCmdAvailable = true;
|
||||
} else {
|
||||
// If stdout is empty but no critical error in stderr, still assume unavailable
|
||||
|
||||
console.warn(`[fetchRemoteDockerStatus] Docker version check on session ${state.ws.sessionId} produced no output, assuming Docker unavailable.`);
|
||||
return { available: false, containers: [] };
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[fetchRemoteDockerStatus] Error executing docker version for session ${state.ws.sessionId}:`, error);
|
||||
// Treat any error during version check as Docker being unavailable
|
||||
|
||||
return { available: false, containers: [] };
|
||||
}
|
||||
|
||||
// If version check failed, we already returned. If it passed, isDockerCmdAvailable is true.
|
||||
|
||||
|
||||
// --- 2. Get basic container info ---
|
||||
|
||||
try {
|
||||
const psCommand = "docker ps -a --no-trunc --format '{{json .}}'";
|
||||
console.log(`[fetchRemoteDockerStatus] Executing: ${psCommand} on session ${state.ws.sessionId}`);
|
||||
@@ -257,44 +251,44 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
stream.on('close', (code: number | null) => {
|
||||
// Don't reject on non-zero code here, check stderr
|
||||
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
stream.on('error', (execErr: Error) => reject(execErr));
|
||||
});
|
||||
});
|
||||
|
||||
// Although version check should catch most, double-check ps stderr
|
||||
if (psStderr.includes('command not found') || // Should not happen if version check passed
|
||||
psStderr.includes('permission denied') || // Could still happen if permissions differ
|
||||
psStderr.includes('Cannot connect to the Docker daemon')) { // Should not happen
|
||||
|
||||
if (psStderr.includes('command not found') ||
|
||||
psStderr.includes('permission denied') ||
|
||||
psStderr.includes('Cannot connect to the Docker daemon')) {
|
||||
console.warn(`[fetchRemoteDockerStatus] Docker ps command failed unexpectedly after version check on session ${state.ws.sessionId}. Stderr: ${psStderr.trim()}`);
|
||||
// Report as available=false, as ps failed critically
|
||||
|
||||
return { available: false, containers: [] };
|
||||
} else if (psStderr) {
|
||||
console.warn(`[fetchRemoteDockerStatus] Docker ps command stderr on session ${state.ws.sessionId}: ${psStderr.trim()}`);
|
||||
// Continue execution but log the warning
|
||||
|
||||
}
|
||||
|
||||
// If stdout is empty, there are no containers, which is valid
|
||||
|
||||
const lines = psStdout.trim() ? psStdout.trim().split('\n') : [];
|
||||
allContainers = lines
|
||||
.map(line => {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
// Map raw data to DockerContainer interface (lowercase id)
|
||||
|
||||
const container: DockerContainer = {
|
||||
id: data.ID, // Map ID to lowercase id
|
||||
id: data.ID,
|
||||
Names: typeof data.Names === 'string' ? data.Names.split(',') : (data.Names || []),
|
||||
Image: data.Image || '',
|
||||
ImageID: data.ImageID || '',
|
||||
Command: data.Command || '',
|
||||
Created: data.CreatedAt || 0, // Check if CreatedAt exists
|
||||
Created: data.CreatedAt || 0,
|
||||
State: data.State || 'unknown',
|
||||
Status: data.Status || '',
|
||||
Ports: parsePortsString(data.Ports),
|
||||
Labels: data.Labels || {},
|
||||
stats: null // Initialize stats as null
|
||||
stats: null
|
||||
};
|
||||
return container;
|
||||
} catch (parseError) {
|
||||
@@ -306,19 +300,19 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[fetchRemoteDockerStatus] Error executing docker ps for session ${state.ws.sessionId}:`, error);
|
||||
// If ps command fails after version check, report as unavailable
|
||||
|
||||
return { available: false, containers: [] };
|
||||
// Rethrowing might be too aggressive here, better to report unavailability
|
||||
// throw new Error(`Failed to get remote Docker container list: ${error.message || error}`);
|
||||
|
||||
|
||||
}
|
||||
|
||||
// --- 3. Get stats for running containers (only if ps was successful) ---
|
||||
// Check if there are any containers before running stats
|
||||
|
||||
|
||||
const runningContainerIds = allContainers.filter(c => c.State === 'running').map(c => c.id);
|
||||
|
||||
if (runningContainerIds.length > 0) {
|
||||
try {
|
||||
// Construct command to get stats only for running containers
|
||||
|
||||
const statsCommand = `docker stats ${runningContainerIds.join(' ')} --no-stream --format '{{json .}}'`;
|
||||
console.log(`[fetchRemoteDockerStatus] Executing: ${statsCommand} on session ${state.ws.sessionId}`);
|
||||
const { stdout: statsStdout, stderr: statsStderr } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
@@ -329,7 +323,7 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
stream.on('close', (code: number | null) => {
|
||||
// Don't reject on non-zero code, check stderr
|
||||
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
stream.on('error', (execErr: Error) => reject(execErr));
|
||||
@@ -337,7 +331,7 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
});
|
||||
|
||||
if (statsStderr) {
|
||||
// Log stats errors but don't necessarily fail the whole process
|
||||
|
||||
console.warn(`[fetchRemoteDockerStatus] Docker stats command stderr on session ${state.ws.sessionId}: ${statsStderr.trim()}`);
|
||||
}
|
||||
|
||||
@@ -346,7 +340,7 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
try {
|
||||
const statsData = JSON.parse(line) as DockerStats;
|
||||
if (statsData.ID) {
|
||||
// Use the ID from stats data (usually short ID) as the key
|
||||
|
||||
statsMap.set(statsData.ID, statsData);
|
||||
}
|
||||
} catch (parseError) {
|
||||
@@ -354,32 +348,32 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Failure to get stats is not critical, just log and continue
|
||||
|
||||
console.warn(`[fetchRemoteDockerStatus] Error executing docker stats for session ${state.ws.sessionId}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`[fetchRemoteDockerStatus] No running containers found on session ${state.ws.sessionId}, skipping docker stats.`);
|
||||
}
|
||||
|
||||
// --- 4. Merge stats into containers ---
|
||||
|
||||
allContainers.forEach(container => {
|
||||
const shortId = container.id.substring(0, 12); // docker stats often uses short ID
|
||||
const stats = statsMap.get(container.id) || statsMap.get(shortId); // Try matching long and short ID
|
||||
const shortId = container.id.substring(0, 12);
|
||||
const stats = statsMap.get(container.id) || statsMap.get(shortId);
|
||||
if (stats) {
|
||||
container.stats = stats;
|
||||
}
|
||||
});
|
||||
|
||||
// If we reached here, Docker is considered available (version check passed)
|
||||
|
||||
return { available: true, containers: allContainers };
|
||||
};
|
||||
// --- End fetchRemoteDockerStatus function ---
|
||||
|
||||
|
||||
export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise<WebSocketServer> => { // Make async
|
||||
|
||||
export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise<WebSocketServer> => {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const db = await getDbInstance(); // 获取数据库实例 (use await and getDbInstance)
|
||||
const DOCKER_STATUS_INTERVAL = 2000; // Poll Docker status every 2 seconds
|
||||
const db = await getDbInstance();
|
||||
const DOCKER_STATUS_INTERVAL = 2000;
|
||||
|
||||
// --- 心跳检测 ---
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
@@ -499,7 +493,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
// --- 添加日志:打印收到的原始数据 ---
|
||||
console.log(`SSH Data (会话: ${newSessionId}, 原始): `, data.toString()); // 添加原始数据日志 (尝试 utf8)
|
||||
console.log(`SSH Data (会话: ${newSessionId}, Hex): `, data.toString('hex')); // 添加 Hex 日志
|
||||
// ------------------------------------
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' }));
|
||||
}
|
||||
@@ -543,10 +537,10 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`);
|
||||
statusMonitorService.startStatusPolling(newSessionId);
|
||||
|
||||
// 8. Start Docker status polling (using setting)
|
||||
|
||||
console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`);
|
||||
// --- Get interval from settings ---
|
||||
let dockerPollIntervalMs = 2000; // Default interval
|
||||
|
||||
let dockerPollIntervalMs = 2000;
|
||||
try {
|
||||
const intervalSetting = await settingsService.getSetting('dockerStatusIntervalSeconds');
|
||||
if (intervalSetting) {
|
||||
@@ -563,32 +557,32 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
} catch (settingError) {
|
||||
console.error(`[Docker Polling] Error fetching interval setting for session ${newSessionId}. Using default ${dockerPollIntervalMs}ms:`, settingError);
|
||||
}
|
||||
// --- End get interval ---
|
||||
|
||||
|
||||
const dockerIntervalId = setInterval(async () => {
|
||||
const currentState = clientStates.get(newSessionId); // Re-fetch state
|
||||
const currentState = clientStates.get(newSessionId);
|
||||
if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) {
|
||||
console.log(`[Docker Polling] Session ${newSessionId} no longer valid or WS closed. Stopping poll.`);
|
||||
clearInterval(dockerIntervalId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// console.log(`[Docker Polling] Fetching status for session ${newSessionId}...`);
|
||||
|
||||
const statusPayload = await fetchRemoteDockerStatus(currentState);
|
||||
if (currentState.ws.readyState === WebSocket.OPEN) {
|
||||
currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload }));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error);
|
||||
// Optionally send error to client, or just log
|
||||
// if (currentState.ws.readyState === WebSocket.OPEN) {
|
||||
// currentState.ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Polling failed: ${error.message}` } }));
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}, dockerPollIntervalMs); // <-- Use the determined interval
|
||||
}, dockerPollIntervalMs);
|
||||
newState.dockerStatusIntervalId = dockerIntervalId;
|
||||
|
||||
// 9. Trigger initial Docker status fetch immediately
|
||||
|
||||
(async () => {
|
||||
const currentState = clientStates.get(newSessionId);
|
||||
if (currentState && currentState.ws.readyState === WebSocket.OPEN) {
|
||||
@@ -601,7 +595,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
} catch (error: any) {
|
||||
console.error(`[Docker Initial Fetch] Error fetching Docker status for session ${newSessionId}:`, error);
|
||||
if (currentState.ws.readyState === WebSocket.OPEN) {
|
||||
// Send specific error type for initial fetch failure
|
||||
|
||||
const errorMessage = error.message || 'Unknown error during initial fetch';
|
||||
const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon');
|
||||
if (isUnavailable) {
|
||||
@@ -654,7 +648,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
ws.send(JSON.stringify({ type: 'ssh:error', payload: `连接失败: ${connectError.message}` }));
|
||||
}
|
||||
break;
|
||||
} // end case 'ssh:connect'
|
||||
}
|
||||
|
||||
// --- SSH 输入 ---
|
||||
case 'ssh:input': {
|
||||
@@ -683,7 +677,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
break;
|
||||
}
|
||||
|
||||
// --- REFACTORED: Handle Docker Status Request ---
|
||||
|
||||
case 'docker:get_status': {
|
||||
if (!state) {
|
||||
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动会话状态。`);
|
||||
@@ -697,13 +691,13 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
}
|
||||
console.log(`WebSocket: 处理来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求 (手动触发)...`);
|
||||
try {
|
||||
// Call the reusable function
|
||||
|
||||
const statusPayload = await fetchRemoteDockerStatus(state);
|
||||
ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload }));
|
||||
} catch (error: any) {
|
||||
console.error(`WebSocket: 手动执行远程 Docker 状态命令失败 for session ${sessionId}:`, error);
|
||||
const errorMessage = error.message || 'Unknown error fetching status';
|
||||
// Send specific error if Docker unavailable, general error otherwise
|
||||
|
||||
const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon');
|
||||
if (isUnavailable) {
|
||||
ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } }));
|
||||
@@ -712,9 +706,9 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
}
|
||||
}
|
||||
break;
|
||||
} // end case 'docker:get_status' (Refactored)
|
||||
}
|
||||
|
||||
// --- NEW: Handle Docker Command Execution ---
|
||||
|
||||
case 'docker:command': {
|
||||
if (!state || !state.sshClient) {
|
||||
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`);
|
||||
@@ -743,7 +737,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
console.log(`WebSocket: Validation PASSED for docker:command.`); // 增加成功日志
|
||||
console.log(`WebSocket: Processing command '${command}' for container '${containerId}' on session ${sessionId}...`);
|
||||
try {
|
||||
// Sanitize containerId (basic) - more robust validation might be needed
|
||||
|
||||
const cleanContainerId = containerId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (!cleanContainerId) throw new Error('Invalid container ID format after sanitization.');
|
||||
|
||||
@@ -752,11 +746,11 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
case 'start': dockerCliCommand = `docker start ${cleanContainerId}`; break;
|
||||
case 'stop': dockerCliCommand = `docker stop ${cleanContainerId}`; break;
|
||||
case 'restart': dockerCliCommand = `docker restart ${cleanContainerId}`; break;
|
||||
case 'remove': dockerCliCommand = `docker rm -f ${cleanContainerId}`; break; // Use -f for remove
|
||||
default: throw new Error(`Unsupported command: ${command}`); // Should be caught by earlier validation
|
||||
case 'remove': dockerCliCommand = `docker rm -f ${cleanContainerId}`; break;
|
||||
default: throw new Error(`Unsupported command: ${command}`);
|
||||
}
|
||||
|
||||
// Execute command remotely
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
state.sshClient.exec(dockerCliCommand, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
@@ -771,20 +765,20 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
reject(new Error(`Command failed with code ${code}. ${stderr || 'No stderr output.'}`));
|
||||
}
|
||||
});
|
||||
// Add type annotation for execErr
|
||||
|
||||
stream.on('error', (execErr: Error) => reject(execErr));
|
||||
});
|
||||
});
|
||||
// Optionally send a success confirmation back? Not strictly needed if status updates quickly.
|
||||
// ws.send(JSON.stringify({ type: 'docker:command:success', payload: { command, containerId } }));
|
||||
|
||||
|
||||
|
||||
// Trigger a status update after command execution
|
||||
// Use a small delay to allow Docker daemon to potentially update state
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (clientStates.has(sessionId!)) { // Check if session still exists
|
||||
ws.send(JSON.stringify({ type: 'request_docker_status_update' })); // Ask frontend to re-request
|
||||
// Or directly trigger backend fetch and push:
|
||||
// handleDockerGetStatus(ws, state); // Need to refactor get_status logic into a reusable function
|
||||
if (clientStates.has(sessionId!)) {
|
||||
ws.send(JSON.stringify({ type: 'request_docker_status_update' }));
|
||||
|
||||
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -794,10 +788,10 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
ws.send(JSON.stringify({ type: 'docker:command:error', payload: { command, containerId, message: `Failed to execute remote command: ${error.message}` } }));
|
||||
}
|
||||
break;
|
||||
} // end case 'docker:command'
|
||||
}
|
||||
|
||||
|
||||
// --- SFTP Cases ---
|
||||
|
||||
case 'sftp:readdir':
|
||||
case 'sftp:stat':
|
||||
case 'sftp:readfile':
|
||||
@@ -887,7 +881,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
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;
|
||||
}
|
||||
@@ -907,17 +901,17 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
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;
|
||||
}
|
||||
|
||||
// --- NEW CASE: Handle docker:get_stats ---
|
||||
|
||||
case 'docker:get_stats': {
|
||||
if (!state || !state.sshClient) { // Check state and sshClient
|
||||
if (!state || !state.sshClient) {
|
||||
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`);
|
||||
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId: payload?.containerId, message: 'SSH connection not active.' } }));
|
||||
return; // Use return instead of break inside switch
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.containerId) {
|
||||
console.warn(`WebSocket: Invalid payload for docker:get_stats in session ${sessionId}:`, payload);
|
||||
@@ -930,7 +924,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
const command = `docker stats ${containerId} --no-stream --format '{{json .}}'`;
|
||||
|
||||
try {
|
||||
// --- FIX: Use sshClient.exec directly ---
|
||||
|
||||
const execResult = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
@@ -939,33 +933,33 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
stream.on('close', (code: number | null) => {
|
||||
// Don't reject on non-zero exit code here, stderr check is more reliable for docker stats
|
||||
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
stream.on('error', (execErr: Error) => reject(execErr));
|
||||
});
|
||||
});
|
||||
// --- End FIX ---
|
||||
|
||||
|
||||
if (execResult.stderr) {
|
||||
// Handle cases like container not found or docker errors
|
||||
|
||||
console.error(`WebSocket: Docker stats stderr for ${containerId} in session ${sessionId}: ${execResult.stderr}`);
|
||||
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: execResult.stderr.trim() || 'Error executing stats command.' } }));
|
||||
return; // Use return after sending error
|
||||
return;
|
||||
}
|
||||
|
||||
if (!execResult.stdout) {
|
||||
console.warn(`WebSocket: No stats output for container ${containerId} in session ${sessionId}. Might be stopped or error occurred.`);
|
||||
// Check stderr again just in case, although previous check should catch most errors
|
||||
|
||||
if (!execResult.stderr) {
|
||||
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: 'No stats data received (container might be stopped).' } }));
|
||||
}
|
||||
return; // Use return after sending error or warning
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const statsData = JSON.parse(execResult.stdout.trim());
|
||||
// Optional: Clean up or format statsData if needed before sending
|
||||
|
||||
ws.send(JSON.stringify({ type: 'docker:stats:update', payload: { containerId, stats: statsData } }));
|
||||
} catch (parseError) {
|
||||
console.error(`WebSocket: Failed to parse docker stats JSON for ${containerId} in session ${sessionId}: ${execResult.stdout}`, parseError);
|
||||
@@ -976,8 +970,8 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
console.error(`WebSocket: Failed to execute docker stats for ${containerId} in session ${sessionId}:`, error);
|
||||
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: error.message || 'Failed to fetch Docker stats.' } }));
|
||||
}
|
||||
break; // Break after handling the case
|
||||
} // --- END CASE: docker:get_stats ---
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
@@ -1016,4 +1010,4 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
return wss;
|
||||
};
|
||||
|
||||
// --- 移除旧的辅助函数 ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user