This commit is contained in:
Baobhan Sith
2025-04-26 15:20:37 +08:00
parent 93b8863fdd
commit e269f40754
80 changed files with 868 additions and 1528 deletions
@@ -34,7 +34,7 @@ const backgroundUpload = multer({
}, },
limits: { fileSize: 5 * 1024 * 1024 } // 限制文件大小为 5MB 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 - 上传页面背景图片 // POST /api/v1/appearance/background/page - 上传页面背景图片
router.post( router.post(
'/background/page', '/background/page',
appearanceController.uploadPageBackgroundMiddleware, // 使用 multer 中间件 appearanceController.uploadPageBackgroundMiddleware,
appearanceController.uploadPageBackgroundController appearanceController.uploadPageBackgroundController
); );
// POST /api/v1/appearance/background/terminal - 上传终端背景图片 // POST /api/v1/appearance/background/terminal - 上传终端背景图片
router.post( router.post(
'/background/terminal', '/background/terminal',
appearanceController.uploadTerminalBackgroundMiddleware, // 使用 multer 中间件 appearanceController.uploadTerminalBackgroundMiddleware,
appearanceController.uploadTerminalBackgroundController appearanceController.uploadTerminalBackgroundController
); );
@@ -39,7 +39,6 @@ export class AuditController {
res.status(400).json({ message: '无效的 endDate 参数' }); res.status(400).json({ message: '无效的 endDate 参数' });
return; return;
} }
// TODO: 可以添加对 actionType 是否有效的验证
// 将 searchTerm 传递给 service // 将 searchTerm 传递给 service
const result = await auditLogService.getLogs(limit, offset, actionType, startDate, endDate, searchTerm); const result = await auditLogService.getLogs(limit, offset, actionType, startDate, endDate, searchTerm);
@@ -52,7 +51,7 @@ export class AuditController {
parsedDetails = JSON.parse(log.details); parsedDetails = JSON.parse(log.details);
} catch (e) { } catch (e) {
console.warn(`[Audit Log] Failed to parse details for log ID ${log.id}:`, 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 }; return { ...log, details: parsedDetails };
+2 -3
View File
@@ -1,14 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import { AuditController } from './audit.controller'; 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 router = Router();
const auditController = new AuditController(); const auditController = new AuditController();
// Apply auth middleware to protect the audit log endpoint
router.use(isAuthenticated); router.use(isAuthenticated);
// Define route for getting audit logs
router.get('/', auditController.getAuditLogs); router.get('/', auditController.getAuditLogs);
export default router; export default router;
+51 -148
View File
@@ -1,41 +1,35 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
// Import the instance getter and promisified helpers
import { getDbInstance, runDb, getDb, allDb } from '../database/connection'; 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 speakeasy from 'speakeasy';
import qrcode from 'qrcode'; import qrcode from 'qrcode';
import { PasskeyService } from '../services/passkey.service'; // 导入 PasskeyService import { PasskeyService } from '../services/passkey.service';
import { NotificationService } from '../services/notification.service'; // 导入 NotificationService import { NotificationService } from '../services/notification.service';
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService import { AuditLogService } from '../services/audit.service';
import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务 import { ipBlacklistService } from '../services/ip-blacklist.service';
import { captchaService } from '../services/captcha.service'; // <-- Import CaptchaService import { captchaService } from '../services/captcha.service';
import { settingsService } from '../services/settings.service'; // <-- Import SettingsService for config check 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 { interface User {
id: number; id: number;
username: string; username: string;
hashed_password: string; // 数据库中存储的哈希密码 hashed_password: string;
two_factor_secret?: string | null; // 2FA 密钥 (数据库中可能为 NULL) two_factor_secret?: string | null;
// 其他可能的字段...
} }
// 扩展 SessionData 接口以包含临时的 2FA 密钥
declare module 'express-session' { declare module 'express-session' {
interface SessionData { interface SessionData {
userId?: number; userId?: number;
username?: string; username?: string;
tempTwoFactorSecret?: string; tempTwoFactorSecret?: string;
requiresTwoFactor?: boolean; requiresTwoFactor?: boolean;
currentChallenge?: string; // 用于存储 Passkey 操作的挑战 currentChallenge?: string;
rememberMe?: boolean; // 新增:临时存储“记住我”选项 rememberMe?: boolean;
} }
} }
@@ -58,54 +52,39 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (captchaConfig.enabled) { if (captchaConfig.enabled) {
const { captchaToken } = req.body; const { captchaToken } = req.body;
if (!captchaToken) { if (!captchaToken) {
console.log(`[AuthController] 登录尝试失败: CAPTCHA 已启用但未提供令牌 - ${username}`);
// 记录审计日志等(可选,看是否需要区分)
res.status(400).json({ message: '需要提供 CAPTCHA 令牌。' }); res.status(400).json({ message: '需要提供 CAPTCHA 令牌。' });
return; // 添加 return 语句以确保函数在此处终止 return;
} }
try { try {
const isCaptchaValid = await captchaService.verifyToken(captchaToken); const isCaptchaValid = await captchaService.verifyToken(captchaToken);
if (!isCaptchaValid) { if (!isCaptchaValid) {
console.log(`[AuthController] 登录尝试失败: CAPTCHA 验证失败 - ${username}`); console.log(`[AuthController] 登录尝试失败: CAPTCHA 验证失败 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; 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 }); auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
notificationService.sendNotification('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 验证失败。' }); res.status(401).json({ message: 'CAPTCHA 验证失败。' });
return; // 添加 return 语句以确保函数在此处终止 return;
} }
console.log(`[AuthController] CAPTCHA 验证成功 - ${username}`); console.log(`[AuthController] CAPTCHA 验证成功 - ${username}`);
} catch (captchaError: any) { } catch (captchaError: any) {
console.error(`[AuthController] CAPTCHA 验证过程中出错 (${username}):`, captchaError.message); console.error(`[AuthController] CAPTCHA 验证过程中出错 (${username}):`, captchaError.message);
// 如果是配置错误或 API 请求失败,返回 500
res.status(500).json({ message: 'CAPTCHA 验证服务出错,请稍后重试或检查配置。' }); res.status(500).json({ message: 'CAPTCHA 验证服务出错,请稍后重试或检查配置。' });
return; // 添加 return 语句以确保函数在此处终止 return;
} }
} else { } else {
console.log(`[AuthController] CAPTCHA 未启用,跳过验证 - ${username}`); 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]); 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) { if (!user) {
console.log(`登录尝试失败: 用户未找到 - ${username}`); console.log(`登录尝试失败: 用户未找到 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录失败尝试 // 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp); ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP) // 记录审计日志 (添加 IP)
@@ -153,11 +132,11 @@ export const login = async (req: Request, res: Response): Promise<void> => {
// 根据 rememberMe 设置 cookie maxAge // 根据 rememberMe 设置 cookie maxAge
if (rememberMe) { if (rememberMe) {
// 如果勾选了“记住我”,设置 cookie 有效期为 1 年 (毫秒) // 如果勾选了“记住我”,设置 cookie 有效期为 10 年 (毫秒)
req.session.cookie.maxAge = 315360000000; // 10 years = 10 * 365 * 24 * 60 * 60 * 1000 (Effectively permanent) req.session.cookie.maxAge = 315360000000;
} else { } else {
// 如果未勾选,则不设置 maxAge,使其成为会话 cookie // 如果未勾选,则不设置 maxAge,使其成为会话 cookie
req.session.cookie.maxAge = undefined; // 或者 null req.session.cookie.maxAge = undefined;
} }
res.status(200).json({ res.status(200).json({
@@ -190,19 +169,7 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
// 查询用户的 2FA 状态 using promisified getDb // 查询用户的 2FA 状态 using promisified getDb
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]); 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) { if (!user) {
res.status(401).json({ isAuthenticated: false }); res.status(401).json({ isAuthenticated: false });
return; return;
@@ -241,21 +208,11 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
} }
try { try {
const db = await getDbInstance(); // Get DB instance const db = await getDbInstance();
// 获取用户的 2FA 密钥 using promisified getDb // 获取用户的 2FA 密钥 using promisified getDb
const user = await getDb<User>(db, 'SELECT id, username, two_factor_secret FROM users WHERE id = ?', [userId]); 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) { if (!user || !user.two_factor_secret) {
console.error(`2FA 验证错误: 未找到用户 ${userId} 或未设置密钥。`); console.error(`2FA 验证错误: 未找到用户 ${userId} 或未设置密钥。`);
@@ -342,21 +299,10 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
} }
try { try {
const db = await getDbInstance(); // Get DB instance const db = await getDbInstance();
// Use promisified getDb
const user = await getDb<User>(db, 'SELECT id, hashed_password FROM users WHERE id = ?', [userId]); 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) { if (!user) {
console.error(`修改密码错误: 未找到 ID 为 ${userId} 的用户。`); 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 newHashedPassword = await bcrypt.hash(newPassword, saltRounds);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
// Use promisified runDb instead of prepare/run/finalize
const result = await runDb(db, const result = await runDb(db,
'UPDATE users SET hashed_password = ?, updated_at = ? WHERE id = ?', 'UPDATE users SET hashed_password = ?, updated_at = ? WHERE id = ?',
[newHashedPassword, now, userId] [newHashedPassword, now, userId]
@@ -383,7 +329,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
if (result.changes === 0) { if (result.changes === 0) {
console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`); console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`);
throw new Error('未找到要更新的用户'); // Throw error to be caught below throw new Error('未找到要更新的用户');
} }
console.log(`用户 ${userId} 密码已成功修改。`); console.log(`用户 ${userId} 密码已成功修改。`);
@@ -413,20 +359,12 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
} }
try { try {
const db = await getDbInstance(); // Get DB instance const db = await getDbInstance();
// 检查用户是否已启用 2FA using promisified getDb // 检查用户是否已启用 2FA using promisified getDb
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]); 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; 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) { if (existingSecret) {
res.status(400).json({ message: '两步验证已启用。如需重置,请先禁用。' }); res.status(400).json({ message: '两步验证已启用。如需重置,请先禁用。' });
return; 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) * 生成 Passkey 注册选项 (POST /api/v1/auth/passkey/register-options)
@@ -526,11 +464,10 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
name name
); );
// Check if verification was successful and registrationInfo is present
if (verification.verified && verification.registrationInfo) { if (verification.verified && verification.registrationInfo) {
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP) // 记录审计日志 (添加 IP)
// Use type assertion 'as any' to bypass persistent TS error for now
const regInfo: any = verification.registrationInfo; const regInfo: any = verification.registrationInfo;
auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp }); auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp });
res.status(201).json({ message: 'Passkey 注册成功!', verified: true }); res.status(201).json({ message: 'Passkey 注册成功!', verified: true });
@@ -568,7 +505,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
} }
try { try {
const db = await getDbInstance(); // <<< Add this line to get the db instance const db = await getDbInstance();
// 使用临时密钥验证用户提交的令牌 // 使用临时密钥验证用户提交的令牌
const verified = speakeasy.totp.verify({ const verified = speakeasy.totp.verify({
secret: tempSecret, secret: tempSecret,
@@ -591,7 +528,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
} }
console.log(`用户 ${userId} 已成功激活两步验证。`); console.log(`用户 ${userId} 已成功激活两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP) // 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp }); auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp });
@@ -629,17 +566,10 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
} }
try { try {
const db = await getDbInstance(); // Get DB instance const db = await getDbInstance();
// 1. 验证当前密码 using promisified getDb // 验证当前密码
const user = await getDb<User>(db, 'SELECT id, hashed_password FROM users WHERE id = ?', [userId]); 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) { if (!user) {
res.status(404).json({ message: '用户不存在。' }); return; 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; res.status(400).json({ message: '当前密码不正确。' }); return;
} }
// 2. 清除数据库中的 2FA 密钥 using promisified runDb // 清除数据库中的 2FA 密钥
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const result = await runDb(db, const result = await runDb(db,
'UPDATE users SET two_factor_secret = NULL, updated_at = ? WHERE id = ?', '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} 已成功禁用两步验证。`); console.log(`用户 ${userId} 已成功禁用两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP) // 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp }); 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 row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
const userCount = row ? row.count : 0; 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 }); res.status(200).json({ needsSetup: userCount === 0 });
} catch (error) { } 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> => { export const setupAdmin = async (req: Request, res: Response): Promise<void> => {
const { username, password, confirmPassword } = req.body; const { username, password, confirmPassword } = req.body;
// 1. 基本输入验证 // 基本输入验证
if (!username || !password || !confirmPassword) { if (!username || !password || !confirmPassword) {
res.status(400).json({ message: '用户名、密码和确认密码不能为空。' }); res.status(400).json({ message: '用户名、密码和确认密码不能为空。' });
return; return;
@@ -727,44 +645,30 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
try { try {
const db = await getDbInstance(); // Get DB instance const db = await getDbInstance();
// 2. 检查数据库中是否已存在用户 (关键安全检查) using promisified getDb // 检查数据库中是否已存在用户
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users'); const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
const userCount = row ? row.count : 0; 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) { if (userCount > 0) {
console.warn('尝试在已有用户的情况下执行初始设置。'); console.warn('尝试在已有用户的情况下执行初始设置。');
res.status(403).json({ message: '设置已完成,无法重复执行。' }); res.status(403).json({ message: '设置已完成,无法重复执行。' });
return; return;
} }
// 3. 哈希密码 // 哈希密码
const saltRounds = 10; const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds); const hashedPassword = await bcrypt.hash(password, saltRounds);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
// 4. 插入新用户 using promisified runDb // 插入新用户
const result = await runDb(db, const result = await runDb(db,
`INSERT INTO users (username, hashed_password, created_at, updated_at) `INSERT INTO users (username, hashed_password, created_at, updated_at)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
[username, hashedPassword, now, now] [username, hashedPassword, now, now]
); );
// Check if insertion was successful and get the ID
if (typeof result.lastID !== 'number' || result.lastID <= 0) { 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。可能原因:用户名已存在或其他数据库错误。'); console.error('创建初始管理员后未能获取有效的 lastID。可能原因:用户名已存在或其他数据库错误。');
throw new Error('创建初始管理员失败,可能用户名已存在。'); throw new Error('创建初始管理员失败,可能用户名已存在。');
} }
@@ -772,7 +676,7 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
console.log(`初始管理员账号 '${username}' (ID: ${newUser.id}) 已成功创建。`); console.log(`初始管理员账号 '${username}' (ID: ${newUser.id}) 已成功创建。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP) // 记录审计日志 (添加 IP)
auditLogService.logAction('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp }); 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) * 处理用户登出请求 (POST /api/v1/auth/logout)
*/ */
export const logout = (req: Request, res: Response): void => { export const logout = (req: Request, res: Response): void => {
const userId = req.session.userId; // 获取用户 ID 用于日志记录 const userId = req.session.userId;
const username = req.session.username; const username = req.session.username;
req.session.destroy((err) => { 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> => { export const getPublicCaptchaConfig = async (req: Request, res: Response): Promise<void> => {
try { try {
console.log('[AuthController] Received request for public CAPTCHA config.'); 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 = { const publicConfig = {
enabled: fullConfig.enabled, enabled: fullConfig.enabled,
provider: fullConfig.provider, provider: fullConfig.provider,
@@ -13,5 +13,3 @@ export const isAuthenticated = (req: Request, res: Response, next: NextFunction)
} }
}; };
// 未来可以添加基于角色的授权中间件等
// export const isAdmin = ...
+8 -11
View File
@@ -6,16 +6,16 @@ import {
setup2FA, setup2FA,
verifyAndActivate2FA, verifyAndActivate2FA,
disable2FA, disable2FA,
getAuthStatus, // 导入获取状态的方法 getAuthStatus,
generatePasskeyRegistrationOptions, // 导入 Passkey 方法 generatePasskeyRegistrationOptions,
verifyPasskeyRegistration, // 导入 Passkey 方法 verifyPasskeyRegistration,
needsSetup, // 导入 needsSetup 控制器 needsSetup,
setupAdmin, // 导入 setupAdmin 控制器 setupAdmin,
logout, // *** 新增:导入 logout 控制器 *** logout,
getPublicCaptchaConfig // <-- Import public CAPTCHA config controller getPublicCaptchaConfig
} from './auth.controller'; } from './auth.controller';
import { isAuthenticated } from './auth.middleware'; import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件 import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
const router = Router(); const router = Router();
@@ -64,8 +64,5 @@ router.post('/passkey/verify-registration', isAuthenticated, verifyPasskeyRegist
// POST /api/v1/auth/logout - 用户登出接口 (公开访问) // POST /api/v1/auth/logout - 用户登出接口 (公开访问)
router.post('/logout', logout); router.post('/logout', logout);
// 未来可以添加的其他认证相关路由
// router.get('/status', getStatus); // 获取当前登录状态
// router.post('/setup', setupAdmin); // 已移到上面
export default router; export default router;
@@ -23,7 +23,6 @@ export const ipBlacklistCheckMiddleware = async (req: Request, res: Response, ne
// 可以返回更通用的错误信息,避免泄露封禁状态 // 可以返回更通用的错误信息,避免泄露封禁状态
res.status(403).json({ message: '访问被拒绝。' }); res.status(403).json({ message: '访问被拒绝。' });
// 或者返回更具体的错误 // 或者返回更具体的错误
// res.status(429).json({ message: '尝试次数过多,请稍后再试。' });
return; // 显式返回 void return; // 显式返回 void
} }
// IP 未被封禁,继续处理请求 // IP 未被封禁,继续处理请求
@@ -1,13 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import * as CommandHistoryController from './command-history.controller'; import * as CommandHistoryController from './command-history.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // 使用正确的认证中间件 import { isAuthenticated } from '../auth/auth.middleware';
const router = Router(); const router = Router();
// 应用认证中间件到所有命令历史记录相关的路由
router.use(isAuthenticated); router.use(isAuthenticated);
// 定义路由
router.post('/', CommandHistoryController.addCommand); // POST /api/command-history router.post('/', CommandHistoryController.addCommand); // POST /api/command-history
router.get('/', CommandHistoryController.getAllCommands); // GET /api/command-history router.get('/', CommandHistoryController.getAllCommands); // GET /api/command-history
router.delete('/:id', CommandHistoryController.deleteCommand); // DELETE /api/command-history/:id router.delete('/:id', CommandHistoryController.deleteCommand); // DELETE /api/command-history/:id
+15 -17
View File
@@ -6,7 +6,7 @@ export const defaultXtermTheme: ITheme = {
background: '#1e1e1e', background: '#1e1e1e',
foreground: '#d4d4d4', foreground: '#d4d4d4',
cursor: '#d4d4d4', cursor: '#d4d4d4',
selectionBackground: '#264f78', // 使用 selectionBackground selectionBackground: '#264f78',
black: '#000000', black: '#000000',
red: '#cd3131', red: '#cd3131',
green: '#0dbc79', green: '#0dbc79',
@@ -32,25 +32,23 @@ export const defaultUiTheme: Record<string, string> = {
'--text-color': '#333333', '--text-color': '#333333',
'--text-color-secondary': '#666666', '--text-color-secondary': '#666666',
'--border-color': '#cccccc', '--border-color': '#cccccc',
'--link-color': '#8E44AD', // 现代紫色 (Amethyst 变种) '--link-color': '#8E44AD',
'--link-hover-color': '#B180E0', // 现代紫色 - 悬停 (更亮) '--link-hover-color': '#B180E0',
'--link-active-color': '#A06CD5', // 现代紫色 - 激活 (基础) '--link-active-color': '#A06CD5',
'--link-active-bg-color': '#F3EBFB', /* 现代紫色 - 激活背景 (非常浅) */ '--link-active-bg-color': '#F3EBFB',
'--nav-item-active-bg-color': 'var(--link-active-bg-color)', /* Added */ '--nav-item-active-bg-color': 'var(--link-active-bg-color)',
'--header-bg-color': '#f0f0f0', '--header-bg-color': '#f0f0f0',
'--footer-bg-color': '#f0f0f0', '--footer-bg-color': '#f0f0f0',
'--button-bg-color': '#A06CD5', // 现代紫色 - 激活 (基础) '--button-bg-color': '#A06CD5',
'--button-text-color': '#ffffff', '--button-text-color': '#ffffff',
'--button-hover-bg-color': '#8E44AD', // 现代紫色 - 悬停 (稍暗) '--button-hover-bg-color': '#8E44AD',
// Added new variables '--icon-color': 'var(--text-color-secondary)',
'--icon-color': 'var(--text-color-secondary)', // 图标颜色 '--icon-hover-color': 'var(--link-hover-color)',
'--icon-hover-color': 'var(--link-hover-color)', // 图标悬停颜色 (自动更新) '--split-line-color': 'var(--border-color)',
'--split-line-color': 'var(--border-color)', /* 分割线颜色 */ '--split-line-hover-color': 'var(--border-color)',
'--split-line-hover-color': 'var(--border-color)', /* 分割线悬停颜色 */ '--input-focus-border-color': 'var(--link-active-color)',
'--input-focus-border-color': 'var(--link-active-color)', /* 输入框聚焦边框颜色 (自动更新) */ '--input-focus-glow': 'var(--link-active-color)',
'--input-focus-glow': 'var(--link-active-color)', /* 输入框聚焦光晕值 (自动更新) */ '--overlay-bg-color': 'rgba(0, 0, 0, 0.6)',
'--overlay-bg-color': 'rgba(0, 0, 0, 0.6)', /* Added Overlay Background - 恢复 rgba 以支持透明度 */
// End added variables
'--font-family-sans-serif': 'sans-serif', '--font-family-sans-serif': 'sans-serif',
'--base-padding': '1rem', '--base-padding': '1rem',
'--base-margin': '0.5rem', '--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'; import type { TerminalTheme } from '../types/terminal-theme.types';
// 定义预设主题数组的类型,确保包含 preset_key
type PresetThemeDefinition = Omit<TerminalTheme, '_id' | 'createdAt' | 'updatedAt'> & { preset_key: string }; type PresetThemeDefinition = Omit<TerminalTheme, '_id' | 'createdAt' | 'updatedAt'> & { preset_key: string };
export const presetTerminalThemes: PresetThemeDefinition[] = [ export const presetTerminalThemes: PresetThemeDefinition[] = [
@@ -1,15 +1,8 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
// Removed duplicate import
import * as ConnectionService from '../services/connection.service'; import * as ConnectionService from '../services/connection.service';
import * as SshService from '../services/ssh.service'; // 引入 SshService import * as SshService from '../services/ssh.service';
import * as ImportExportService from '../services/import-export.service'; // 引入 ImportExportService import * as ImportExportService from '../services/import-export.service';
// Removed AuditLogService import and instantiation
// --- 移除所有不再需要的导入和变量 ---
// 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> => { export const createConnection = async (req: Request, res: Response): Promise<void> => {
try { 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); const newConnection = await ConnectionService.createConnection(req.body);
res.status(201).json({ message: '连接创建成功。', connection: newConnection }); res.status(201).json({ message: '连接创建成功。', connection: newConnection });
} catch (error: any) { } catch (error: any) {
console.error('Controller: 创建连接时发生错误:', error); console.error('Controller: 创建连接时发生错误:', error);
// 根据错误类型返回不同的状态码,例如验证错误返回 400
if (error.message.includes('缺少') || error.message.includes('需要提供')) { if (error.message.includes('缺少') || error.message.includes('需要提供')) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} else { } else {
@@ -81,20 +71,16 @@ export const updateConnection = async (req: Request, res: Response): Promise<voi
return; return;
} }
// Controller performs minimal validation, Service layer handles detailed business logic validation.
// 注意:服务层会处理更复杂的验证,比如切换认证方式时凭证是否提供
const updatedConnection = await ConnectionService.updateConnection(connectionId, req.body); const updatedConnection = await ConnectionService.updateConnection(connectionId, req.body);
if (!updatedConnection) { if (!updatedConnection) {
res.status(404).json({ message: '连接未找到。' }); res.status(404).json({ message: '连接未找到。' });
} else { } else {
// Audit logging is now handled by the service layer
res.status(200).json({ message: '连接更新成功。', connection: updatedConnection }); res.status(200).json({ message: '连接更新成功。', connection: updatedConnection });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Controller: 更新连接 ${req.params.id} 时发生错误:`, error); console.error(`Controller: 更新连接 ${req.params.id} 时发生错误:`, error);
// 根据错误类型返回不同的状态码
if (error.message.includes('需要提供')) { if (error.message.includes('需要提供')) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} else { } else {
@@ -119,8 +105,7 @@ export const deleteConnection = async (req: Request, res: Response): Promise<voi
if (!deleted) { if (!deleted) {
res.status(404).json({ message: '连接未找到。' }); res.status(404).json({ message: '连接未找到。' });
} else { } else {
// Audit logging is now handled by the service layer res.status(200).json({ message: '连接删除成功。' });
res.status(200).json({ message: '连接删除成功。' }); // 或使用 204 No Content
} }
} catch (error: any) { } catch (error: any) {
console.error(`Controller: 删除连接 ${req.params.id} 时发生错误:`, error); 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) * 测试连接 (POST /api/v1/connections/:id/test)
*/ */
@@ -143,16 +128,10 @@ export const testConnection = async (req: Request, res: Response): Promise<void>
// 调用 SshService 进行连接测试,现在它会返回延迟 // 调用 SshService 进行连接测试,现在它会返回延迟
const { latency } = await SshService.testConnection(connectionId); const { latency } = await SshService.testConnection(connectionId);
// 如果 SshService.testConnection 没有抛出错误,则表示成功
// 记录审计日志 (可选,看是否需要记录测试操作)
// auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: true });
res.status(200).json({ success: true, message: '连接测试成功。', latency }); // 返回延迟 res.status(200).json({ success: true, message: '连接测试成功。', latency }); // 返回延迟
} catch (error: any) { } catch (error: any) {
// 记录审计日志 (可选)
// auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: false, error: error.message });
console.error(`Controller: 测试连接 ${req.params.id} 时发生错误:`, error); console.error(`Controller: 测试连接 ${req.params.id} 时发生错误:`, error);
// SshService 会抛出包含具体原因的 Error
res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); 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) * 导出所有连接配置 (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`; const filename = `nexus-terminal-connections-${timestamp}.json`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/json'); 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); res.status(200).json(exportedData);
} catch (error: any) { } 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) * 导入连接配置 (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); const result = await ImportExportService.importConnections(req.file.buffer);
if (result.failureCount > 0) { if (result.failureCount > 0) {
// Partial success or complete failure res.status(400).json({
res.status(400).json({ // Use 400 for partial success with errors
message: `导入完成,但存在 ${result.failureCount} 个错误。成功导入 ${result.successCount} 条。`, message: `导入完成,但存在 ${result.failureCount} 个错误。成功导入 ${result.successCount} 条。`,
successCount: result.successCount, successCount: result.successCount,
failureCount: result.failureCount, failureCount: result.failureCount,
errors: result.errors errors: result.errors
}); });
} else { } 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({ res.status(200).json({
message: `导入成功完成。共导入 ${result.successCount} 条连接。`, message: `导入成功完成。共导入 ${result.successCount} 条连接。`,
successCount: result.successCount, successCount: result.successCount,
@@ -279,12 +250,10 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
} }
} catch (error: any) { } catch (error: any) {
console.error('Controller: 导入连接时发生错误:', error); console.error('Controller: 导入连接时发生错误:', error);
// Handle specific errors like JSON parsing error from service
if (error.message.includes('解析 JSON 文件失败')) { if (error.message.includes('解析 JSON 文件失败')) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} else { } else {
res.status(500).json({ message: error.message || '导入连接时发生内部服务器错误。' }); 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 { Router, Request, Response, NextFunction } from 'express';
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件 import { isAuthenticated } from '../auth/auth.middleware';
import multer from 'multer'; // 引入 multer 用于文件上传 import multer from 'multer';
import { import {
createConnection, createConnection,
getConnections, getConnections,
getConnectionById, // 引入获取单个连接的控制器 getConnectionById,
updateConnection, // 引入更新连接的控制器 updateConnection,
deleteConnection, // 引入删除连接的控制器 deleteConnection,
testConnection, // 引入测试连接的控制器 testConnection,
testUnsavedConnection, // 添加导入: 引入测试未保存连接的控制器 testUnsavedConnection,
exportConnections, // 引入导出连接的控制器 exportConnections,
importConnections // 引入导入连接的控制器 importConnections
} from './connections.controller'; } from './connections.controller';
const router = Router(); const router = Router();
@@ -20,14 +20,12 @@ const storage = multer.memoryStorage(); // 将文件存储在内存中作为 Buf
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 限制文件大小为 5MB 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') { if (file.mimetype === 'application/json') {
cb(null, true); cb(null, true);
} else { } 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 文件!'; (req as any).fileValidationError = '只允许上传 JSON 文件!';
cb(null, false); // Reject the file cb(null, false);
} }
} }
}); });
@@ -35,34 +33,27 @@ const upload = multer({
// 应用认证中间件到所有 /connections 路由 // 应用认证中间件到所有 /connections 路由
router.use(isAuthenticated); // 恢复认证检查 router.use(isAuthenticated); // 恢复认证检查
// --- Specific routes before parameterized routes ---
// GET /api/v1/connections/export - 导出连接配置 // GET /api/v1/connections/export - 导出连接配置
router.get('/export', exportConnections); router.get('/export', exportConnections);
// POST /api/v1/connections/import - 导入连接配置 // POST /api/v1/connections/import - 导入连接配置
router.post('/import', (req: Request, res: Response, next: NextFunction) => { router.post('/import', (req: Request, res: Response, next: NextFunction) => {
// Use multer middleware, but handle errors specifically
upload.single('connectionsFile')(req, res, (err: any) => { upload.single('connectionsFile')(req, res, (err: any) => {
// Check for file filter validation error first
if ((req as any).fileValidationError) { if ((req as any).fileValidationError) {
return res.status(400).json({ message: (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) { if (err instanceof multer.MulterError) {
return res.status(400).json({ message: `文件上传错误: ${err.message}` }); return res.status(400).json({ message: `文件上传错误: ${err.message}` });
} else if (err) { } else if (err) {
// Other unexpected errors during upload
console.error("Unexpected error during file upload:", err); console.error("Unexpected error during file upload:", err);
return res.status(500).json({ message: '文件上传处理失败' }); return res.status(500).json({ message: '文件上传处理失败' });
} }
// If no errors, proceed to the controller
next(); next();
}); });
}, importConnections); }, importConnections);
// --- General CRUD and other routes ---
// GET /api/v1/connections - 获取连接列表 // GET /api/v1/connections - 获取连接列表
router.get('/', getConnections); router.get('/', getConnections);
+38 -108
View File
@@ -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 path from 'path';
import fs from 'fs'; import fs from 'fs';
import * as schema from './schema';
// Import the table definitions registry instead of individual repositories here
import { tableDefinitions } from './schema.registry'; 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 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); 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)) { if (!fs.existsSync(dbDir)) {
// console.log(`[DB FS] Directory does not exist. Attempting to create: ${dbDir}`); // 移除调试日志
try { try {
fs.mkdirSync(dbDir, { recursive: true }); fs.mkdirSync(dbDir, { recursive: true });
// console.log(`[DB FS] Directory successfully created: ${dbDir}`); // 移除调试日志
} catch (mkdirErr: any) { } catch (mkdirErr: any) {
console.error(`[DB FS] Failed to create directory ${dbDir}:`, mkdirErr.message); console.error(`[数据库文件系统] 创建目录 ${dbDir} 失败:`, mkdirErr.message);
// Consider throwing error here to prevent proceeding if directory creation fails throw new Error(`创建数据库目录失败: ${mkdirErr.message}`);
throw new Error(`Failed to create database directory: ${mkdirErr.message}`);
} }
} else { } else {
// console.log(`[DB FS] Directory already exists: ${dbDir}`); // 移除调试日志
} }
const verboseSqlite3 = sqlite3.verbose(); const verboseSqlite3 = sqlite3.verbose();
let dbInstancePromise: Promise<sqlite3.Database> | null = null; let dbInstancePromise: Promise<sqlite3.Database> | null = null;
// --- Promisified Database Operations ---
interface RunResult { interface RunResult {
lastID: number; lastID: number;
changes: number; changes: number;
} }
/**
* Promisified version of db.run(). Resolves with { lastID, changes }.
*/
export const runDb = (db: sqlite3.Database, sql: string, params: any[] = []): Promise<RunResult> => { export const runDb = (db: sqlite3.Database, sql: string, params: any[] = []): Promise<RunResult> => {
return new Promise((resolve, reject) => { 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) { 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); reject(err);
} else { } else {
// 'this' context provides lastID and changes for INSERT/UPDATE/DELETE
resolve({ lastID: this.lastID, changes: this.changes }); 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> => { export const getDb = <T = any>(db: sqlite3.Database, sql: string, params: any[] = []): Promise<T | undefined> => {
return new Promise((resolve, reject) => { 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) { 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); reject(err);
} else { } 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[]> => { export const allDb = <T = any>(db: sqlite3.Database, sql: string, params: any[] = []): Promise<T[]> => {
return new Promise((resolve, reject) => { 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) { 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); reject(err);
} else { } 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> => { const runDatabaseInitializations = async (db: sqlite3.Database): Promise<void> => {
// console.log('[DB Init] 开始数据库初始化序列...'); // 移除调试日志
try { try {
// 1. Enable foreign key constraints await runDb(db, 'PRAGMA foreign_keys = ON;');
await runDb(db, 'PRAGMA foreign_keys = ON;'); // Use promisified runDb
// console.log('[DB Init] 外键约束已启用。'); // 移除调试日志
// 2. Create tables and run initializations based on the registry
for (const tableDef of tableDefinitions) { for (const tableDef of tableDefinitions) {
await runDb(db, tableDef.sql); // Create table (IF NOT EXISTS) await runDb(db, tableDef.sql);
// console.log(`[DB Init] ${tableDef.name} 表已存在或已创建。`); // 移除调试日志
if (tableDef.init) { if (tableDef.init) {
// Pass the db instance to the init function
await tableDef.init(db); 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) { } catch (error) {
console.error('[DB Init] 数据库初始化序列失败:', error); console.error('[DB Init] 数据库初始化序列失败:', error);
// Propagate the error to stop the application startup in index.ts
throw error; 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> => { export const getDbInstance = (): Promise<sqlite3.Database> => {
if (!dbInstancePromise) { if (!dbInstancePromise) {
dbInstancePromise = new Promise((resolve, reject) => { 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 const db = new verboseSqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, async (err) => { // Mark callback as async
// --- Strict Error Check FIRST ---
if (err) { if (err) {
console.error(`[DB Connection] Error opening database file ${dbPath}:`, err.message); console.error(`[数据库连接] 打开数据库文件 ${dbPath} 时出错:`, err.message);
// connectionFailed = true; // Remove flag setting dbInstancePromise = null;
dbInstancePromise = null; // Reset promise on error reject(err);
reject(err); // Reject the main promise return;
return; // Explicitly 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 { try {
// Wait for initializations to complete
await runDatabaseInitializations(db); await runDatabaseInitializations(db);
// console.log('[DB] Database initialization complete. Ready.'); // 移除调试日志 resolve(db);
resolve(db); // Resolve the main promise with the db instance
} catch (initError) { } catch (initError) {
console.error('[DB] Initialization failed after connection, closing connection...'); console.error('[数据库] 连接后初始化失败,正在关闭连接...');
// connectionFailed = true; // Remove flag setting dbInstancePromise = null;
dbInstancePromise = null; // Reset promise on error
db.close((closeErr) => { db.close((closeErr) => {
if (closeErr) console.error('[DB] Error closing connection after init failure:', closeErr.message); if (closeErr) console.error('[数据库] 初始化失败后关闭连接时出错:', closeErr.message);
reject(initError); // Reject with the initialization error reject(initError);
}); });
// process.exit(1); // Consider exiting on init failure
} }
}); });
}); });
@@ -184,15 +120,12 @@ export const getDbInstance = (): Promise<sqlite3.Database> => {
return dbInstancePromise; 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. process.on('SIGINT', async () => {
// For now, it checks the promise state indirectly.
process.on('SIGINT', async () => { // Mark as async if needed
if (dbInstancePromise) { if (dbInstancePromise) {
console.log('[DB] 收到 SIGINT,尝试关闭数据库连接...'); console.log('[DB] 收到 SIGINT,尝试关闭数据库连接...');
try { 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; const db = await dbInstancePromise;
db.close((err) => { db.close((err) => {
if (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.
+3 -10
View File
@@ -1,6 +1,5 @@
// packages/backend/src/migrations.ts
import { Database } from 'sqlite3'; import { Database } from 'sqlite3';
// import { getDb } from './database'; // 可能不再需要直接从这里获取 db
/** /**
* 运行数据库迁移。 * 运行数据库迁移。
@@ -11,14 +10,8 @@ import { Database } from 'sqlite3';
export const runMigrations = (db: Database): Promise<void> => { export const runMigrations = (db: Database): Promise<void> => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
console.log('[Migrations] 检查数据库迁移(当前无操作)。'); console.log('[Migrations] 检查数据库迁移(当前无操作)。');
// 在这里添加未来的迁移逻辑,例如: resolve();
// db.serialize(() => {
// db.run("ALTER TABLE users ADD COLUMN last_login INTEGER;", (err) => { ... });
// // 更多迁移步骤...
// });
resolve(); // 立即解决,因为没有迁移要运行
}); });
}; };
// 可以保留一个默认导出或根据需要移除
// export default runMigrations;
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { DockerService, DockerCommand } from '../services/docker.service'; // 导入服务和命令类型 import { DockerService, DockerCommand } from '../services/docker.service';
// 由于没有 typedi,我们将手动实例化服务或通过其他方式获取实例 // 由于没有 typedi,我们将手动实例化服务或通过其他方式获取实例
// 简单起见,这里直接 new 一个实例。在实际项目中,可能需要更复杂的实例管理。 // 简单起见,这里直接 new 一个实例。在实际项目中,可能需要更复杂的实例管理。
@@ -55,8 +55,6 @@ export class DockerController {
// 其他执行错误,可能是 Docker 守护进程错误等 // 其他执行错误,可能是 Docker 守护进程错误等
res.status(500).json({ message: error.message || 'Failed to execute Docker command.' }); // Internal Server Error res.status(500).json({ message: error.message || 'Failed to execute Docker command.' }); // Internal Server Error
} }
// 注意:这里没有调用 next(error),因为我们已经处理了响应。
// 如果希望使用统一的错误处理中间件,则应该调用 next(error)。
} }
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { DockerController } from './docker.controller';
import { isAuthenticated } from '../auth/auth.middleware'; import { isAuthenticated } from '../auth/auth.middleware';
const router = Router(); const router = Router();
const dockerController = new DockerController(); // 同样,手动实例化 const dockerController = new DockerController();
// 应用认证中间件,确保只有登录用户才能访问 Docker 相关接口 // 应用认证中间件,确保只有登录用户才能访问 Docker 相关接口
router.use(isAuthenticated); router.use(isAuthenticated);
+1 -4
View File
@@ -1,7 +1,7 @@
import i18next from 'i18next'; import i18next from 'i18next';
import Backend from 'i18next-fs-backend'; import Backend from 'i18next-fs-backend';
import path from 'path'; import path from 'path';
import fs from 'fs'; // 导入 fs 模块 import fs from 'fs';
// --- 动态确定支持的语言 --- // --- 动态确定支持的语言 ---
const localesDir = path.join(__dirname, 'locales'); const localesDir = path.join(__dirname, 'locales');
@@ -52,9 +52,6 @@ i18next
return console.error('[i18next] Error during initialization:', err); return console.error('[i18next] Error during initialization:', err);
} }
console.log('[i18next] Initialization complete. Loaded languages:', Object.keys(i18next.store.data)); 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; export default i18next;
+5 -10
View File
@@ -1,15 +1,13 @@
import express = require('express'); import express = require('express');
import { Request, Response, NextFunction, RequestHandler } from 'express'; import { Request, Response, NextFunction, RequestHandler } from 'express';
import http from 'http'; import http from 'http';
import fs from 'fs'; // 导入 fs 模块 import fs from 'fs';
import path from 'path'; // 导入 path 模块 import path from 'path';
import crypto from 'crypto'; // 导入 crypto 模块 import crypto from 'crypto';
import dotenv from 'dotenv'; // 导入 dotenv import dotenv from 'dotenv';
import session from 'express-session'; import session from 'express-session';
import sessionFileStore from 'session-file-store'; import sessionFileStore from 'session-file-store';
import bcrypt from 'bcrypt';
import { getDbInstance } from './database/connection'; import { getDbInstance } from './database/connection';
// import { runMigrations } from './database/migrations'; // Migrations are handled within getDbInstance
import authRouter from './auth/auth.routes'; import authRouter from './auth/auth.routes';
import connectionsRouter from './connections/connections.routes'; import connectionsRouter from './connections/connections.routes';
import sftpRouter from './sftp/sftp.routes'; import sftpRouter from './sftp/sftp.routes';
@@ -161,10 +159,7 @@ const startServer = () => {
proxy: true, // 信任反向代理设置的 X-Forwarded-Proto 头 proxy: true, // 信任反向代理设置的 X-Forwarded-Proto 头
cookie: { cookie: {
httpOnly: true, httpOnly: true,
// secure: 'auto' in newer versions, relies on proxy: true here secure: false,
// 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: 默认会话期
} }
}); });
app.use(sessionMiddleware); app.use(sessionMiddleware);
@@ -1,9 +1,9 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { NotificationSetting } from '../types/notification.types'; 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 { export class NotificationController {
private notificationService: NotificationService; private notificationService: NotificationService;
@@ -18,7 +18,6 @@ export class NotificationController {
const settings = await this.notificationService.getAllSettings(); const settings = await this.notificationService.getAllSettings();
res.status(200).json(settings); res.status(200).json(settings);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching notification settings:", error);
res.status(500).json({ message: '获取通知设置失败', error: error.message }); res.status(500).json({ message: '获取通知设置失败', error: error.message });
} }
}; };
@@ -27,7 +26,6 @@ export class NotificationController {
create = async (req: Request, res: Response): Promise<void> => { create = async (req: Request, res: Response): Promise<void> => {
const settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'> = req.body; 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) { if (!settingData.channel_type || !settingData.name || !settingData.config) {
res.status(400).json({ message: '缺少必要的通知设置字段 (channel_type, name, config)' }); res.status(400).json({ message: '缺少必要的通知设置字段 (channel_type, name, config)' });
return; return;
@@ -35,14 +33,13 @@ export class NotificationController {
try { try {
const newSettingId = await this.notificationService.createSetting(settingData); 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) { if (newSetting) {
auditLogService.logAction('NOTIFICATION_SETTING_CREATED', { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type }); auditLogService.logAction('NOTIFICATION_SETTING_CREATED', { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type });
} }
res.status(201).json(newSetting); res.status(201).json(newSetting);
} catch (error: any) { } catch (error: any) {
console.error("Error creating notification setting:", error);
res.status(500).json({ message: '创建通知设置失败', error: error.message }); res.status(500).json({ message: '创建通知设置失败', error: error.message });
} }
}; };
@@ -72,7 +69,6 @@ export class NotificationController {
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Error updating notification setting ID ${id}:`, error);
res.status(500).json({ message: '更新通知设置失败', error: error.message }); res.status(500).json({ message: '更新通知设置失败', error: error.message });
} }
}; };
@@ -96,7 +92,6 @@ export class NotificationController {
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Error deleting notification setting ID ${id}:`, error);
res.status(500).json({ message: '删除通知设置失败', error: error.message }); res.status(500).json({ message: '删除通知设置失败', error: error.message });
} }
}; };
@@ -104,7 +99,7 @@ export class NotificationController {
// POST /api/v1/notifications/:id/test // POST /api/v1/notifications/:id/test
testSetting = async (req: Request, res: Response): Promise<void> => { testSetting = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); 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)) { if (isNaN(id)) {
res.status(400).json({ message: '无效的通知设置 ID' }); res.status(400).json({ message: '无效的通知设置 ID' });
@@ -116,28 +111,24 @@ export class NotificationController {
} }
try { try {
// Fetch the original setting to determine the channel type
const originalSetting = await this.notificationService.getSettingById(id); const originalSetting = await this.notificationService.getSettingById(id);
if (!originalSetting) { if (!originalSetting) {
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); 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); const result = await this.notificationService.testSetting(originalSetting.channel_type, config);
if (result.success) { if (result.success) {
// 记录审计日志 (可选,根据需要决定是否记录测试操作) // 记录审计日志 (可选,根据需要决定是否记录测试操作)
// auditLogService.logAction('NOTIFICATION_SETTING_TESTED', { settingId: id, success: true });
res.status(200).json({ message: result.message }); res.status(200).json({ message: result.message });
} else { } 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 }); res.status(500).json({ message: result.message });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Error testing notification setting ID ${id}:`, error);
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
} }
}; };
@@ -158,17 +149,14 @@ export class NotificationController {
} }
try { try {
// Call the generic testSetting method directly with provided type and config
const result = await this.notificationService.testSetting(channel_type, config); const result = await this.notificationService.testSetting(channel_type, config);
if (result.success) { if (result.success) {
res.status(200).json({ message: result.message }); res.status(200).json({ message: result.message });
} else { } else {
// Return 500 for test failure to indicate an issue with the config/sending
res.status(500).json({ message: result.message }); res.status(500).json({ message: result.message });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Error testing unsaved notification setting:`, error);
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
} }
}; };
@@ -1,23 +1,22 @@
import { Router } from 'express'; import { Router } from 'express';
import { NotificationController } from './notification.controller'; import { NotificationController } from './notification.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // Corrected import name import { isAuthenticated } from '../auth/auth.middleware';
const router = Router(); const router = Router();
const notificationController = new NotificationController(); const notificationController = new NotificationController();
// Apply auth middleware to all notification routes
router.use(isAuthenticated); router.use(isAuthenticated);
// Define routes for notification settings CRUD
router.get('/', notificationController.getAll); router.get('/', notificationController.getAll);
router.post('/', notificationController.create); router.post('/', notificationController.create);
router.put('/:id', notificationController.update); router.put('/:id', notificationController.update);
router.delete('/:id', notificationController.delete); router.delete('/:id', notificationController.delete);
// Route for testing a saved notification setting
router.post('/:id/test', notificationController.testSetting); router.post('/:id/test', notificationController.testSetting);
// Route for testing an unsaved notification setting configuration
router.post('/test-unsaved', notificationController.testUnsavedSetting); router.post('/test-unsaved', notificationController.testUnsavedSetting);
export default router; export default router;
@@ -1,10 +1,10 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as ProxyService from '../services/proxy.service'; 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 => { const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial<ProxyService.ProxyData> | null => {
if (!proxy) return null; if (!proxy) return null;
const { encrypted_password, encrypted_private_key, encrypted_passphrase, ...sanitized } = proxy; 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) => { export const getAllProxies = async (req: Request, res: Response) => {
try { try {
const proxies = await ProxyService.getAllProxies(); const proxies = await ProxyService.getAllProxies();
// Sanitize each proxy before sending
res.status(200).json(proxies.map(sanitizeProxy)); res.status(200).json(proxies.map(sanitizeProxy));
} catch (error: any) { } catch (error: any) {
console.error('Controller: 获取代理列表失败:', error); console.error('Controller: 获取代理列表失败:', error);
@@ -47,7 +46,6 @@ export const getProxyById = async (req: Request, res: Response) => {
// 创建新的代理配置 // 创建新的代理配置
export const createProxy = async (req: Request, res: Response) => { export const createProxy = async (req: Request, res: Response) => {
try { try {
// Basic validation (more in service)
const { name, type, host, port } = req.body; const { name, type, host, port } = req.body;
if (!name || !type || !host || !port) { if (!name || !type || !host || !port) {
return res.status(400).json({ message: '缺少必要的代理信息 (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 }); auditLogService.logAction('PROXY_CREATED', { proxyId: newProxy.id, name: newProxy.name, type: newProxy.type });
res.status(201).json({ res.status(201).json({
message: '代理创建成功', message: '代理创建成功',
proxy: sanitizeProxy(newProxy) // Return sanitized proxy proxy: sanitizeProxy(newProxy)
}); });
} catch (error: any) { } catch (error: any) {
@@ -85,7 +83,6 @@ export const updateProxy = async (req: Request, res: Response) => {
return res.status(400).json({ message: '无效的代理 ID' }); 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; 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) { if (!name && !type && !host && port === undefined && username === undefined && password === undefined && auth_method === undefined && private_key === undefined && passphrase === undefined) {
return res.status(400).json({ message: '没有提供任何要更新的字段' }); 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); const updatedProxy = await ProxyService.updateProxy(proxyId, req.body);
if (updatedProxy) { if (updatedProxy) {
// 记录审计日志
auditLogService.logAction('PROXY_UPDATED', { proxyId, updatedFields: Object.keys(req.body) }); auditLogService.logAction('PROXY_UPDATED', { proxyId, updatedFields: Object.keys(req.body) });
res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) }); res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) });
} else { } else {
@@ -1,4 +1,4 @@
import express, { RequestHandler } from 'express'; // 引入 RequestHandler import express, { RequestHandler } from 'express';
import { isAuthenticated } from '../auth/auth.middleware'; import { isAuthenticated } from '../auth/auth.middleware';
import { import {
getAllProxies, getAllProxies,
@@ -6,19 +6,18 @@ import {
createProxy, createProxy,
updateProxy, updateProxy,
deleteProxy deleteProxy
} from './proxies.controller'; // 引入控制器函数 } from './proxies.controller';
const router = express.Router(); const router = express.Router();
// 应用认证中间件到所有代理路由
router.use(isAuthenticated); router.use(isAuthenticated);
// 定义代理 CRUD 路由
// 显式类型断言以解决潜在的类型不匹配问题
router.get('/', getAllProxies as RequestHandler); router.get('/', getAllProxies as RequestHandler);
router.get('/:id', getProxyById as RequestHandler); router.get('/:id', getProxyById as RequestHandler);
router.post('/', createProxy as RequestHandler); router.post('/', createProxy as RequestHandler);
router.put('/:id', updateProxy as RequestHandler); // 类型断言 router.put('/:id', updateProxy as RequestHandler);
router.delete('/:id', deleteProxy as RequestHandler); // 类型断言 router.delete('/:id', deleteProxy as RequestHandler);
export default router; export default router;
@@ -120,8 +120,6 @@ export const incrementUsage = async (req: Request, res: Response): Promise<void>
// 即使没找到也可能返回成功,避免不必要的错误提示 // 即使没找到也可能返回成功,避免不必要的错误提示
console.warn(`尝试增加不存在的快捷指令 (ID: ${id}) 的使用次数`); console.warn(`尝试增加不存在的快捷指令 (ID: ${id}) 的使用次数`);
res.status(200).json({ message: '使用次数已记录 (或指令不存在)' }); res.status(200).json({ message: '使用次数已记录 (或指令不存在)' });
// 或者严格一点返回 404:
// res.status(404).json({ message: '未找到要增加使用次数的快捷指令' });
} }
} catch (error: any) { } catch (error: any) {
console.error('增加快捷指令使用次数控制器出错:', error); console.error('增加快捷指令使用次数控制器出错:', error);
@@ -1,10 +1,10 @@
import { Router } from 'express'; import { Router } from 'express';
import * as QuickCommandsController from './quick-commands.controller'; import * as QuickCommandsController from './quick-commands.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件 import { isAuthenticated } from '../auth/auth.middleware';
const router = Router(); const router = Router();
// 应用认证中间件到所有快捷指令相关的路由
router.use(isAuthenticated); 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 { getDbInstance, runDb, getDb, allDb } from '../database/connection';
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types'; import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
import { defaultUiTheme } from '../config/default-themes'; import { defaultUiTheme } from '../config/default-themes';
// Import findThemeById from terminal theme repository for validation
import { findThemeById as findTerminalThemeById } from './terminal-theme.repository'; 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'; 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 { interface DbAppearanceSettingsRow {
key: string; key: string;
value: string; value: string;
@@ -19,13 +14,13 @@ interface DbAppearanceSettingsRow {
updated_at: number; updated_at: number;
} }
// Helper function to map DB rows (key-value pairs) to AppearanceSettings object
const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): AppearanceSettings => { const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): AppearanceSettings => {
const settings: Partial<AppearanceSettings> = {}; const settings: Partial<AppearanceSettings> = {};
let latestUpdatedAt = 0; let latestUpdatedAt = 0;
for (const row of rows) { for (const row of rows) {
// Update latestUpdatedAt // 更新 latestUpdatedAt
if (row.updated_at > latestUpdatedAt) { if (row.updated_at > latestUpdatedAt) {
latestUpdatedAt = row.updated_at; latestUpdatedAt = row.updated_at;
} }
@@ -35,7 +30,6 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
settings.customUiTheme = row.value; settings.customUiTheme = row.value;
break; break;
case 'activeTerminalThemeId': case 'activeTerminalThemeId':
// Ensure value is parsed as number or null
const parsedId = parseInt(row.value, 10); const parsedId = parseInt(row.value, 10);
settings.activeTerminalThemeId = isNaN(parsedId) ? null : parsedId; settings.activeTerminalThemeId = isNaN(parsedId) ? null : parsedId;
break; break;
@@ -49,19 +43,17 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
settings.editorFontSize = parseInt(row.value, 10); settings.editorFontSize = parseInt(row.value, 10);
break; break;
case 'terminalBackgroundImage': case 'terminalBackgroundImage':
settings.terminalBackgroundImage = row.value || undefined; // Use undefined if empty string settings.terminalBackgroundImage = row.value || undefined;
break; break;
case 'pageBackgroundImage': case 'pageBackgroundImage':
settings.pageBackgroundImage = row.value || undefined; // Use undefined if empty string settings.pageBackgroundImage = row.value || undefined;
break; break;
// Add cases for other potential keys if needed
} }
} }
// Merge with defaults for any missing keys and add _id and updatedAt const defaults = getDefaultAppearanceSettings();
const defaults = getDefaultAppearanceSettings(); // Get defaults
return { return {
_id: 'global_appearance', // Use a fixed string ID for the conceptual global settings _id: 'global_appearance', // 全局外观设置的固定 ID
customUiTheme: settings.customUiTheme ?? defaults.customUiTheme, customUiTheme: settings.customUiTheme ?? defaults.customUiTheme,
activeTerminalThemeId: settings.activeTerminalThemeId ?? defaults.activeTerminalThemeId, activeTerminalThemeId: settings.activeTerminalThemeId ?? defaults.activeTerminalThemeId,
terminalFontFamily: settings.terminalFontFamily ?? defaults.terminalFontFamily, terminalFontFamily: settings.terminalFontFamily ?? defaults.terminalFontFamily,
@@ -69,59 +61,60 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
editorFontSize: settings.editorFontSize ?? defaults.editorFontSize, editorFontSize: settings.editorFontSize ?? defaults.editorFontSize,
terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage, terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage,
pageBackgroundImage: settings.pageBackgroundImage ?? defaults.pageBackgroundImage, 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'> => { const getDefaultAppearanceSettings = (): Omit<AppearanceSettings, '_id'> => {
return { return {
customUiTheme: JSON.stringify(defaultUiTheme), customUiTheme: JSON.stringify(defaultUiTheme),
activeTerminalThemeId: null, // Default should be null initially activeTerminalThemeId: null, // 初始默认应为 null
terminalFontFamily: 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"', terminalFontFamily: 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"',
terminalFontSize: 14, terminalFontSize: 14,
editorFontSize: 14, editorFontSize: 14,
terminalBackgroundImage: undefined, terminalBackgroundImage: undefined,
pageBackgroundImage: 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> => { export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<void> => {
const defaults = getDefaultAppearanceSettings(); const defaults = getDefaultAppearanceSettings();
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
const sqlInsertOrIgnore = `INSERT OR IGNORE INTO ${TABLE_NAME} (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)`; 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 }> = [ const defaultEntries: Array<{ key: keyof Omit<AppearanceSettings, '_id' | 'updatedAt'>, value: any }> = [
{ key: 'customUiTheme', value: defaults.customUiTheme }, { key: 'customUiTheme', value: defaults.customUiTheme },
{ key: 'activeTerminalThemeId', value: null }, // Start with null { key: 'activeTerminalThemeId', value: null }, // null 开始
{ key: 'terminalFontFamily', value: defaults.terminalFontFamily }, { key: 'terminalFontFamily', value: defaults.terminalFontFamily },
{ key: 'terminalFontSize', value: defaults.terminalFontSize }, { key: 'terminalFontSize', value: defaults.terminalFontSize },
{ key: 'editorFontSize', value: defaults.editorFontSize }, { key: 'editorFontSize', value: defaults.editorFontSize },
{ key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // Use empty string for DB { key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // 数据库中使用空字符串
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // Use empty string for DB { key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
]; ];
try { try {
for (const entry of defaultEntries) { for (const entry of defaultEntries) {
// Convert value to string for DB storage, handle null/undefined // 将值转换为字符串以存储到数据库,处理 null/undefined
let dbValue: string; let dbValue: string;
if (entry.value === null || entry.value === undefined) { 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') { } else if (typeof entry.value === 'object') {
dbValue = JSON.stringify(entry.value); dbValue = JSON.stringify(entry.value);
} else { } else {
dbValue = String(entry.value); 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') { if (entry.key === 'activeTerminalThemeId') {
dbValue = entry.value === null ? 'null' : String(entry.value); 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]); 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); await findAndSetDefaultThemeIdIfNull(db);
} catch (err: any) { } 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. * 查找默认终端主题 ID,并在 'activeTerminalThemeId' 设置当前为 null 时更新它。
* @param db - The active database instance * @param db - 活动的数据库实例
*/ */
const findAndSetDefaultThemeIdIfNull = async (db: sqlite3.Database): Promise<void> => { const findAndSetDefaultThemeIdIfNull = async (db: sqlite3.Database): Promise<void> => {
try { try {
// Check the current value of activeTerminalThemeId // 检查 activeTerminalThemeId 的当前值
const currentSetting = await getDb<{ value: string }>(db, `SELECT value FROM ${TABLE_NAME} WHERE key = ?`, ['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') { 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 defaultThemeSql = `SELECT id FROM terminal_themes WHERE name = 'default' AND theme_type = 'preset' LIMIT 1`;
const defaultThemeRow = await getDb<{ id: number }>(db, defaultThemeSql); const defaultThemeRow = await getDb<{ id: number }>(db, defaultThemeSql);
if (defaultThemeRow) { if (defaultThemeRow) {
const defaultThemeIdNum = defaultThemeRow.id; const defaultThemeIdNum = defaultThemeRow.id;
console.log(`[AppearanceRepo] activeTerminalThemeId 为 null,尝试设置为默认主题 ID: ${defaultThemeIdNum}`); // console.log(`[AppearanceRepo] activeTerminalThemeId 为 null,尝试设置为默认主题 ID: ${defaultThemeIdNum}`); // 移除:信息不太关键
// Update the setting using INSERT OR REPLACE // 使用 INSERT OR REPLACE 更新设置
const sqlReplace = `INSERT OR REPLACE INTO ${TABLE_NAME} (key, value, updated_at) VALUES (?, ?, ?)`; 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)]); await runDb(db, sqlReplace, ['activeTerminalThemeId', String(defaultThemeIdNum), Math.floor(Date.now() / 1000)]);
} else { } else {
console.warn("[AppearanceRepo] 未找到名为 'default' 的预设终端主题,无法设置默认 activeTerminalThemeId。"); console.warn("[AppearanceRepo] 未找到名为 'default' 的预设终端主题,无法设置默认 activeTerminalThemeId。");
} }
} else {
// console.log(`[AppearanceRepo] activeTerminalThemeId 已设置 (${currentSetting?.value}) 或键不存在,跳过设置默认 ID。`);
} }
// 如果 activeTerminalThemeId 已设置或键不存在,则不执行任何操作
} catch (error: any) { } catch (error: any) {
console.error("[AppearanceRepo] 设置默认终端主题 ID 时出错:", error.message); 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> => { export const getAppearanceSettings = async (): Promise<AppearanceSettings> => {
try { try {
const db = await getDbInstance(); 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}`); 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) { } catch (err: any) {
console.error('获取外观设置失败:', err.message); console.error('[AppearanceRepo] 获取外观设置失败:', err.message);
throw new Error('获取外观设置失败'); throw new Error('获取外观设置失败');
} }
}; };
/** /**
* 更新外观设置 (Public API) * 更新外观设置 (公共 API)
* @param settingsDto 更新的数据 * 接收一个包含要更新设置的 DTO,执行必要的验证,然后调用内部更新函数。
* @returns Promise<boolean> 是否成功更新 * @param {UpdateAppearanceDto} settingsDto - 包含要更新设置的对象。
* @returns {Promise<boolean>} 如果至少有一个设置被成功更新或插入,则返回 true,否则返回 false。
* @throws {Error} 如果验证失败或内部更新过程中发生错误。
*/ */
export const updateAppearanceSettings = async (settingsDto: UpdateAppearanceDto): Promise<boolean> => { export const updateAppearanceSettings = async (settingsDto: UpdateAppearanceDto): Promise<boolean> => {
const db = await getDbInstance(); 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) { if (settingsDto.activeTerminalThemeId !== undefined && settingsDto.activeTerminalThemeId !== null) {
try { try {
const themeExists = await findTerminalThemeById(settingsDto.activeTerminalThemeId); const themeExists = await findTerminalThemeById(settingsDto.activeTerminalThemeId);
@@ -210,18 +206,20 @@ export const updateAppearanceSettings = async (settingsDto: UpdateAppearanceDto)
throw new Error(`验证主题 ID 失败: ${validationError.message}`); throw new Error(`验证主题 ID 失败: ${validationError.message}`);
} }
} }
// ... other validations ... // ... 其他验证 ...
return updateAppearanceSettingsInternal(db, settingsDto); return updateAppearanceSettingsInternal(db, settingsDto);
}; };
/** /**
* 内部更新外观设置函数 (供内部调用,如初始化) * 内部更新外观设置函数 (供内部调用,例如在初始化或公共 API 中)。
* @param db - Active database instance * 此函数直接与数据库交互,使用 INSERT OR REPLACE 来更新或插入键值对。
* @param settingsDto - Data to update * @param {sqlite3.Database} db - 活动的数据库实例。
* @returns Promise<boolean> - Success status * @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 updateAppearanceSettingsInternal = async (db: sqlite3.Database, settingsDto: UpdateAppearanceDto): Promise<boolean> => {
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
const sqlReplace = `INSERT OR REPLACE INTO ${TABLE_NAME} (key, value, updated_at) VALUES (?, ?, ?)`; 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]; const value = settingsDto[key];
let dbValue: string; let dbValue: string;
// Convert value to string for DB, handle null/undefined // 将值转换为字符串以存储到数据库,处理 null/undefined
if (value === null || value === 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') { } else if (typeof value === 'object') {
dbValue = JSON.stringify(value); dbValue = JSON.stringify(value);
} else { } else {
dbValue = String(value); dbValue = String(value);
} }
// Special handling for activeTerminalThemeId to store 'null' string or number string // activeTerminalThemeId 的特殊处理:存储 'null' 字符串或数字字符串
if (key === 'activeTerminalThemeId') { if (key === 'activeTerminalThemeId') {
dbValue = value === null ? 'null' : String(value); 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') { if (key === 'activeTerminalThemeId' && value !== null && typeof value !== 'number') {
console.error(`[AppearanceRepo] 更新 activeTerminalThemeId 时收到无效类型值: ${value} (类型: ${typeof value}),应为数字或 null。跳过此字段。`); 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]); const result = await runDb(db, sqlReplace, [key, dbValue, nowSeconds]);
if (result.changes > 0) { if (result.changes > 0) {
changesMade = true; changesMade = true;
} }
} }
console.log(`[AppearanceRepo] 更新外观设置完成。是否有更改: ${changesMade}`); return changesMade; // 如果有任何行被插入或替换,则返回 true
return changesMade; // Return true if any row was inserted or replaced
} catch (err: any) { } catch (err: any) {
console.error('更新外观设置失败:', err.message); console.error('[AppearanceRepo] 更新外观设置失败:', err.message);
throw new Error('更新外观设置失败'); throw new Error('更新外观设置失败');
} }
}; };
@@ -1,30 +1,27 @@
// packages/backend/src/repositories/audit.repository.ts
import { Database } from 'sqlite3'; import { Database } from 'sqlite3';
// Import new async helpers and the instance getter
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { AuditLogEntry, AuditLogActionType } from '../types/audit.types'; import { AuditLogEntry, AuditLogActionType } from '../types/audit.types';
// Define the expected row structure from the database if it matches AuditLogEntry
type DbAuditLogRow = AuditLogEntry; type DbAuditLogRow = AuditLogEntry;
export class AuditLogRepository { export class AuditLogRepository {
// Remove constructor or leave it empty
// constructor() { }
/** /**
* 添加一条审计日志记录 * 添加一条审计日志记录
* @param actionType 操作类型 * @param actionType 操作类型
* @param details 可选的详细信息 (对象或字符串) * @param details 可选的详细信息对象或字符串)。
*/ */
async addLog(actionType: AuditLogActionType, details?: Record<string, any> | string | null): Promise<void> { 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; let detailsString: string | null = null;
if (details) { if (details) {
try { try {
detailsString = typeof details === 'string' ? details : JSON.stringify(details); detailsString = typeof details === 'string' ? details : JSON.stringify(details);
} catch (error: any) { } 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 detailsString = JSON.stringify({ error: 'Failed to stringify details', originalDetails: String(details) }); // Ensure originalDetails is stringifiable
} }
} }
@@ -35,22 +32,20 @@ export class AuditLogRepository {
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
await runDb(db, sql, params); await runDb(db, sql, params);
// console.log(`[Audit Log] Logged action: ${actionType}`); // Optional: verbose logging
// --- 添加日志清理逻辑 --- // --- 添加日志清理逻辑 ---
await this.cleanupOldLogs(db); await this.cleanupOldLogs(db);
// --- 清理逻辑结束 --- // --- 清理逻辑结束 ---
} catch (err: any) { } catch (err: any) {
console.error(`[Audit Log] Error adding log entry for action ${actionType}: ${err.message}`); console.error(`[审计日志] 添加操作 ${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
} }
} }
/** /**
* 清理旧的审计日志,保持最多 MAX_LOG_ENTRIES 条记录 * 清理旧的审计日志,保持最多 MAX_LOG_ENTRIES 条记录
* @param db - 数据库实例 * @param db - 数据库实例
*/ */
private async cleanupOldLogs(db: Database): Promise<void> { private async cleanupOldLogs(db: Database): Promise<void> {
const MAX_LOG_ENTRIES = 50000; // 设置最大日志条数 const MAX_LOG_ENTRIES = 50000; // 设置最大日志条数
@@ -71,24 +66,23 @@ export class AuditLogRepository {
if (total > MAX_LOG_ENTRIES) { if (total > MAX_LOG_ENTRIES) {
const logsToDelete = 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]); await runDb(db, deleteSql, [logsToDelete]);
console.log(`[Audit Log] Successfully deleted ${logsToDelete} oldest log entries.`);
} }
} catch (err: any) { } catch (err: any) {
console.error(`[Audit Log] Error during log cleanup: ${err.message}`); console.error(`[审计日志] 日志清理过程中出错: ${err.message}`);
// 清理失败不应阻止主日志记录流程,仅记录错误 // 清理失败不应阻止主日志记录流程,仅记录错误
} }
} }
/** /**
* 获取审计日志列表 (支持分页和基本过滤) * 获取审计日志列表支持分页和基本过滤)。
* @param limit 每页数量 * @param limit 每页数量
* @param offset 偏移量 * @param offset 偏移量
* @param actionType 可选的操作类型过滤 * @param actionType 可选的操作类型过滤
* @param startDate 可选的开始时间戳 (秒) * @param startDate 可选的开始时间戳(秒)。
* @param endDate 可选的结束时间戳 (秒) * @param endDate 可选的结束时间戳(秒)。
* @param searchTerm 可选的搜索关键词 (模糊匹配 details) * @param searchTerm 可选的搜索关键词模糊匹配 details)。
*/ */
async getLogs( async getLogs(
limit: number = 50, limit: number = 50,
@@ -98,8 +92,7 @@ export class AuditLogRepository {
endDate?: number, endDate?: number,
searchTerm?: string // 添加 searchTerm 参数 searchTerm?: string // 添加 searchTerm 参数
): Promise<{ logs: AuditLogEntry[], total: number }> { ): Promise<{ logs: AuditLogEntry[], total: number }> {
console.log(`[Audit Repo] getLogs called with: actionType=${actionType}, searchTerm=${searchTerm}`); // 添加日志
let baseSql = 'SELECT * FROM audit_logs'; let baseSql = 'SELECT * FROM audit_logs';
let countSql = 'SELECT COUNT(*) as total FROM audit_logs'; let countSql = 'SELECT COUNT(*) as total FROM audit_logs';
const whereClauses: string[] = []; const whereClauses: string[] = [];
@@ -107,14 +100,12 @@ export class AuditLogRepository {
const countParams: (string | number)[] = []; const countParams: (string | number)[] = [];
if (actionType) { if (actionType) {
console.log(`[Audit Repo] Filtering by actionType: ${actionType}`); // 添加日志
whereClauses.push('action_type = ?'); whereClauses.push('action_type = ?');
params.push(actionType); params.push(actionType);
countParams.push(actionType); countParams.push(actionType);
} }
// 添加 searchTerm 的过滤逻辑 // 添加 searchTerm 的过滤逻辑
if (searchTerm) { if (searchTerm) {
console.log(`[Audit Repo] Filtering by searchTerm: ${searchTerm}`); // 添加日志
// 搜索 details 字段,使用 LIKE 进行模糊匹配 // 搜索 details 字段,使用 LIKE 进行模糊匹配
whereClauses.push('details LIKE ?'); whereClauses.push('details LIKE ?');
const searchTermLike = `%${searchTerm}%`; const searchTermLike = `%${searchTerm}%`;
@@ -132,25 +123,20 @@ export class AuditLogRepository {
baseSql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'; baseSql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?';
params.push(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 { try {
const db = await getDbInstance(); const db = await getDbInstance();
// First get the total count
const countRow = await getDbRow<{ total: number }>(db, countSql, countParams); const countRow = await getDbRow<{ total: number }>(db, countSql, countParams);
const total = countRow?.total ?? 0; const total = countRow?.total ?? 0;
// Then get the paginated logs
const logs = await allDb<DbAuditLogRow>(db, baseSql, params); const logs = await allDb<DbAuditLogRow>(db, baseSql, params);
return { logs, total }; return { logs, total };
} catch (err: any) { } catch (err: any) {
console.error(`Error fetching audit logs:`, err.message); console.error(`获取审计日志时出错:`, err.message);
throw new Error(`Error fetching audit logs: ${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 { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; // Import new async helpers
// 定义命令历史记录的接口 // 定义命令历史记录的接口
export interface CommandHistoryEntry { export interface CommandHistoryEntry {
@@ -8,7 +7,6 @@ export interface CommandHistoryEntry {
timestamp: number; // Unix 时间戳 (秒) timestamp: number; // Unix 时间戳 (秒)
} }
// Define the expected row structure from the database if it matches CommandHistoryEntry
type DbCommandHistoryRow = CommandHistoryEntry; type DbCommandHistoryRow = CommandHistoryEntry;
/** /**
@@ -94,7 +92,7 @@ export const clearAllCommands = async (): Promise<number> => {
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql); const result = await runDb(db, sql);
return result.changes; // Return the number of deleted rows return result.changes;
} catch (err: any) { } catch (err: any) {
console.error('清空命令历史记录时出错:', err.message); console.error('清空命令历史记录时出错:', err.message);
throw new Error('无法清空命令历史记录'); throw new Error('无法清空命令历史记录');
@@ -1,16 +1,12 @@
// packages/backend/src/repositories/connection.repository.ts import { Database } from 'sqlite3';
import { Database, Statement } from 'sqlite3';
// Import new async helpers and the instance getter
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// Remove top-level db instance
// const db = getDb();
// Define Connection 类型 (可以从 controller 或 types 文件导入,暂时在此定义) // Define Connection 类型 (可以从 controller 或 types 文件导入,暂时在此定义)
// 注意:这里不包含加密字段,因为 Repository 不应处理解密 // 注意:这里不包含加密字段,因为 Repository 不应处理解密
interface ConnectionBase { interface ConnectionBase {
id: number; id: number;
name: string | null; // 允许 name 为 null name: string | null;
host: string; host: string;
port: number; port: number;
username: string; username: string;
@@ -21,9 +17,8 @@ interface ConnectionBase {
last_connected_at: number | null; last_connected_at: number | null;
} }
// Type for the result of the JOIN query in findAllConnectionsWithTags and findConnectionByIdWithTags
interface ConnectionWithTagsRow extends ConnectionBase { interface ConnectionWithTagsRow extends ConnectionBase {
tag_ids_str: string | null; // Raw string from GROUP_CONCAT tag_ids_str: string | null;
} }
export interface ConnectionWithTags extends ConnectionBase { export interface ConnectionWithTags extends ConnectionBase {
@@ -35,12 +30,10 @@ export interface FullConnectionData extends ConnectionBase {
encrypted_password?: string | null; encrypted_password?: string | null;
encrypted_private_key?: string | null; encrypted_private_key?: string | null;
encrypted_passphrase?: string | null; encrypted_passphrase?: string | null;
// Include tag_ids for creation/update convenience if needed, handled separately
tag_ids?: number[]; 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 { interface FullConnectionDbRow extends FullConnectionData {
proxy_db_id: number | null; proxy_db_id: number | null;
proxy_name: string | null; proxy_name: string | null;
@@ -70,7 +63,6 @@ export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const rows = await allDb<ConnectionWithTagsRow>(db, sql); const rows = await allDb<ConnectionWithTagsRow>(db, sql);
// Safely map rows, handling potential null tag_ids_str
return rows.map(row => ({ return rows.map(row => ({
...row, ...row,
tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] 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 { try {
const db = await getDbInstance(); const db = await getDbInstance();
const row = await getDbRow<ConnectionWithTagsRow>(db, sql, [id]); 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 { return {
...row, ...row,
tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] 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 { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, params); const result = await runDb(db, sql, params);
// Ensure lastID is valid before returning
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('创建连接后未能获取有效的 lastID'); throw new Error('创建连接后未能获取有效的 lastID');
} }
@@ -176,7 +167,7 @@ export const updateConnection = async (id: number, data: Partial<Omit<FullConnec
delete fieldsToUpdate.id; delete fieldsToUpdate.id;
delete fieldsToUpdate.created_at; delete fieldsToUpdate.created_at;
delete fieldsToUpdate.last_connected_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); 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 = ?`; const sql = `DELETE FROM connections WHERE id = ?`;
try { try {
const db = await getDbInstance(); 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]); const result = await runDb(db, sql, [id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } 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> => { export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise<void> => {
const db = await getDbInstance(); const db = await getDbInstance();
// Use a transaction to ensure atomicity
try { try {
await runDb(db, 'BEGIN TRANSACTION'); await runDb(db, 'BEGIN TRANSACTION');
// 1. Delete old associations
await runDb(db, `DELETE FROM connection_tags WHERE connection_id = ?`, [connectionId]); await runDb(db, `DELETE FROM connection_tags WHERE connection_id = ?`, [connectionId]);
// 2. Insert new associations (if any)
if (tagIds.length > 0) { if (tagIds.length > 0) {
const insertSql = `INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`; 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 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 => { .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}`); console.warn(`Repository: 更新连接 ${connectionId} 标签时,插入 tag_id ${tagId} 失败: ${err.message}`);
})); }));
await Promise.all(insertPromises); await Promise.all(insertPromises);
@@ -269,21 +256,20 @@ export const updateConnectionTags = async (connectionId: number, tagIds: number[
} catch (err: any) { } catch (err: any) {
console.error(`Repository: 更新连接 ${connectionId} 的标签关联时出错:`, err.message); console.error(`Repository: 更新连接 ${connectionId} 的标签关联时出错:`, err.message);
try { try {
await runDb(db, 'ROLLBACK'); // Attempt to rollback on error await runDb(db, 'ROLLBACK');
} catch (rollbackErr: any) { } catch (rollbackErr: any) {
console.error(`Repository: 回滚连接 ${connectionId} 的标签更新事务失败:`, rollbackErr.message); 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 ( 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[] }> connections: Array<Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }>
): Promise<{ connectionId: number, originalData: any }[]> => { ): Promise<{ connectionId: number, originalData: any }[]> => {
@@ -291,8 +277,6 @@ export const bulkInsertConnections = async (
const results: { connectionId: number, originalData: any }[] = []; const results: { connectionId: number, originalData: any }[] = [];
const now = Math.floor(Date.now() / 1000); 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) { for (const connData of connections) {
const params = [ const params = [
@@ -304,19 +288,15 @@ export const bulkInsertConnections = async (
now, now now, now
]; ];
try { try {
// Use the passed db instance (which should be in a transaction)
const connResult = await runDb(db, insertConnSql, params); const connResult = await runDb(db, insertConnSql, params);
if (typeof connResult.lastID !== 'number' || connResult.lastID <= 0) { if (typeof connResult.lastID !== 'number' || connResult.lastID <= 0) {
throw new Error(`插入连接 "${connData.name}" 后未能获取有效的 lastID`); throw new Error(`插入连接 "${connData.name}" 后未能获取有效的 lastID`);
} }
results.push({ connectionId: connResult.lastID, originalData: connData }); results.push({ connectionId: connResult.lastID, originalData: connData });
} catch (err: any) { } 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}`); console.error(`Repository: 批量插入连接 "${connData.name}" 时出错: ${err.message}`);
// Decide on error handling strategy for batch operations throw new Error(`批量插入连接 "${connData.name}" 失败`);
throw new Error(`批量插入连接 "${connData.name}" 失败`); // Fail fast for now
} }
} }
return results; 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 { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { NotificationSetting, RawNotificationSetting, NotificationChannelType, NotificationEvent, NotificationChannelConfig } from '../types/notification.types'; import { NotificationSetting, RawNotificationSetting, NotificationChannelType, NotificationEvent, NotificationChannelConfig } from '../types/notification.types';
// Helper to parse raw data from DB
const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => { const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => {
try { try {
return { return {
@@ -13,20 +10,18 @@ const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => {
config: JSON.parse(raw.config || '{}'), config: JSON.parse(raw.config || '{}'),
enabled_events: JSON.parse(raw.enabled_events || '[]'), enabled_events: JSON.parse(raw.enabled_events || '[]'),
}; };
} catch (error: any) { // Add type annotation } catch (error: any) {
console.error(`Error parsing notification setting ID ${raw.id}:`, error.message); console.error(`解析通知设置 ID ${raw.id} 时出错:`, error.message);
return { return {
...raw, ...raw,
enabled: Boolean(raw.enabled), enabled: Boolean(raw.enabled),
config: {} as NotificationChannelConfig, // Indicate parsing error config: {} as NotificationChannelConfig,
enabled_events: [], enabled_events: [],
}; };
} }
}; };
export class NotificationSettingsRepository { export class NotificationSettingsRepository {
// Remove constructor or leave it empty
// constructor() { }
async getAll(): Promise<NotificationSetting[]> { async getAll(): Promise<NotificationSetting[]> {
try { try {
@@ -34,8 +29,8 @@ export class NotificationSettingsRepository {
const rows = await allDb<RawNotificationSetting>(db, 'SELECT * FROM notification_settings ORDER BY created_at ASC'); const rows = await allDb<RawNotificationSetting>(db, 'SELECT * FROM notification_settings ORDER BY created_at ASC');
return rows.map(parseRawSetting); return rows.map(parseRawSetting);
} catch (err: any) { } catch (err: any) {
console.error(`Error fetching notification settings:`, err.message); console.error(`获取通知设置时出错:`, err.message);
throw new Error(`Error fetching notification settings: ${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]); const row = await getDbRow<RawNotificationSetting>(db, 'SELECT * FROM notification_settings WHERE id = ?', [id]);
return row ? parseRawSetting(row) : null; return row ? parseRawSetting(row) : null;
} catch (err: any) { } catch (err: any) {
console.error(`Error fetching notification setting by ID ${id}:`, err.message); console.error(`通过 ID ${id} 获取通知设置时出错:`, err.message);
throw new Error(`Error fetching notification setting by ID ${id}: ${err.message}`); throw new Error(`通过 ID ${id} 获取通知设置时出错: ${err.message}`);
} }
} }
async getEnabledByEvent(event: NotificationEvent): Promise<NotificationSetting[]> { async getEnabledByEvent(event: NotificationEvent): Promise<NotificationSetting[]> {
// Note: Query remains inefficient, consider optimization later if needed.
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const rows = await allDb<RawNotificationSetting>(db, 'SELECT * FROM notification_settings WHERE enabled = 1'); 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)); const filteredRows = parsedRows.filter(setting => setting.enabled_events.includes(event));
return filteredRows; return filteredRows;
} catch (err: any) { } catch (err: any) {
console.error(`Error fetching enabled notification settings:`, err.message); console.error(`获取启用的通知设置时出错:`, err.message);
throw new Error(`Error fetching enabled notification settings: ${err.message}`); throw new Error(`获取启用的通知设置时出错: ${err.message}`);
} }
} }
@@ -68,10 +62,10 @@ export class NotificationSettingsRepository {
const sql = ` const sql = `
INSERT INTO notification_settings (channel_type, name, enabled, config, enabled_events, created_at, updated_at) INSERT INTO notification_settings (channel_type, name, enabled, config, enabled_events, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')) VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
`; // Added created_at, updated_at `;
const params = [ const params = [
setting.channel_type, setting.channel_type,
setting.name ?? '', // Ensure name is not undefined setting.name ?? '',
setting.enabled ? 1 : 0, setting.enabled ? 1 : 0,
JSON.stringify(setting.config || {}), JSON.stringify(setting.config || {}),
JSON.stringify(setting.enabled_events || []) JSON.stringify(setting.enabled_events || [])
@@ -85,17 +79,15 @@ export class NotificationSettingsRepository {
} }
return result.lastID; return result.lastID;
} catch (err: any) { } catch (err: any) {
console.error(`Error creating notification setting:`, err.message); console.error(`创建通知设置时出错:`, err.message);
throw new Error(`Error creating notification setting: ${err.message}`); throw new Error(`创建通知设置时出错: ${err.message}`);
} }
} }
async update(id: number, setting: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> { 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 fields: string[] = [];
const params: (string | number | null)[] = []; 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.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.name !== undefined) { fields.push('name = ?'); params.push(setting.name); }
if (setting.enabled !== undefined) { fields.push('enabled = ?'); params.push(setting.enabled ? 1 : 0); } 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 (setting.enabled_events !== undefined) { fields.push('enabled_events = ?'); params.push(JSON.stringify(setting.enabled_events || [])); }
if (fields.length === 0) { if (fields.length === 0) {
console.warn(`[NotificationRepo] update called for ID ${id} with no fields to update.`); console.warn(`[通知仓库] 针对 ID ${id} 调用了更新,但没有要更新的字段。`);
return true; // Or false, depending on desired behavior for no-op update 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 = ?`; const sql = `UPDATE notification_settings SET ${fields.join(', ')} WHERE id = ?`;
params.push(id); params.push(id);
@@ -117,8 +109,8 @@ export class NotificationSettingsRepository {
const result = await runDb(db, sql, params); const result = await runDb(db, sql, params);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`Error updating notification setting ID ${id}:`, err.message); console.error(`更新通知设置 ID ${id} 时出错:`, err.message);
throw new Error(`Error updating notification setting 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]); const result = await runDb(db, sql, [id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`Error deleting notification setting ID ${id}:`, err.message); console.error(`删除通知设置 ID ${id} 时出错:`, err.message);
throw new Error(`Error deleting notification setting 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'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// 定义 Passkey 数据库记录的接口 // 定义 Passkey 数据库记录的接口
@@ -9,18 +6,16 @@ export interface PasskeyRecord {
credential_id: string; // Base64URL encoded credential_id: string; // Base64URL encoded
public_key: string; // Base64URL encoded public_key: string; // Base64URL encoded
counter: number; counter: number;
transports: string | null; // JSON string or null transports: string | null;
name: string | null; name: string | null;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
} }
// Define the expected row structure from the database if it matches PasskeyRecord
type DbPasskeyRow = PasskeyRecord; type DbPasskeyRow = PasskeyRecord;
export class PasskeyRepository { export class PasskeyRepository {
// Remove constructor or leave it empty, db instance will be fetched in each method
// constructor() { }
/** /**
* 保存新的 Passkey 凭证 * 保存新的 Passkey 凭证
@@ -48,7 +43,6 @@ export class PasskeyRepository {
return result.lastID; return result.lastID;
} catch (err: any) { } catch (err: any) {
console.error('保存 Passkey 时出错:', err.message); console.error('保存 Passkey 时出错:', err.message);
// Handle potential UNIQUE constraint errors on credential_id
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`Credential ID "${credentialId}" 已存在。`); throw new Error(`Credential ID "${credentialId}" 已存在。`);
} }
@@ -76,12 +70,10 @@ export class PasskeyRepository {
* 获取所有已注册的 Passkey 记录 (仅选择必要字段) * 获取所有已注册的 Passkey 记录 (仅选择必要字段)
* @returns Promise<Partial<PasskeyRecord>[]> 所有记录的部分信息的数组 * @returns Promise<Partial<PasskeyRecord>[]> 所有记录的部分信息的数组
*/ */
// Adjust return type based on selected columns
async getAllPasskeys(): Promise<Array<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>> { 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`; const sql = `SELECT id, credential_id, name, transports, created_at FROM passkeys ORDER BY created_at DESC`;
try { try {
const db = await getDbInstance(); 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); const rows = await allDb<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>(db, sql);
return rows; return rows;
} catch (err: any) { } 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'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// Remove top-level db instance
// const db = getDb();
// 定义 Proxy 类型 (可以共享到 types 文件)
export interface ProxyData { export interface ProxyData {
id: number; id: number;
name: string; name: string;
@@ -22,7 +15,6 @@ export interface ProxyData {
updated_at: number; updated_at: number;
} }
// Define the expected row structure from the database if it matches ProxyData
type DbProxyRow = ProxyData; type DbProxyRow = ProxyData;
/** /**
@@ -59,14 +51,12 @@ export const createProxy = async (data: Omit<ProxyData, 'id' | 'created_at' | 'u
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, params); const result = await runDb(db, sql, params);
// Ensure lastID is valid before returning
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('创建代理后未能获取有效的 lastID'); throw new Error('创建代理后未能获取有效的 lastID');
} }
return result.lastID; return result.lastID;
} catch (err: any) { } catch (err: any) {
console.error('Repository: 创建代理时出错:', err.message); console.error('Repository: 创建代理时出错:', err.message);
// Handle potential UNIQUE constraint errors if needed (e.g., on name)
throw new Error(`创建代理时出错: ${err.message}`); 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> => { export const updateProxy = async (id: number, data: Partial<Omit<ProxyData, 'id' | 'created_at'>>): Promise<boolean> => {
const fieldsToUpdate: { [key: string]: any } = { ...data }; const fieldsToUpdate: { [key: string]: any } = { ...data };
const params: any[] = []; const params: any[] = [];
// Remove fields that should not be updated directly
delete fieldsToUpdate.id; delete fieldsToUpdate.id;
delete fieldsToUpdate.created_at; delete fieldsToUpdate.created_at;
// updated_at will be set explicitly
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000); 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) { if (!setClauses) {
console.warn(`[Repository] updateProxy called for ID ${id} with no fields to update.`); 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 = ?`; 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; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`Repository: 更新代理 ${id} 时出错:`, err.message); console.error(`Repository: 更新代理 ${id} 时出错:`, err.message);
// Handle potential UNIQUE constraint errors if needed
throw new Error('更新代理记录失败'); 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> => { 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 = ?`; const sql = `DELETE FROM proxies WHERE id = ?`;
try { try {
const db = await getDbInstance(); 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 { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; // Import new async helpers
// 定义快捷指令的接口 // 定义快捷指令的接口
export interface QuickCommand { export interface QuickCommand {
@@ -11,7 +10,6 @@ export interface QuickCommand {
updated_at: number; // Unix 时间戳 (秒) updated_at: number; // Unix 时间戳 (秒)
} }
// Define the expected row structure from the database if it matches QuickCommand
type DbQuickCommandRow = QuickCommand; type DbQuickCommandRow = QuickCommand;
/** /**
@@ -25,7 +23,6 @@ export const addQuickCommand = async (name: string | null, command: string): Pro
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [name, command]); const result = await runDb(db, sql, [name, command]);
// Ensure lastID is valid before returning
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('添加快捷指令后未能获取有效的 lastID'); throw new Error('添加快捷指令后未能获取有效的 lastID');
} }
@@ -34,7 +31,7 @@ export const addQuickCommand = async (name: string | null, command: string): Pro
console.error('添加快捷指令时出错:', err.message); console.error('添加快捷指令时出错:', err.message);
throw new Error('无法添加快捷指令'); throw new Error('无法添加快捷指令');
} }
}; // End of addQuickCommand };
/** /**
* 更新指定的快捷指令 * 更新指定的快捷指令
@@ -53,7 +50,7 @@ export const updateQuickCommand = async (id: number, name: string | null, comman
console.error('更新快捷指令时出错:', err.message); console.error('更新快捷指令时出错:', err.message);
throw new Error('无法更新快捷指令'); throw new Error('无法更新快捷指令');
} }
}; // End of updateQuickCommand };
/** /**
* 根据 ID 删除指定的快捷指令 * 根据 ID 删除指定的快捷指令
@@ -70,7 +67,7 @@ export const deleteQuickCommand = async (id: number): Promise<boolean> => {
console.error('删除快捷指令时出错:', err.message); console.error('删除快捷指令时出错:', err.message);
throw new Error('无法删除快捷指令'); throw new Error('无法删除快捷指令');
} }
}; // End of deleteQuickCommand };
/** /**
* 获取所有快捷指令 * 获取所有快捷指令
@@ -91,7 +88,7 @@ export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name
console.error('获取快捷指令时出错:', err.message); console.error('获取快捷指令时出错:', err.message);
throw new Error('无法获取快捷指令'); throw new Error('无法获取快捷指令');
} }
}; // End of getAllQuickCommands };
/** /**
* 增加指定快捷指令的使用次数 * 增加指定快捷指令的使用次数
@@ -108,7 +105,7 @@ export const incrementUsageCount = async (id: number): Promise<boolean> => {
console.error('增加快捷指令使用次数时出错:', err.message); console.error('增加快捷指令使用次数时出错:', err.message);
throw new Error('无法增加快捷指令使用次数'); throw new Error('无法增加快捷指令使用次数');
} }
}; // End of incrementUsageCount };
/** /**
* 根据 ID 查找快捷指令 (用于编辑前获取数据) * 根据 ID 查找快捷指令 (用于编辑前获取数据)
@@ -120,9 +117,9 @@ export const findQuickCommandById = async (id: number): Promise<QuickCommand | u
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const row = await getDbRow<DbQuickCommandRow>(db, sql, [id]); const row = await getDbRow<DbQuickCommandRow>(db, sql, [id]);
return row; // Returns undefined if not found return row;
} catch (err: any) { } catch (err: any) {
console.error('查找快捷指令时出错:', err.message); console.error('查找快捷指令时出错:', err.message);
throw new Error('无法查找快捷指令'); 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 { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types'; // <-- Import LayoutNode and PaneName import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types';
import { CaptchaSettings } from '../types/settings.types'; // <-- Import CaptchaSettings import { CaptchaSettings } from '../types/settings.types';
import * as sqlite3 from 'sqlite3'; // Import sqlite3 for Database type hint import * as sqlite3 from 'sqlite3';
// Define keys for specific settings
const SIDEBAR_CONFIG_KEY = 'sidebarConfig'; const SIDEBAR_CONFIG_KEY = 'sidebarConfig';
const CAPTCHA_CONFIG_KEY = 'captchaConfig'; // <-- Add key for CAPTCHA settings const CAPTCHA_CONFIG_KEY = 'captchaConfig';
export interface Setting { export interface Setting {
key: string; key: string;
value: 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; type DbSettingRow = Setting;
export const settingsRepository = { export const settingsRepository = {
@@ -30,13 +26,12 @@ export const settingsRepository = {
}, },
async getSetting(key: string): Promise<string | null> { async getSetting(key: string): Promise<string | null> {
console.log(`[Repository] Attempting to get setting with key: ${key}`); console.log(`[仓库] 尝试获取键为 ${key} 的设置`);
try { try {
const db = await getDbInstance(); 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 row = await getDbRow<{ value: string }>(db, 'SELECT value FROM settings WHERE key = ?', [key]);
const value = row ? row.value : null; const value = row ? row.value : null;
console.log(`[Repository] Found value for key ${key}:`, value); console.log(`[仓库] 找到键 ${key} 的值:`, value);
return value; return value;
} catch (err: any) { } catch (err: any) {
console.error(`[Repository] 获取设置项 ${key} 时出错:`, err.message); console.error(`[Repository] 获取设置项 ${key} 时出错:`, err.message);
@@ -45,7 +40,7 @@ export const settingsRepository = {
}, },
async setSetting(key: string, value: string): Promise<void> { 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) const sql = `INSERT INTO settings (key, value, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
@@ -53,27 +48,27 @@ export const settingsRepository = {
updated_at = excluded.updated_at`; updated_at = excluded.updated_at`;
const params = [key, value, now, now]; const params = [key, value, now, now];
console.log(`[Repository] Attempting to set setting. Key: ${key}, Value: ${value}`); console.log(`[仓库] 尝试设置设置项。键: ${key}, : ${value}`);
console.log(`[Repository] Executing SQL: ${sql} with params: ${JSON.stringify(params)}`); console.log(`[仓库] 执行 SQL: ${sql},参数: ${JSON.stringify(params)}`);
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, params); 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) { } catch (err: any) {
console.error(`[Repository] 设置设置项 ${key} 时出错:`, err.message); console.error(`[Repository] 设置设置项 ${key} 时出错:`, err.message);
throw new Error(`设置设置项 ${key} 失败`); throw new Error(`设置设置项 ${key} 失败`);
} }
}, },
async deleteSetting(key: string): Promise<boolean> { // Return boolean indicating success async deleteSetting(key: string): Promise<boolean> {
console.log(`[Repository] Attempting to delete setting with key: ${key}`); console.log(`[仓库] 尝试删除键为 ${key} 的设置`);
const sql = 'DELETE FROM settings WHERE key = ?'; const sql = 'DELETE FROM settings WHERE key = ?';
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [key]); const result = await runDb(db, sql, [key]);
console.log(`[Repository] Successfully deleted setting for key: ${key}. Rows affected: ${result.changes}`); console.log(`[仓库] 成功删除键为 ${key} 的设置。影响行数: ${result.changes}`);
return result.changes > 0; // Return true if a row was deleted return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`[Repository] 删除设置项 ${key} 时出错:`, err.message); console.error(`[Repository] 删除设置项 ${key} 时出错:`, err.message);
throw new Error(`删除设置项 ${key} 失败`); throw new Error(`删除设置项 ${key} 失败`);
@@ -81,28 +76,23 @@ export const settingsRepository = {
}, },
async setMultipleSettings(settings: Record<string, string>): Promise<void> { async setMultipleSettings(settings: Record<string, string>): Promise<void> {
console.log('[Repository] setMultipleSettings called with:', JSON.stringify(settings)); console.log('[仓库] 调用 setMultipleSettings,参数:', JSON.stringify(settings));
// Use Promise.all with the async setSetting method
// Note: 'this' inside map refers to the settingsRepository object correctly here
const promises = Object.entries(settings).map(([key, value]) => const promises = Object.entries(settings).map(([key, value]) =>
this.setSetting(key, value) this.setSetting(key, value)
); );
try { try {
await Promise.all(promises); await Promise.all(promises);
console.log('[Repository] setMultipleSettings finished successfully.'); console.log('[仓库] setMultipleSettings 成功完成。');
} catch (error) { } catch (error) {
console.error('[Repository] setMultipleSettings failed:', error); console.error('[仓库] setMultipleSettings 失败:', error);
// Re-throw the error or handle it as needed
throw new Error('批量设置失败'); throw new Error('批量设置失败');
} }
}, },
}; };
// --- Specific Setting Getters/Setters ---
/** /**
* 获取侧栏配置 * 获取侧栏配置
* @returns Promise<SidebarConfig> - Returns the parsed config or default
*/ */
export const getSidebarConfig = async (): Promise<SidebarConfig> => { export const getSidebarConfig = async (): Promise<SidebarConfig> => {
const defaultValue: SidebarConfig = { left: [], right: [] }; const defaultValue: SidebarConfig = { left: [], right: [] };
@@ -111,44 +101,38 @@ export const getSidebarConfig = async (): Promise<SidebarConfig> => {
if (jsonString) { if (jsonString) {
try { try {
const config = JSON.parse(jsonString); const config = JSON.parse(jsonString);
// Basic validation
if (config && Array.isArray(config.left) && Array.isArray(config.right)) { 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; return config as SidebarConfig;
} }
console.warn(`[SettingsRepo] Invalid sidebarConfig format found in DB: ${jsonString}. Returning default.`); console.warn(`[设置仓库] 在数据库中发现无效的 sidebarConfig 格式: ${jsonString}。返回默认值。`);
} catch (parseError) { } catch (parseError) {
console.error(`[SettingsRepo] Failed to parse sidebarConfig JSON from DB: ${jsonString}`, parseError); console.error(`[设置仓库] 从数据库解析 sidebarConfig JSON 失败: ${jsonString}`, parseError);
} }
} }
} catch (error) { } 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; return defaultValue;
}; };
/** /**
* 设置侧栏配置 * 设置侧栏配置
* @param config - The sidebar configuration object
*/ */
export const setSidebarConfig = async (config: SidebarConfig): Promise<void> => { export const setSidebarConfig = async (config: SidebarConfig): Promise<void> => {
try { try {
// Basic validation before stringifying
if (!config || typeof config !== 'object' || !Array.isArray(config.left) || !Array.isArray(config.right)) { 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); const jsonString = JSON.stringify(config);
await settingsRepository.setSetting(SIDEBAR_CONFIG_KEY, jsonString); await settingsRepository.setSetting(SIDEBAR_CONFIG_KEY, jsonString);
} catch (error) { } catch (error) {
console.error(`[SettingsRepo] Error setting sidebar config (key: ${SIDEBAR_CONFIG_KEY}):`, error); console.error(`[设置仓库] 设置侧边栏配置时出错 (键: ${SIDEBAR_CONFIG_KEY}):`, error);
throw new Error('Failed to save sidebar configuration.'); throw new Error('保存侧边栏配置失败。');
} }
}; };
// --- CAPTCHA Settings ---
/** /**
* 获取 CAPTCHA 配置 * 获取 CAPTCHA 配置
* @returns Promise<CaptchaSettings> - 返回解析后的配置或默认值 * @returns Promise<CaptchaSettings> - 返回解析后的配置或默认值
@@ -158,18 +142,16 @@ export const getCaptchaConfig = async (): Promise<CaptchaSettings> => {
enabled: false, enabled: false,
provider: 'none', provider: 'none',
hcaptchaSiteKey: '', hcaptchaSiteKey: '',
hcaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible hcaptchaSecretKey: '',
recaptchaSiteKey: '', recaptchaSiteKey: '',
recaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible recaptchaSecretKey: '',
}; };
try { try {
const jsonString = await settingsRepository.getSetting(CAPTCHA_CONFIG_KEY); const jsonString = await settingsRepository.getSetting(CAPTCHA_CONFIG_KEY);
if (jsonString) { if (jsonString) {
try { try {
const config = JSON.parse(jsonString); const config = JSON.parse(jsonString);
// Basic validation (add more specific checks if needed)
if (config && typeof config.enabled === 'boolean' && typeof config.provider === 'string') { if (config && typeof config.enabled === 'boolean' && typeof config.provider === 'string') {
// Ensure all keys exist, even if undefined/null from older saves
return { return {
enabled: config.enabled ?? defaultValue.enabled, enabled: config.enabled ?? defaultValue.enabled,
provider: config.provider ?? defaultValue.provider, provider: config.provider ?? defaultValue.provider,
@@ -179,29 +161,25 @@ export const getCaptchaConfig = async (): Promise<CaptchaSettings> => {
recaptchaSecretKey: config.recaptchaSecretKey ?? defaultValue.recaptchaSecretKey, recaptchaSecretKey: config.recaptchaSecretKey ?? defaultValue.recaptchaSecretKey,
} as CaptchaSettings; } as CaptchaSettings;
} }
console.warn(`[SettingsRepo] Invalid captchaConfig format found in DB: ${jsonString}. Returning default.`); console.warn(`[设置仓库] 在数据库中发现无效的 captchaConfig 格式: ${jsonString}。返回默认值。`);
} catch (parseError) { } catch (parseError) {
console.error(`[SettingsRepo] Failed to parse captchaConfig JSON from DB: ${jsonString}`, parseError); console.error(`[设置仓库] 从数据库解析 captchaConfig JSON 失败: ${jsonString}`, parseError);
} }
} }
} catch (error) { } 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; return defaultValue;
}; };
/** /**
* 设置 CAPTCHA 配置 * 设置 CAPTCHA 配置
* @param config - The CAPTCHA configuration object
*/ */
export const setCaptchaConfig = async (config: CaptchaSettings): Promise<void> => { export const setCaptchaConfig = async (config: CaptchaSettings): Promise<void> => {
try { try {
// Basic validation before stringifying
if (!config || typeof config !== 'object' || typeof config.enabled !== 'boolean' || typeof config.provider !== 'string') { 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.hcaptchaSecretKey = config.hcaptchaSecretKey || '';
config.recaptchaSecretKey = config.recaptchaSecretKey || ''; config.recaptchaSecretKey = config.recaptchaSecretKey || '';
config.hcaptchaSiteKey = config.hcaptchaSiteKey || ''; config.hcaptchaSiteKey = config.hcaptchaSiteKey || '';
@@ -210,22 +188,16 @@ export const setCaptchaConfig = async (config: CaptchaSettings): Promise<void> =
const jsonString = JSON.stringify(config); const jsonString = JSON.stringify(config);
await settingsRepository.setSetting(CAPTCHA_CONFIG_KEY, jsonString); await settingsRepository.setSetting(CAPTCHA_CONFIG_KEY, jsonString);
} catch (error) { } catch (error) {
console.error(`[SettingsRepo] Error setting CAPTCHA config (key: ${CAPTCHA_CONFIG_KEY}):`, error); console.error(`[设置仓库] 设置 CAPTCHA 配置时出错 (键: ${CAPTCHA_CONFIG_KEY}):`, error);
throw new Error('Failed to save CAPTCHA configuration.'); 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> => { 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 type OmitIdRecursive<T> = T extends object
? { [K in keyof Omit<T, 'id'>]: OmitIdRecursive<T[K]> } ? { [K in keyof Omit<T, 'id'>]: OmitIdRecursive<T[K]> }
: T; : T;
@@ -279,36 +251,34 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
recaptchaSecretKey: '', recaptchaSecretKey: '',
}; };
// --- Define All Default Settings ---
const defaultSettings: Record<string, string> = { const defaultSettings: Record<string, string> = {
ipWhitelistEnabled: 'false', ipWhitelistEnabled: 'false',
ipWhitelist: '', ipWhitelist: '',
maxLoginAttempts: '5', maxLoginAttempts: '5',
loginBanDuration: '300', // 5 minutes in seconds loginBanDuration: '300',
focusSwitcherSequence: JSON.stringify(["quickCommandsSearch", "commandHistorySearch", "fileManagerSearch", "commandInput", "terminalSearch"]), // Default focus sequence focusSwitcherSequence: JSON.stringify(["quickCommandsSearch", "commandHistorySearch", "fileManagerSearch", "commandInput", "terminalSearch"]),
navBarVisible: 'true', // Default nav bar visibility navBarVisible: 'true',
layoutTree: JSON.stringify(defaultLayoutTreeStructure), // Use the defined structure layoutTree: JSON.stringify(defaultLayoutTreeStructure),
autoCopyOnSelect: 'false', // Default auto copy setting autoCopyOnSelect: 'false',
showPopupFileEditor: 'false', // Default popup editor setting showPopupFileEditor: 'false',
shareFileEditorTabs: 'true', // Default editor tab sharing shareFileEditorTabs: 'true',
dockerStatusIntervalSeconds: '5', // Default Docker refresh interval dockerStatusIntervalSeconds: '5',
dockerDefaultExpand: 'false', // Default Docker expand state dockerDefaultExpand: 'false',
statusMonitorIntervalSeconds: '3', // Default Status Monitor interval statusMonitorIntervalSeconds: '3',
[SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure), // Use the defined structure [SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure),
[CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings), // Add default CAPTCHA settings [CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings),
// Add other default settings here
}; };
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
const sqlInsertOrIgnore = `INSERT OR IGNORE INTO settings (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)`; const sqlInsertOrIgnore = `INSERT OR IGNORE INTO settings (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)`;
console.log('[SettingsRepo] Ensuring default settings exist...'); console.log('[设置仓库] 确保默认设置存在...');
try { try {
for (const [key, value] of Object.entries(defaultSettings)) { for (const [key, value] of Object.entries(defaultSettings)) {
await runDb(db, sqlInsertOrIgnore, [key, value, nowSeconds, nowSeconds]); await runDb(db, sqlInsertOrIgnore, [key, value, nowSeconds, nowSeconds]);
} }
console.log('[SettingsRepo] Default settings check complete.'); console.log('[设置仓库] 默认设置检查完成。');
} catch (err: any) { } catch (err: any) {
console.error(`[SettingsRepo] Error ensuring default settings:`, err.message); console.error(`[设置仓库] 确保默认设置时出错:`, err.message);
throw new Error(`Failed to ensure default settings: ${err.message}`); throw new Error(`确保默认设置失败: ${err.message}`);
} }
}; };
@@ -1,13 +1,8 @@
// packages/backend/src/repositories/tag.repository.ts import { Database, Statement } from 'sqlite3';
import { Database, Statement } from 'sqlite3'; // Keep Statement if using prepare directly, otherwise remove
// Import new async helpers and the instance getter
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// Remove top-level db instance
// const db = getDb();
// 定义 Tag 类型 (可以共享到 types 文件) // 定义 Tag 类型 (可以共享到 types 文件)
// Let's assume TagData is the correct interface for a row from the 'tags' table
export interface TagData { export interface TagData {
id: number; id: number;
name: string; 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`); const rows = await allDb<TagData>(db, `SELECT * FROM tags ORDER BY name ASC`);
return rows; return rows;
} catch (err: any) { } catch (err: any) {
console.error('Repository: 查询标签列表时出错:', err.message); console.error('[仓库] 查询标签列表时出错:', err.message);
throw new Error('获取标签列表失败'); 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]); const row = await getDbRow<TagData>(db, `SELECT * FROM tags WHERE id = ?`, [id]);
return row || null; return row || null;
} catch (err: any) { } catch (err: any) {
console.error(`Repository: 查询标签 ${id} 时出错:`, err.message); console.error(`[仓库] 查询标签 ${id} 时出错:`, err.message);
throw new Error('获取标签信息失败'); throw new Error('获取标签信息失败');
} }
}; };
@@ -48,19 +43,17 @@ export const findTagById = async (id: number): Promise<TagData | null> => {
* 创建新标签 * 创建新标签
*/ */
export const createTag = async (name: string): Promise<number> => { 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 (?, ?, ?)`; const sql = `INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [name, now, now]); const result = await runDb(db, sql, [name, now, now]);
// Ensure lastID is valid before returning
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('创建标签后未能获取有效的 lastID'); throw new Error('创建标签后未能获取有效的 lastID');
} }
return result.lastID; return result.lastID;
} catch (err: any) { } catch (err: any) {
console.error('Repository: 创建标签时出错:', err.message); console.error('[仓库] 创建标签时出错:', err.message);
// Handle unique constraint error specifically if needed
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`标签名称 "${name}" 已存在。`); 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]); const result = await runDb(db, sql, [name, now, id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`Repository: 更新标签 ${id} 时出错:`, err.message); console.error(`[仓库] 更新标签 ${id} 时出错:`, err.message);
// Handle unique constraint error specifically if needed
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`标签名称 "${name}" 已存在。`); 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> => { 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 = ?`; const sql = `DELETE FROM tags WHERE id = ?`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [id]); const result = await runDb(db, sql, [id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`Repository: 删除标签 ${id} 时出错:`, err.message); console.error(`[仓库] 删除标签 ${id} 时出错:`, err.message);
throw new Error('删除标签失败'); throw new Error('删除标签失败');
} }
}; };
@@ -1,12 +1,7 @@
// packages/backend/src/repositories/terminal-theme.repository.ts import { Database } from 'sqlite3';
import { Database } from 'sqlite3'; // Import Database type if needed for type hints import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
import { getDbInstance, runDb, getDb, allDb } from '../database/connection'; // Import new async helpers, including getDb
// Remove the incorrect import of DbTerminalThemeRow
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types'; 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 { interface DbTerminalThemeRow {
id: number; id: number;
name: string; name: string;
@@ -36,23 +31,18 @@ interface DbTerminalThemeRow {
updated_at: number; updated_at: number;
} }
// SQL_CREATE_TABLE and createTableIfNotExists removed as initialization is handled in database/connection.ts
// 辅助函数:将数据库行转换为 TerminalTheme 对象 // 辅助函数:将数据库行转换为 TerminalTheme 对象
// Add type annotation for the input row
const mapRowToTerminalTheme = (row: DbTerminalThemeRow): TerminalTheme => { const mapRowToTerminalTheme = (row: DbTerminalThemeRow): TerminalTheme => {
// Basic check if row exists and has id property // Basic check if row exists and has id property
if (!row || typeof row.id === 'undefined') { if (!row || typeof row.id === 'undefined') {
console.error("mapRowToTerminalTheme received invalid row:", row); 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"); throw new Error("Invalid database row provided to mapRowToTerminalTheme");
} }
try { try {
return { return {
_id: row.id.toString(), _id: row.id.toString(),
name: row.name, name: row.name,
// Reconstruct themeData from individual columns
themeData: { themeData: {
foreground: row.foreground ?? undefined, foreground: row.foreground ?? undefined,
background: row.background ?? undefined, background: row.background ?? undefined,
@@ -77,16 +67,12 @@ const mapRowToTerminalTheme = (row: DbTerminalThemeRow): TerminalTheme => {
brightWhite: row.bright_white ?? undefined, brightWhite: row.bright_white ?? undefined,
}, },
isPreset: row.theme_type === 'preset', isPreset: row.theme_type === 'preset',
// isSystemDefault needs to be handled differently, maybe based on name 'default'? isSystemDefault: row.name === 'default',
// For now, let's assume it's not directly mapped or needed here.
isSystemDefault: row.name === 'default', // Tentative mapping based on name
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
} catch (e: any) { } 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); 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}`); 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[]> => { export const findAllThemes = async (): Promise<TerminalTheme[]> => {
try { try {
const db = await getDbInstance(); 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'); 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 => { return rows.map(row => {
try { try {
return mapRowToTerminalTheme(row); return mapRowToTerminalTheme(row);
} catch (mapError: any) { } catch (mapError: any) {
console.error(`Error mapping row ID ${row?.id}:`, mapError.message); console.error(`Error mapping row ID ${row?.id}:`, mapError.message);
return null; // Or handle differently return null;
} }
}).filter((theme): theme is TerminalTheme => theme !== 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.message);
// 添加详细错误日志 // 添加详细错误日志
console.error('详细错误:', err); 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> => { export const findThemeById = async (id: number): Promise<TerminalTheme | null> => {
if (isNaN(id) || id <= 0) { if (isNaN(id) || id <= 0) {
console.error("findThemeById called with invalid ID:", id); console.error("findThemeById called with invalid ID:", id);
return null; // Return null for invalid IDs return null;
} }
try { try {
const db = await getDbInstance(); 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]); const row = await getDb<DbTerminalThemeRow>(db, 'SELECT * FROM terminal_themes WHERE id = ?', [id]);
return row ? mapRowToTerminalTheme(row) : null; return row ? mapRowToTerminalTheme(row) : null;
} catch (err: any) { // Add type annotation for err } catch (err: any) {
console.error(`查询 ID 为 ${id} 的终端主题失败:`, err.message); console.error(`查询 ID 为 ${id} 的终端主题失败:`, err.message);
throw new Error('查询终端主题失败'); throw new Error('查询终端主题失败');
} }
@@ -146,10 +127,10 @@ export const findThemeById = async (id: number): Promise<TerminalTheme | null> =
* @returns Promise<TerminalTheme> 新创建的主题 * @returns Promise<TerminalTheme> 新创建的主题
*/ */
export const createTheme = async (themeDto: CreateTerminalThemeDto): 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; const theme = themeDto.themeData;
// Define columns based on the DbTerminalThemeRow interface (excluding id, created_at, updated_at)
const columns = [ const columns = [
'name', 'theme_type', 'foreground', 'background', 'cursor', 'cursor_accent', 'name', 'theme_type', 'foreground', 'background', 'cursor', 'cursor_accent',
'selection_background', 'black', 'red', 'green', 'yellow', 'blue', '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', 'bright_yellow', 'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white',
'created_at', 'updated_at' 'created_at', 'updated_at'
]; ];
// Map themeDto data to corresponding columns, using null for missing optional values
const 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?.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?.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?.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, 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(', '); const placeholders = columns.map(() => '?').join(', ');
@@ -175,8 +155,7 @@ export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<Ter
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, values); // Use the mapped values array const result = await runDb(db, sql, values);
// Ensure lastID is valid before trying to find the theme
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('创建主题后未能获取有效的 lastID'); throw new Error('创建主题后未能获取有效的 lastID');
} }
@@ -184,10 +163,9 @@ export const createTheme = async (themeDto: CreateTerminalThemeDto): Promise<Ter
if (newTheme) { if (newTheme) {
return newTheme; return newTheme;
} else { } else {
// This case might happen if findThemeById fails for some reason
throw new Error(`创建主题后未能检索到 ID 为 ${result.lastID} 的主题`); throw new Error(`创建主题后未能检索到 ID 为 ${result.lastID} 的主题`);
} }
} catch (err: any) { // Add type annotation for err } catch (err: any) {
console.error('创建新终端主题失败:', err.message); console.error('创建新终端主题失败:', err.message);
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`主题名称 "${themeDto.name}" 已存在。`); throw new Error(`主题名称 "${themeDto.name}" 已存在。`);
@@ -215,7 +193,7 @@ export const updateTheme = async (id: number, themeDto: UpdateTerminalThemeDto):
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [themeDto.name, themeDataJson, now, id]); const result = await runDb(db, sql, [themeDto.name, themeDataJson, now, id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { // Add type annotation for err } catch (err: any) {
console.error(`更新 ID 为 ${id} 的终端主题失败:`, err.message); console.error(`更新 ID 为 ${id} 的终端主题失败:`, err.message);
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`主题名称 "${themeDto.name}" 已存在。`); throw new Error(`主题名称 "${themeDto.name}" 已存在。`);
@@ -231,13 +209,12 @@ export const updateTheme = async (id: number, themeDto: UpdateTerminalThemeDto):
* @returns Promise<boolean> 是否成功删除 * @returns Promise<boolean> 是否成功删除
*/ */
export const deleteTheme = async (id: number): 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\''; const sql = 'DELETE FROM terminal_themes WHERE id = ? AND theme_type = \'user\'';
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [id]); const result = await runDb(db, sql, [id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { // Add type annotation for err } catch (err: any) {
console.error(`删除 ID 为 ${id} 的终端主题失败:`, err.message); console.error(`删除 ID 为 ${id} 的终端主题失败:`, err.message);
throw new Error('删除终端主题失败'); 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] 开始检查并初始化预设主题...');
// 在这里添加日志,显示总共要处理多少个预设主题 // 在这里添加日志,显示总共要处理多少个预设主题
console.log(`[DB Init] 发现 ${presets.length} 个预设主题定义。`); console.log(`[DB Init] 发现 ${presets.length} 个预设主题定义。`);
const nowSeconds = Math.floor(Date.now() / 1000); // Use seconds for DB consistency const nowSeconds = Math.floor(Date.now() / 1000);
// const db = await getDbInstance(); // Use the passed db instance
for (const preset of presets) { for (const preset of presets) {
// 在循环开始时添加日志,显示正在处理哪个主题 // 在循环开始时添加日志,显示正在处理哪个主题
console.log(`[DB Init] 正在处理预设主题: "${preset.name}"`); console.log(`[DB Init] 正在处理预设主题: "${preset.name}"`);
try { 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]); const existing = await getDb<{ id: number }>(db, `SELECT id FROM terminal_themes WHERE name = ? AND theme_type = 'preset'`, [preset.name]);
if (!existing) { if (!existing) {
// 添加日志,表明正在插入新主题
console.log(`[DB Init] 主题 "${preset.name}" 不存在,正在插入...`);
// Map preset.themeData to individual columns
const theme = preset.themeData; const theme = preset.themeData;
const columns = [ const columns = [
'name', 'theme_type', 'foreground', 'background', 'cursor', 'cursor_accent', '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); await runDb(db, insertSql, values);
console.log(`[DB Init] 预设主题 "${preset.name}" 已初始化到数据库。`); console.log(`[DB Init] 预设主题 "${preset.name}" 已初始化到数据库。`);
} else { } else {
// 取消注释并添加日志,表明主题已存在
console.log(`[DB Init] 预设主题 "${preset.name}" 已存在,跳过初始化。`); console.log(`[DB Init] 预设主题 "${preset.name}" 已存在,跳过初始化。`);
} }
} catch (err: any) { // Add type annotation for err } catch (err: any) {
// Remove reference to non-existent preset_key
console.error(`[DB Init] 处理预设主题 "${preset.name}" 时出错:`, err.message); 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] 预设主题检查和初始化完成。'); console.log('[DB Init] 预设主题检查和初始化完成。');
@@ -1,6 +1,6 @@
import * as appearanceRepository from '../repositories/appearance.repository'; import * as appearanceRepository from '../repositories/appearance.repository';
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types'; 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); return appearanceRepository.updateAppearanceSettings(settingsDto);
}; };
// 注意:背景图片上传/处理逻辑需要根据最终决定(URL vs 上传)来添加。
@@ -48,6 +48,3 @@ export class AuditLogService {
return this.repository.getLogs(limit, offset, actionType, startDate, endDate, searchTerm); 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 axios from 'axios';
import { settingsService } from './settings.service'; import { settingsService } from './settings.service';
import { CaptchaSettings, CaptchaProvider } from '../types/settings.types';
// CAPTCHA 验证 API 端点 // CAPTCHA 验证 API 端点
const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify'; const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify';
@@ -95,7 +94,6 @@ export class CaptchaService {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('secret', secretKey); params.append('secret', secretKey);
params.append('response', token); params.append('response', token);
// params.append('remoteip', userIpAddress); // 如果需要传递用户 IP
const response = await axios.post(RECAPTCHA_VERIFY_URL, params, { const response = await axios.post(RECAPTCHA_VERIFY_URL, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } 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) { if (!command || command.trim().length === 0) {
throw new Error('命令不能为空'); throw new Error('命令不能为空');
} }
// 可以在此添加去重逻辑,如果不想记录重复的命令
// const existing = await CommandHistoryRepository.findCommand(command); // 如果需要更复杂的去重逻辑
// if (existing) { ... }
// 调用 upsertCommand 来处理插入或更新时间戳 // 调用 upsertCommand 来处理插入或更新时间戳
return CommandHistoryRepository.upsertCommand(command.trim()); return CommandHistoryRepository.upsertCommand(command.trim());
@@ -1,15 +1,14 @@
import * as ConnectionRepository from '../repositories/connection.repository'; import * as ConnectionRepository from '../repositories/connection.repository';
import { encrypt, decrypt } from '../utils/crypto'; import { encrypt, decrypt } from '../utils/crypto';
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService import { AuditLogService } from '../services/audit.service';
import { import {
ConnectionBase, ConnectionBase,
ConnectionWithTags, ConnectionWithTags,
CreateConnectionInput, CreateConnectionInput,
UpdateConnectionInput, UpdateConnectionInput,
FullConnectionData // Import FullConnectionData if needed internally or by repo FullConnectionData
} from '../types/connection.types'; // 从集中类型文件导入 } from '../types/connection.types'; // 从集中类型文件导入
// Re-export types if they need to be available via this service module
export type { ConnectionBase, ConnectionWithTags, CreateConnectionInput, UpdateConnectionInput }; 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> => { export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
// 1. Validate input (basic validation, more complex validation can be added) // 1. 验证输入
// Removed name validation: if (!input.name || !input.host || !input.username || !input.auth_method) { if (!input.host || !input.username || !input.auth_method) {
if (!input.host || !input.username || !input.auth_method) { // Validate required fields except name
throw new Error('缺少必要的连接信息 (host, username, auth_method)。'); throw new Error('缺少必要的连接信息 (host, username, auth_method)。');
} }
if (input.auth_method === 'password' && !input.password) { 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) { if (input.auth_method === 'key' && !input.private_key) {
throw new Error('密钥认证方式需要提供 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 encryptedPassword = null;
let encryptedPrivateKey = null; let encryptedPrivateKey = null;
let encryptedPassphrase = null; let encryptedPassphrase = null;
@@ -60,11 +57,11 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
} }
} }
// 3. Prepare data for repository // 3. 准备仓库数据
const connectionData = { const connectionData = {
name: input.name || '', // Use empty string '' if name is empty or undefined name: input.name || '', // 如果 name 为空或 undefined,则使用空字符串 ''
host: input.host, host: input.host,
port: input.port ?? 22, // Default port port: input.port ?? 22, // 默认端口
username: input.username, username: input.username,
auth_method: input.auth_method, auth_method: input.auth_method,
encrypted_password: encryptedPassword, encrypted_password: encryptedPassword,
@@ -73,26 +70,25 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
proxy_id: input.proxy_id ?? null, proxy_id: input.proxy_id ?? null,
}; };
// 4. Create connection record in repository // 4. 在仓库中创建连接记录
const newConnectionId = await ConnectionRepository.createConnection(connectionData); const newConnectionId = await ConnectionRepository.createConnection(connectionData);
// 5. Handle tags // 5. 处理标签
const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? []; const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? [];
if (tagIds.length > 0) { if (tagIds.length > 0) {
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds); await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
} }
// 6. Log audit action // 6. 记录审计操作
// Fetch the created connection to get necessary details for logging
const newConnection = await getConnectionById(newConnectionId); const newConnection = await getConnectionById(newConnectionId);
if (!newConnection) { if (!newConnection) {
// This should ideally not happen if creation was successful // 如果创建成功,这理论上不应该发生
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`); console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
throw new Error('创建连接后无法检索到该连接。'); throw new Error('创建连接后无法检索到该连接。');
} }
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host }); auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host });
// 7. Return the newly created connection with tags // 7. 返回新创建的带标签的连接
return newConnection; 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> => { 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); const currentFullConnection = await ConnectionRepository.findFullConnectionById(id);
if (!currentFullConnection) { if (!currentFullConnection) {
return null; // Connection not found return null; // 未找到连接
} }
// 2. Prepare data for update // 2. 准备更新数据
const dataToUpdate: Partial<ConnectionRepository.FullConnectionData> = {}; const dataToUpdate: Partial<ConnectionRepository.FullConnectionData> = {};
let needsCredentialUpdate = false; let needsCredentialUpdate = false;
let newAuthMethod = input.auth_method || currentFullConnection.auth_method; 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.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port; if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username; 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) { if (input.auth_method && input.auth_method !== currentFullConnection.auth_method) {
// Auth method changed // 认证方法已更改
dataToUpdate.auth_method = input.auth_method; dataToUpdate.auth_method = input.auth_method;
needsCredentialUpdate = true; needsCredentialUpdate = true;
if (input.auth_method === 'password') { 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_password = encrypt(input.password);
dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null; dataToUpdate.encrypted_passphrase = null;
} else { // key } else { // 密钥
if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。'); if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。');
dataToUpdate.encrypted_private_key = encrypt(input.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_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
dataToUpdate.encrypted_password = null; dataToUpdate.encrypted_password = null;
} }
} else { } 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() !== '') { if (newAuthMethod === 'password' && input.password && input.password.trim() !== '') {
dataToUpdate.encrypted_password = encrypt(input.password); dataToUpdate.encrypted_password = encrypt(input.password);
needsCredentialUpdate = true; needsCredentialUpdate = true;
@@ -145,51 +141,51 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
let passphraseChanged = false; let passphraseChanged = false;
if (input.private_key && input.private_key.trim() !== '') { if (input.private_key && input.private_key.trim() !== '') {
dataToUpdate.encrypted_private_key = encrypt(input.private_key); 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; dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true; 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) { 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; 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; const hasNonTagChanges = Object.keys(dataToUpdate).length > 0;
let updatedFieldsForAudit: string[] = []; // Track fields for audit log let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段
if (hasNonTagChanges) { if (hasNonTagChanges) {
updatedFieldsForAudit = Object.keys(dataToUpdate); // Get fields before update call updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate); const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
if (!updated) { if (!updated) {
// Should not happen if findFullConnectionById succeeded, but good practice // 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践
throw new Error('更新连接记录失败。'); throw new Error('更新连接记录失败。');
} }
} }
// 4. Handle tags update if tag_ids were provided // 4. 如果提供了 tag_ids,则处理标签更新
if (input.tag_ids !== undefined) { if (input.tag_ids !== undefined) {
const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0); const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0);
await ConnectionRepository.updateConnectionTags(id, validTagIds); await ConnectionRepository.updateConnectionTags(id, validTagIds);
} }
// Add 'tag_ids' to audit log if they were updated // 如果 tag_ids 已更新,则将其添加到审计日志
if (input.tag_ids !== undefined) { if (input.tag_ids !== undefined) {
updatedFieldsForAudit.push('tag_ids'); updatedFieldsForAudit.push('tag_ids');
} }
// 5. Log audit action if any changes were made // 5. 如果进行了任何更改,则记录审计操作
if (hasNonTagChanges || input.tag_ids !== undefined) { if (hasNonTagChanges || input.tag_ids !== undefined) {
auditLogService.logAction('CONNECTION_UPDATED', { connectionId: id, updatedFields: updatedFieldsForAudit }); auditLogService.logAction('CONNECTION_UPDATED', { connectionId: id, updatedFields: updatedFieldsForAudit });
} }
// 6. Fetch and return the updated connection // 6. 获取并返回更新后的连接
return getConnectionById(id); return getConnectionById(id);
}; };
@@ -200,11 +196,11 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
export const deleteConnection = async (id: number): Promise<boolean> => { export const deleteConnection = async (id: number): Promise<boolean> => {
const deleted = await ConnectionRepository.deleteConnection(id); const deleted = await ConnectionRepository.deleteConnection(id);
if (deleted) { if (deleted) {
// Log audit action after successful deletion // 删除成功后记录审计操作
auditLogService.logAction('CONNECTION_DELETED', { connectionId: id }); auditLogService.logAction('CONNECTION_DELETED', { connectionId: id });
} }
return deleted; return deleted;
}; };
// Note: testConnection, importConnections, exportConnections logic // 注意:testConnectionimportConnectionsexportConnections 逻辑
// will be moved to SshService and ImportExportService respectively. // 将分别移至 SshService ImportExportService
@@ -1,7 +1,5 @@
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
// import { Service } from 'typedi'; // Removed typedi import
// import { logger } from '../utils/logger'; // Removed logger import
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -60,11 +58,10 @@ export class DockerService {
try { try {
// 尝试执行一个简单的 docker 命令,如 docker version // 尝试执行一个简单的 docker 命令,如 docker version
await execAsync('docker version', { timeout: 2000 }); // 5秒超时 await execAsync('docker version', { timeout: 2000 }); // 5秒超时
console.log('[DockerService] Docker is available.'); // Use console.log
this.isDockerAvailableCache = true; this.isDockerAvailableCache = true;
return true; return true;
} catch (error: any) { } 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; this.isDockerAvailableCache = false;
return false; return false;
} }
@@ -1,13 +1,10 @@
// packages/backend/src/services/import-export.service.ts
import * as ConnectionRepository from '../repositories/connection.repository'; import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.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 { 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 { interface ImportedConnectionData {
name: string; name: string;
host: string; host: string;
@@ -36,7 +33,6 @@ export interface ImportResult {
failureCount: number; failureCount: number;
errors: { connectionName?: string; message: string }[]; errors: { connectionName?: string; message: string }[];
} }
// --- End Interface definitions ---
/** /**
@@ -44,9 +40,8 @@ export interface ImportResult {
*/ */
export const exportConnections = async (): Promise<ExportedConnectionData[]> => { export const exportConnections = async (): Promise<ExportedConnectionData[]> => {
try { 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 & { type ExportRow = ConnectionRepository.FullConnectionData & {
proxy_db_id: number | null; proxy_db_id: number | null;
proxy_name: string | null; proxy_name: string | null;
@@ -60,7 +55,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
proxy_encrypted_passphrase?: string | null; proxy_encrypted_passphrase?: string | null;
}; };
// Fetch connections joined with proxies using await allDb
const connectionsWithProxies = await allDb<ExportRow>(db, const connectionsWithProxies = await allDb<ExportRow>(db,
`SELECT `SELECT
c.*, c.*,
@@ -75,22 +70,22 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
ORDER BY c.name ASC` ORDER BY c.name ASC`
); );
// Fetch all tag associations using await allDb
const tagRows = await allDb<{ connection_id: number, tag_id: number }>(db, const tagRows = await allDb<{ connection_id: number, tag_id: number }>(db,
'SELECT connection_id, tag_id FROM connection_tags' 'SELECT connection_id, tag_id FROM connection_tags'
); );
// Create a map for easy tag lookup
const tagsMap: { [connId: number]: number[] } = {}; const tagsMap: { [connId: number]: number[] } = {};
tagRows.forEach(row => { tagRows.forEach(row => {
if (!tagsMap[row.connection_id]) tagsMap[row.connection_id] = []; if (!tagsMap[row.connection_id]) tagsMap[row.connection_id] = [];
tagsMap[row.connection_id].push(row.tag_id); tagsMap[row.connection_id].push(row.tag_id);
}); });
// Format data for export
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => { const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
const connection: ExportedConnectionData = { const connection: ExportedConnectionData = {
name: row.name ?? 'Unnamed', // Provide default if name is null name: row.name ?? 'Unnamed',
host: row.host, host: row.host,
port: row.port, port: row.port,
username: row.username, username: row.username,
@@ -104,12 +99,12 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
if (row.proxy_db_id) { if (row.proxy_db_id) {
connection.proxy = { connection.proxy = {
name: row.proxy_name ?? 'Unnamed Proxy', // Provide default name: row.proxy_name ?? 'Unnamed Proxy',
type: row.proxy_type ?? 'SOCKS5', // Provide default or handle error type: row.proxy_type ?? 'SOCKS5',
host: row.proxy_host ?? '', // Provide default or handle error host: row.proxy_host ?? '',
port: row.proxy_port ?? 0, // Provide default or handle error port: row.proxy_port ?? 0,
username: row.proxy_username, 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_password: row.proxy_encrypted_password,
encrypted_private_key: row.proxy_encrypted_private_key, encrypted_private_key: row.proxy_encrypted_private_key,
encrypted_passphrase: row.proxy_encrypted_passphrase, encrypted_passphrase: row.proxy_encrypted_passphrase,
@@ -122,7 +117,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
} catch (err: any) { } catch (err: any) {
console.error('Service: 导出连接时出错:', err.message); 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 successCount = 0;
let failureCount = 0; let failureCount = 0;
const errors: { connectionName?: string; message: string }[] = []; const errors: { connectionName?: string; message: string }[] = [];
const db = await getDbInstance(); // Get DB instance once for the transaction const db = await getDbInstance();
try { 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 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) { for (const connData of importedData) {
try { try {
// Basic validation
if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) { if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) {
throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。'); throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。');
} }
// ... (add other validation as before) ...
let proxyIdToUse: number | null = null; let proxyIdToUse: number | null = null;
// Handle proxy (find or create) - uses async repository functions
if (connData.proxy) { if (connData.proxy) {
const proxyData = connData.proxy; const proxyData = connData.proxy;
if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port) { 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); proxyIdToUse = await ProxyRepository.createProxy(newProxyData);
console.log(`Service: 导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`); 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({ connectionsToInsert.push({
name: connData.name, name: connData.name,
host: connData.host, host: connData.host,
@@ -209,7 +202,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
encrypted_private_key: connData.encrypted_private_key || null, encrypted_private_key: connData.encrypted_private_key || null,
encrypted_passphrase: connData.encrypted_passphrase || null, encrypted_passphrase: connData.encrypted_passphrase || null,
proxy_id: proxyIdToUse, proxy_id: proxyIdToUse,
tag_ids: connData.tag_ids || [] // Include tag_ids tag_ids: connData.tag_ids || []
}); });
} catch (connError: any) { } catch (connError: any) {
@@ -217,25 +210,22 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
errors.push({ connectionName: connData.name || '未知连接', message: connError.message }); errors.push({ connectionName: connData.name || '未知连接', message: connError.message });
console.warn(`Service: 处理导入连接 "${connData.name || '未知'}" 时出错: ${connError.message}`); console.warn(`Service: 处理导入连接 "${connData.name || '未知'}" 时出错: ${connError.message}`);
} }
} // End for loop }
// --- Pass 2: Bulk insert connections ---
let insertedResults: { connectionId: number, originalData: any }[] = []; let insertedResults: { connectionId: number, originalData: any }[] = [];
if (connectionsToInsert.length > 0) { if (connectionsToInsert.length > 0) {
// Pass the transaction-aware db instance
insertedResults = await ConnectionRepository.bulkInsertConnections(db, connectionsToInsert); insertedResults = await ConnectionRepository.bulkInsertConnections(db, connectionsToInsert);
successCount = insertedResults.length; successCount = insertedResults.length;
} }
// --- Pass 3: Associate tags --- const insertTagSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`;
const insertTagSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`; // Use INSERT OR IGNORE
for (const result of insertedResults) { for (const result of insertedResults) {
const originalTagIds = result.originalData?.tag_ids; const originalTagIds = result.originalData?.tag_ids;
if (Array.isArray(originalTagIds) && originalTagIds.length > 0) { if (Array.isArray(originalTagIds) && originalTagIds.length > 0) {
const validTagIds = originalTagIds.filter((id: any) => typeof id === 'number' && id > 0); const validTagIds = originalTagIds.filter((id: any) => typeof id === 'number' && id > 0);
if (validTagIds.length > 0) { if (validTagIds.length > 0) {
const tagPromises = validTagIds.map(tagId => 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}`); 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'); await runDb(db, 'COMMIT');
console.log(`Service: 导入事务提交。成功: ${successCount}, 失败: ${failureCount}`); console.log(`Service: 导入事务提交。成功: ${successCount}, 失败: ${failureCount}`);
return { successCount, failureCount, errors }; return { successCount, failureCount, errors };
} catch (error: any) { } catch (error: any) {
// Rollback transaction on any error during the process
console.error('Service: 导入事务处理出错,正在回滚:', error); console.error('Service: 导入事务处理出错,正在回滚:', error);
try { try {
await runDb(db, 'ROLLBACK'); // Use await runDb await runDb(db, 'ROLLBACK');
} catch (rollbackErr: any) { } catch (rollbackErr: any) {
console.error("Service: 回滚事务失败:", rollbackErr); console.error("Service: 回滚事务失败:", rollbackErr);
} }
// Adjust failure count and return error summary
failureCount = importedData.length; failureCount = importedData.length;
successCount = 0; successCount = 0;
errors.push({ message: `事务处理失败: ${error.message}` }); 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 { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { settingsService } from './settings.service'; import { settingsService } from './settings.service';
import { NotificationService } from './notification.service'; // 导入 NotificationService import { NotificationService } from './notification.service';
import * as sqlite3 from 'sqlite3'; // Keep for RunResult type if needed
// Remove top-level db instance
// const db = getDb();
const notificationService = new NotificationService(); // 实例化 NotificationService const notificationService = new NotificationService(); // 实例化 NotificationService
// 黑名单相关设置的 Key // 黑名单相关设置的 Key
@@ -28,7 +25,7 @@ interface IpBlacklistEntry {
blocked_until: number | null; blocked_until: number | null;
} }
// Define the expected row structure from the database if it matches IpBlacklistEntry
type DbIpBlacklistRow = IpBlacklistEntry; type DbIpBlacklistRow = IpBlacklistEntry;
export class IpBlacklistService { export class IpBlacklistService {
@@ -95,18 +92,16 @@ export class IpBlacklistService {
const entry = await this.getEntry(ip); const entry = await this.getEntry(ip);
if (entry) { if (entry) {
// Update existing record
const newAttempts = entry.attempts + 1; const newAttempts = entry.attempts + 1;
let blockedUntil = entry.blocked_until; let blockedUntil = entry.blocked_until;
let shouldNotify = false; 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; blockedUntil = now + banDuration;
shouldNotify = true; shouldNotify = true;
console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`); console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`);
} else if (newAttempts >= maxAttempts && entry.blocked_until) { } else if (newAttempts >= maxAttempts && entry.blocked_until) {
console.log(`[IP Blacklist] IP ${ip} 再次登录失败,当前已处于封禁状态。`); console.log(`[IP Blacklist] IP ${ip} 再次登录失败,当前已处于封禁状态。`);
// Optionally extend ban duration here if needed
} }
await runDb(db, await runDb(db,
@@ -115,7 +110,6 @@ export class IpBlacklistService {
); );
if (shouldNotify && blockedUntil) { if (shouldNotify && blockedUntil) {
// Trigger notification after successful DB update
notificationService.sendNotification('IP_BLOCKED', { notificationService.sendNotification('IP_BLOCKED', {
ip: ip, ip: ip,
attempts: newAttempts, attempts: newAttempts,
@@ -142,7 +136,6 @@ export class IpBlacklistService {
); );
if (shouldNotify && blockedUntil) { if (shouldNotify && blockedUntil) {
// Trigger notification after successful DB insert
notificationService.sendNotification('IP_BLOCKED', { notificationService.sendNotification('IP_BLOCKED', {
ip: ip, ip: ip,
attempts: attempts, attempts: attempts,
@@ -153,7 +146,6 @@ export class IpBlacklistService {
} }
} catch (error: any) { } catch (error: any) {
console.error(`[IP Blacklist] 记录 IP ${ip} 失败尝试时出错:`, error.message); 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} 的失败尝试记录。`); console.log(`[IP Blacklist] 已重置 IP ${ip} 的失败尝试记录。`);
} catch (error: any) { } catch (error: any) {
console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error.message); console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error.message);
// Avoid throwing error here
} }
} }
@@ -189,9 +180,7 @@ export class IpBlacklistService {
return { entries, total }; return { entries, total };
} catch (error: any) { } catch (error: any) {
console.error('[IP Blacklist] 获取黑名单列表时出错:', error.message); console.error('[IP Blacklist] 获取黑名单列表时出错:', error.message);
// Return empty list on error? Or re-throw? return { entries: [], total: 0 };
// throw new Error('获取黑名单列表失败');
return { entries: [], total: 0 }; // Return empty on error
} }
} }
@@ -213,7 +202,7 @@ export class IpBlacklistService {
} }
} catch (error: any) { } catch (error: any) {
console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, error.message); 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, EmailConfig,
TelegramConfig, TelegramConfig,
NotificationChannelConfig, NotificationChannelConfig,
NotificationChannelType // Import the missing type NotificationChannelType
} from '../types/notification.types'; } from '../types/notification.types';
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter import Mail from 'nodemailer/lib/mailer';
import i18next, { defaultLng, supportedLngs } from '../i18n'; // Import i18next instance and config import i18next, { defaultLng, supportedLngs } from '../i18n';
import { settingsService } from './settings.service'; // Import settings service import { settingsService } from './settings.service';
// Removed logger import
// Define translation keys for test notifications for clarity
const testSubjectKey = 'testNotification.subject'; const testSubjectKey = 'testNotification.subject';
const testEmailBodyKey = 'testNotification.email.body'; 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 testWebhookDetailsKey = 'testNotification.webhook.detailsMessage';
const testTelegramDetailsKey = 'testNotification.telegram.detailsMessage'; const testTelegramDetailsKey = 'testNotification.telegram.detailsMessage';
const testTelegramBodyTemplateKey = 'testNotification.telegram.bodyTemplate'; // Key for the template itself const testTelegramBodyTemplateKey = 'testNotification.telegram.bodyTemplate';
export class NotificationService { export class NotificationService {
private repository: NotificationSettingsRepository; private repository: NotificationSettingsRepository;
@@ -41,14 +39,10 @@ export class NotificationService {
} }
async createSetting(settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> { async createSetting(settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
// Add validation if needed
return this.repository.create(settingData); return this.repository.create(settingData);
} }
async updateSetting(id: number, settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> { 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); return this.repository.update(id, settingData);
} }
@@ -56,9 +50,7 @@ export class NotificationService {
return this.repository.delete(id); return this.repository.delete(id);
} }
// --- Test Notification Methods ---
// Generic test method dispatcher
async testSetting(channelType: NotificationChannelType, config: NotificationChannelConfig): Promise<{ success: boolean; message: string }> { async testSetting(channelType: NotificationChannelType, config: NotificationChannelConfig): Promise<{ success: boolean; message: string }> {
switch (channelType) { switch (channelType) {
case 'email': case 'email':
@@ -68,114 +60,91 @@ export class NotificationService {
case 'telegram': case 'telegram':
return this._testTelegramSetting(config as TelegramConfig); return this._testTelegramSetting(config as TelegramConfig);
default: default:
console.warn(`[Notification Test] Unsupported channel type for testing: ${channelType}`); console.warn(`[通知测试] 不支持的测试渠道类型: ${channelType}`);
return { success: false, message: `不支持测试此渠道类型 (${channelType})` }; return { success: false, message: `不支持测试此渠道类型 (${channelType})` };
} }
} }
// Specific test method for Email
private async _testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> { 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) { 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 配置信息 (收件人, 主机, 端口, 发件人)。' }; return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 主机, 端口, 发件人)。' };
} }
// --- Fetch User Language ---
let userLang = defaultLng; let userLang = defaultLng;
try { try {
const langSetting = await settingsService.getSetting('language'); const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) { if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting; userLang = langSetting;
} }
console.log(`[Notification Test - Email] Using language: ${userLang}`); // Added log console.log(`[通知测试 - 邮件] 使用语言: ${userLang}`);
} catch (error) { } 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 = { const transporterOptions = {
host: config.smtpHost, host: config.smtpHost,
port: config.smtpPort, port: config.smtpPort,
secure: config.smtpSecure ?? true, // Default to true (TLS) secure: config.smtpSecure ?? true,
auth: (config.smtpUser || config.smtpPass) ? { auth: (config.smtpUser || config.smtpPass) ? {
user: config.smtpUser, user: config.smtpUser,
pass: config.smtpPass, // Ensure password is included if user is present pass: config.smtpPass,
} : undefined, } : undefined,
// Consider adding TLS options if needed, e.g., ignore self-signed certs
// tls: {
// rejectUnauthorized: false // Use with caution!
// }
}; };
const transporter = nodemailer.createTransport(transporterOptions); const transporter = nodemailer.createTransport(transporterOptions);
// Translate event display name first const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' });
const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' }); // Hardcoding event for test email
const mailOptions: Mail.Options = { const mailOptions: Mail.Options = {
from: config.from, from: config.from,
to: config.to, // Use the 'to' from config for testing to: config.to,
// Use i18next for subject and body, using fetched user language
subject: i18next.t(testSubjectKey, { lng: userLang, defaultValue: 'Nexus Terminal Test Notification ({eventDisplay})', eventDisplay: eventDisplayName }), 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 }), 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 }), 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 { 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); const info = await transporter.sendMail(mailOptions);
console.log(`[Notification Test - Email] Test email sent successfully: ${info.messageId}`); // Updated log prefix console.log(`[通知测试 - 邮件] 测试邮件发送成功: ${info.messageId}`);
// Verify connection if possible (optional)
// await transporter.verify();
// console.log('[Notification Test - Email] SMTP Connection verified.');
return { success: true, message: '测试邮件发送成功!请检查收件箱。' }; return { success: true, message: '测试邮件发送成功!请检查收件箱。' };
} catch (error: any) { } 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 || '未知错误'}` }; return { success: false, message: `测试邮件发送失败: ${error.message || '未知错误'}` };
} }
} }
// Specific test method for Webhook
private async _testWebhookSetting(config: WebhookConfig): Promise<{ success: boolean; message: string }> { 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) { if (!config.url) {
console.error('[Notification Test - Webhook] Missing URL.'); // Added log console.error('[通知测试 - Webhook] 缺少 URL');
return { success: false, message: '测试 Webhook 失败:缺少 URL。' }; return { success: false, message: '测试 Webhook 失败:缺少 URL。' };
} }
// --- Fetch User Language ---
let userLang = defaultLng; let userLang = defaultLng;
try { try {
const langSetting = await settingsService.getSetting('language'); const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) { if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting; userLang = langSetting;
} }
console.log(`[Notification Test - Webhook] Using language: ${userLang}`); // Added log console.log(`[通知测试 - Webhook] 使用语言: ${userLang}`);
} catch (error) { } 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 = { const testPayload: NotificationPayload = {
event: 'SETTINGS_UPDATED', // Use a valid event type event: 'SETTINGS_UPDATED',
timestamp: Date.now(), 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).' }) } 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 不是带有 message 属性的对象';
const translatedWebhookMessage = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details is not an object with message property'; console.log(`[通知测试 - Webhook] 测试负载已创建。翻译后的 details.message:`, translatedWebhookMessage);
console.log(`[Notification Test - Webhook] Test payload created. Translated details.message:`, translatedWebhookMessage); // Added log with type check
// Use the same rendering logic as actual sending
// Translate event display name
const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event }); 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 defaultBody = JSON.stringify(testPayload, null, 2);
const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`; // Updated default template text const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`;
// Pass eventDisplayName to renderTemplate
const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, testPayload, defaultBody, eventDisplayName); const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, testPayload, defaultBody, eventDisplayName);
const requestConfig: AxiosRequestConfig = { const requestConfig: AxiosRequestConfig = {
@@ -186,130 +155,107 @@ export class NotificationService {
...(config.headers || {}), ...(config.headers || {}),
}, },
data: requestBody, data: requestBody,
timeout: 15000, // Slightly longer timeout for testing timeout: 15000,
}; };
try { 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); 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})。` }; return { success: true, message: `测试 Webhook 发送成功 (状态码: ${response.status})。` };
} catch (error: any) { } catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data || error.message || '未知错误'; 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}` }; return { success: false, message: `测试 Webhook 发送失败: ${errorMessage}` };
} }
} }
// Specific test method for Telegram
private async _testTelegramSetting(config: TelegramConfig): Promise<{ success: boolean; message: string }> { 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) { 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。' }; return { success: false, message: '测试 Telegram 失败:缺少机器人 Token 或聊天 ID。' };
} }
// --- Fetch User Language ---
let userLang = defaultLng; let userLang = defaultLng;
try { try {
const langSetting = await settingsService.getSetting('language'); const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) { if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting; userLang = langSetting;
} }
console.log(`[Notification Test - Telegram] Using language: ${userLang}`); // Added log console.log(`[通知测试 - Telegram] 使用语言: ${userLang}`);
} catch (error) { } 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 = { const testPayload: NotificationPayload = {
event: 'SETTINGS_UPDATED', event: 'SETTINGS_UPDATED',
timestamp: Date.now(), timestamp: Date.now(),
details: undefined // Initialize details as undefined details: undefined
}; };
// --- Translation Start --- const detailsOptions = { lng: userLang, defaultValue: 'Fallback: This is a test notification from Nexus Terminal (Telegram).' };
// Log options before calling t() for details message const keyWithNamespace = `notifications:${testTelegramDetailsKey}`;
const detailsOptions = { lng: userLang, defaultValue: 'Fallback: This is a test notification from Nexus Terminal (Telegram).' }; // Use userLang const translatedDetailsMessage = i18next.t(keyWithNamespace, detailsOptions);
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 ---
// Assign the translated details to the existing payload object
testPayload.details = { message: translatedDetailsMessage }; 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'; 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 templateKeyWithNamespace = `notifications:${testTelegramBodyTemplateKey}`;
const defaultMessageTemplateFromI18n = i18next.t(templateKeyWithNamespace, { const defaultMessageTemplateFromI18n = i18next.t(templateKeyWithNamespace, {
lng: userLang, // Use userLang lng: userLang,
defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\`` // Added Fallback prefix 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; 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 }); const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event });
// Render the template, passing eventDisplayName
const messageText = this._renderTemplate(templateToUse, testPayload, '', 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`; const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
try { 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, { const response = await axios.post(telegramApiUrl, {
chat_id: config.chatId, chat_id: config.chatId,
text: messageText, text: messageText,
parse_mode: 'Markdown' // Add parse_mode for testing consistency parse_mode: 'Markdown'
}, { timeout: 15000 }); // Slightly longer timeout for testing }, { timeout: 15000 });
if (response.data?.ok) { 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 消息发送成功!' }; return { success: true, message: '测试 Telegram 消息发送成功!' };
} else { } 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 返回失败'}` }; return { success: false, message: `测试 Telegram 发送失败: ${response.data?.description || 'API 返回失败'}` };
} }
} catch (error: any) { } catch (error: any) {
const errorMessage = error.response?.data?.description || error.response?.data || error.message || '未知错误'; 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}` }; return { success: false, message: `测试 Telegram 发送失败: ${errorMessage}` };
} }
} }
// --- Core Notification Sending Logic ---
async sendNotification(event: NotificationEvent, details?: Record<string, any> | string): Promise<void> { 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; let userLang = defaultLng;
try { try {
// Assuming settingsService is available or needs instantiation if not singleton
const langSetting = await settingsService.getSetting('language'); const langSetting = await settingsService.getSetting('language');
if (langSetting && supportedLngs.includes(langSetting)) { if (langSetting && supportedLngs.includes(langSetting)) {
userLang = langSetting; userLang = langSetting;
} }
} catch (error) { } catch (error) {
console.error(`[Notification] Error fetching language setting for event ${event}:`, error); console.error(`[通知] 获取事件 ${event} 的语言设置时出错:`, error);
// Proceed with default language
} }
console.log(`[Notification] Using language '${userLang}' for event ${event}`); console.log(`[通知] 事件 ${event} 使用语言 '${userLang}'`);
const payload: NotificationPayload = { const payload: NotificationPayload = {
event, event,
@@ -319,224 +265,186 @@ export class NotificationService {
try { try {
const applicableSettings = await this.repository.getEnabledByEvent(event); 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) { if (applicableSettings.length === 0) {
return; // No enabled settings for this event return; // 此事件没有启用的设置
} }
const sendPromises = applicableSettings.map(setting => { const sendPromises = applicableSettings.map(setting => {
switch (setting.channel_type) { switch (setting.channel_type) {
case 'webhook': case 'webhook':
return this._sendWebhook(setting, payload, userLang); // Pass userLang return this._sendWebhook(setting, payload, userLang);
case 'email': case 'email':
return this._sendEmail(setting, payload, userLang); // Pass userLang return this._sendEmail(setting, payload, userLang);
case 'telegram': case 'telegram':
return this._sendTelegram(setting, payload, userLang); // Pass userLang return this._sendTelegram(setting, payload, userLang);
default: default:
console.warn(`[Notification] Unknown channel type: ${setting.channel_type} for setting ID ${setting.id}`); console.warn(`[通知] 未知渠道类型: ${setting.channel_type} (设置 ID: ${setting.id})`);
return Promise.resolve(); // Don't fail all if one is unknown return Promise.resolve(); // 如果有一个未知,不要让所有都失败
} }
}); });
// Wait for all notifications to be attempted
await Promise.allSettled(sendPromises); await Promise.allSettled(sendPromises);
console.log(`[Notification] Finished attempting notifications for event ${event}`); console.log(`[通知] 完成尝试发送事件 ${event} 的通知`);
} catch (error) { } catch (error) {
console.error(`[Notification] Error fetching or processing settings for event ${event}:`, error); console.error(`[通知] 获取或处理事件 ${event} 的设置时出错:`, error);
// Decide if this error itself should trigger a notification (e.g., SERVER_ERROR)
// Be careful to avoid infinite loops
} }
} }
// --- Private Sending Helpers ---
// Updated to accept eventDisplayName
private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string, eventDisplayName?: string): string { private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string, eventDisplayName?: string): string {
if (!template) return defaultText; if (!template) return defaultText;
let rendered = template; let rendered = template;
// Replace single-brace placeholders rendered = rendered.replace(/\{event\}/g, payload.event);
rendered = rendered.replace(/\{event\}/g, payload.event); // Keep original event code if needed rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event);
rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event); // Use translated name, fallback to original code
rendered = rendered.replace(/\{timestamp\}/g, new Date(payload.timestamp).toISOString()); rendered = rendered.replace(/\{timestamp\}/g, new Date(payload.timestamp).toISOString());
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
rendered = rendered.replace(/\{details\}/g, detailsString); rendered = rendered.replace(/\{details\}/g, detailsString);
return rendered; return rendered;
} }
// Updated to accept userLang
private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> { private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
const config = setting.config as WebhookConfig; const config = setting.config as WebhookConfig;
if (!config.url) { if (!config.url) {
console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`); console.error(`[通知] Webhook 设置 ID ${setting.id} 缺少 URL`);
return; return;
} }
// Translate event display name
const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event }); 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 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 const defaultBody = JSON.stringify(translatedPayload, null, 2);
// Note: Webhook body templates might need adjustments if they expect specific structures const defaultBodyTemplate = `Default: JSON payload. Use {eventDisplay}, {timestamp}, {details}.`;
// 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 requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, translatedPayload, defaultBody, eventDisplayName); const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, translatedPayload, defaultBody, eventDisplayName);
const requestConfig: AxiosRequestConfig = { const requestConfig: AxiosRequestConfig = {
method: config.method || 'POST', method: config.method || 'POST',
url: config.url, url: config.url,
headers: { headers: {
'Content-Type': 'application/json', // Default, can be overridden by config.headers 'Content-Type': 'application/json',
...(config.headers || {}), ...(config.headers || {}),
}, },
data: requestBody, data: requestBody,
timeout: 10000, // Add a timeout (e.g., 10 seconds) timeout: 10000,
}; };
try { 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); 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) { } catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data || error.message; 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> { private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
const config = setting.config as EmailConfig; const config = setting.config as EmailConfig;
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { 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; return;
} // <-- Add missing closing brace here }
// Let TypeScript infer the options type for SMTP
const transporterOptions = { const transporterOptions = {
host: config.smtpHost, host: config.smtpHost,
port: config.smtpPort, port: config.smtpPort,
secure: config.smtpSecure ?? true, // Default to true (TLS) secure: config.smtpSecure ?? true,
auth: (config.smtpUser || config.smtpPass) ? { auth: (config.smtpUser || config.smtpPass) ? {
user: config.smtpUser, user: config.smtpUser,
pass: config.smtpPass, // Ensure password is included if user is present pass: config.smtpPass,
} : undefined, } : undefined,
// tls: { rejectUnauthorized: false } // Add if needed for self-signed certs, USE WITH CAUTION
}; };
const transporter = nodemailer.createTransport(transporterOptions); 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 }; const i18nOptions: Record<string, any> = { lng: userLang };
if (payload.details && typeof payload.details === 'object') { 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) { } 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 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}`;
const defaultSubjectKey = `event.${payload.event}`; // This key might not exist, rely on template or default below const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`;
const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`; // Use eventDisplay in fallback const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName });
const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName }); // Pass eventDisplay for interpolation in fallback
// Use default subject template from i18n if user hasn't provided one const defaultSubjectTemplateKey = 'testNotification.subject';
const defaultSubjectTemplateKey = 'testNotification.subject'; // Reuse test subject key structure
const defaultSubjectTemplate = i18next.t(defaultSubjectTemplateKey, { lng: userLang, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName }); 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); 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 bodyKey = `eventBody.${payload.event}`;
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); 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}`; 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 }); 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 = { const mailOptions: Mail.Options = {
from: config.from, from: config.from,
to: config.to, to: config.to,
subject: subject, subject: subject,
text: body, text: body,
// html: `<p>${body.replace(/\n/g, '<br>')}</p>` // Simple HTML version
}; };
try { 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); 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) { } 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> { private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
const config = setting.config as TelegramConfig; const config = setting.config as TelegramConfig;
if (!config.botToken || !config.chatId) { 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; return;
} }
// Translate message using i18next
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
const i18nOptions: Record<string, any> = { lng: userLang }; const i18nOptions: Record<string, any> = { lng: userLang };
if (payload.details && typeof payload.details === 'object') { 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) { } 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 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\`\`\`` : ''; 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}`; 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 }); 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 defaultTemplateKey = `notifications:${testTelegramBodyTemplateKey}`;
const defaultMessageTemplateFromI18n = i18next.t(defaultTemplateKey, { lng: userLang, defaultValue: translatedBody, eventDisplay: eventDisplayName }); 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 messageText = this._renderTemplate(config.messageTemplate || defaultMessageTemplateFromI18n, payload, translatedBody, eventDisplayName);
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
try { 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, { const response = await axios.post(telegramApiUrl, {
chat_id: config.chatId, chat_id: config.chatId,
text: messageText, text: messageText,
parse_mode: 'Markdown', // Keep Markdown for actual sending, user is responsible for valid syntax parse_mode: 'Markdown',
}, { timeout: 10000 }); // Add timeout }, { timeout: 10000 });
console.log(`[Notification] Telegram message sent successfully. Response OK:`, response.data?.ok); console.log(`[通知] Telegram 消息发送成功。响应 OK:`, response.data?.ok);
} catch (error: any) { } catch (error: any) {
const errorMessage = error.response?.data?.description || error.response?.data || error.message; 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 { private _translatePayloadDetails(details: any, lng: string): any {
if (!details || typeof details !== 'object') { 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) { if (details.testResult === 'success' && details.connectionName) {
return { return {
...details, ...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 && Array.isArray(details.updatedKeys)) {
if (details.updatedKeys.includes('ipWhitelist')) { if (details.updatedKeys.includes('ipWhitelist')) {
return { ...details, message: i18next.t('settings.ipWhitelistUpdated', { lng, defaultValue: 'IP Whitelist updated successfully.' }) }; 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.' }) }; 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 details; // Return original details if no specific translation logic matched
} }
} }
// Optional: Export a singleton instance if needed throughout the backend
// export const notificationService = new NotificationService();
@@ -13,13 +13,11 @@ import type {
VerifyAuthenticationResponseOpts, VerifyAuthenticationResponseOpts,
RegistrationResponseJSON, RegistrationResponseJSON,
AuthenticationResponseJSON, AuthenticationResponseJSON,
// AuthenticatorDevice is not typically needed here } from '@simplewebauthn/server';
} from '@simplewebauthn/server'; // Import types directly from the package
import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository'; import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository';
import { settingsService } from './settings.service'; // Import the exported object
// 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置 // 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置
// TODO: 从 SettingsService 或环境变量获取这些值
const rpName = 'Nexus Terminal'; const rpName = 'Nexus Terminal';
// 重要: rpID 应该是你的网站域名 (不包含协议和端口) // 重要: rpID 应该是你的网站域名 (不包含协议和端口)
// 对于本地开发,通常是 'localhost' // 对于本地开发,通常是 'localhost'
@@ -29,13 +27,11 @@ const expectedOrigin = process.env.FRONTEND_URL || 'http://localhost:5173'; //
export class PasskeyService { export class PasskeyService {
private passkeyRepository: PasskeyRepository; 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() { constructor() {
this.passkeyRepository = new PasskeyRepository(); 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 需要一个用户名 async generateRegistrationOptions(userName: string = 'nexus-user') { // WebAuthn 需要一个用户名
// 暂时不获取已存在的凭证,允许同一用户注册多个设备 // 暂时不获取已存在的凭证,允许同一用户注册多个设备
// const existingCredentials = await this.passkeyRepository.getAllPasskeys();
const options: GenerateRegistrationOptionsOpts = { const options: GenerateRegistrationOptionsOpts = {
rpName, rpName,
rpID, rpID,
userID: Buffer.from(userName), // userID should be a Buffer/Uint8Array userID: Buffer.from(userName), // userID should be a Buffer/Uint8Array
userName: userName, userName: userName,
// 不建议排除已存在的凭证,除非有特定原因
// excludeCredentials: existingCredentials.map(cred => ({
// id: cred.credential_id, // 需要是 Base64URL 格式,存储时确保是这个格式
// type: 'public-key',
// transports: cred.transports ? JSON.parse(cred.transports) : undefined,
// })),
authenticatorSelection: { authenticatorSelection: {
// authenticatorAttachment: 'platform', // 倾向于平台认证器 (如 Windows Hello, Touch ID)
userVerification: 'preferred', // 倾向于需要用户验证 (PIN, 生物识别) userVerification: 'preferred', // 倾向于需要用户验证 (PIN, 生物识别)
residentKey: 'preferred', // 倾向于创建可发现凭证 (存储在认证器上) residentKey: 'preferred', // 倾向于创建可发现凭证 (存储在认证器上)
}, },
// 可选:增加超时时间 // 可选:增加超时时间
timeout: 60000, // 60 秒 timeout: 60000, // 60 秒
// attestation: 'none', // Temporarily remove to resolve TS error, 'none' is often default
}; };
const registrationOptions = await generateRegistrationOptions(options); const registrationOptions = await generateRegistrationOptions(options);
// TODO: 需要将生成的 challenge 临时存储起来 (例如在 session 或 内存缓存中),以便后续验证
// 这里暂时返回 challenge,让 Controller 处理存储
return registrationOptions; return registrationOptions;
} }
@@ -105,15 +92,10 @@ export class PasskeyService {
if (verification.verified && verification.registrationInfo) { if (verification.verified && verification.registrationInfo) {
// Use type assertion to bypass strict type checking for registrationInfo properties
const registrationInfo = verification.registrationInfo as any; const registrationInfo = verification.registrationInfo as any;
const { credentialPublicKey, credentialID, counter } = registrationInfo; 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 credentialIdBase64Url = Buffer.from(credentialID).toString('base64url');
const publicKeyBase64Url = Buffer.from(credentialPublicKey).toString('base64url'); const publicKeyBase64Url = Buffer.from(credentialPublicKey).toString('base64url');
@@ -140,16 +122,11 @@ export class PasskeyService {
* Passkey () * Passkey ()
*/ */
async generateAuthenticationOptions(): Promise<ReturnType<typeof generateAuthenticationOptions>> { 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 = { const options: GenerateAuthenticationOptionsOpts = {
rpID, rpID,
// allowCredentials: allowedCredentials, // 如果只想允许已注册的凭证
userVerification: 'preferred', // 倾向于需要用户验证 userVerification: 'preferred', // 倾向于需要用户验证
timeout: 60000, // 60 秒 timeout: 60000, // 60 秒
}; };
@@ -178,45 +155,31 @@ export class PasskeyService {
throw new Error(`未找到 Credential ID 为 ${credentialIdBase64Url} 的认证器`); 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 = { const verificationOptions: VerifyAuthenticationResponseOpts = {
response: authenticationResponse, response: authenticationResponse,
expectedChallenge: expectedChallenge, expectedChallenge: expectedChallenge,
expectedOrigin: expectedOrigin, expectedOrigin: expectedOrigin,
expectedRPID: rpID, 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: { authenticator: {
credentialID: Buffer.from(authenticator.credential_id, 'base64url'), credentialID: Buffer.from(authenticator.credential_id, 'base64url'),
credentialPublicKey: Buffer.from(authenticator.public_key, 'base64url'), credentialPublicKey: Buffer.from(authenticator.public_key, 'base64url'),
counter: authenticator.counter, counter: authenticator.counter,
transports: authenticator.transports ? JSON.parse(authenticator.transports) : undefined, transports: authenticator.transports ? JSON.parse(authenticator.transports) : undefined,
}, },
requireUserVerification: true, // simplewebauthn defaults this to true now requireUserVerification: true,
} as any; // Use type assertion to bypass strict property check for 'authenticator' } as any;
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
verification = await verifyAuthenticationResponse(verificationOptions); verification = await verifyAuthenticationResponse(verificationOptions);
} catch (error: any) { } catch (error: any) {
// If verification fails, log the error but potentially re-throw a more generic one
console.error('Passkey 认证验证时发生异常:', error); console.error('Passkey 认证验证时发生异常:', error);
const err = error as 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)) { if (!err.message.includes(credentialIdBase64Url)) {
throw new Error(`Passkey authentication verification failed: ${err.message || err}`); 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; throw error;
} }
+28 -36
View File
@@ -1,23 +1,20 @@
import * as ProxyRepository from '../repositories/proxy.repository'; 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 {} export interface ProxyData extends ProxyRepository.ProxyData {}
// Input type for creating a proxy
export interface CreateProxyInput { export interface CreateProxyInput {
name: string; name: string;
type: 'SOCKS5' | 'HTTP'; type: 'SOCKS5' | 'HTTP';
host: string; host: string;
port: number; port: number;
username?: string | null; username?: string | null;
auth_method?: 'none' | 'password' | 'key'; // Optional, defaults to 'none' auth_method?: 'none' | 'password' | 'key';
password?: string | null; // Plain text password password?: string | null;
private_key?: string | null; // Plain text private key private_key?: string | null;
passphrase?: string | null; // Plain text passphrase passphrase?: string | null;
} }
// Input type for updating a proxy
export interface UpdateProxyInput { export interface UpdateProxyInput {
name?: string; name?: string;
type?: 'SOCKS5' | 'HTTP'; type?: 'SOCKS5' | 'HTTP';
@@ -25,9 +22,9 @@ export interface UpdateProxyInput {
port?: number; port?: number;
username?: string | null; username?: string | null;
auth_method?: 'none' | 'password' | 'key'; auth_method?: 'none' | 'password' | 'key';
password?: string | null; // Use undefined for no change, null/empty to clear password?: string | null;
private_key?: string | null; // Use undefined for no change, null/empty to clear private_key?: string | null;
passphrase?: string | null; // Use undefined for no change, null/empty to clear passphrase?: string | null;
} }
@@ -35,8 +32,6 @@ export interface UpdateProxyInput {
* *
*/ */
export const getAllProxies = async (): Promise<ProxyData[]> => { 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(); return ProxyRepository.findAllProxies();
}; };
@@ -44,7 +39,6 @@ export const getAllProxies = async (): Promise<ProxyData[]> => {
* ID * ID
*/ */
export const getProxyById = async (id: number): Promise<ProxyData | null> => { export const getProxyById = async (id: number): Promise<ProxyData | null> => {
// Repository returns data with encrypted fields
return ProxyRepository.findProxyById(id); 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> => { export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> => {
// 1. Validate input // 1. 验证输入
if (!input.name || !input.type || !input.host || !input.port) { if (!input.name || !input.type || !input.host || !input.port) {
throw new Error('缺少必要的代理信息 (name, type, host, 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) { if (input.auth_method === 'key' && !input.private_key) {
throw new Error('代理密钥认证方式需要提供 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 encryptedPassword = input.password ? encrypt(input.password) : null;
const encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null; const encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null;
const encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null; const encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null;
// 3. Prepare data for repository // 3. 准备仓库数据
const proxyData: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'> = { const proxyData: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'> = {
name: input.name, name: input.name,
type: input.type, type: input.type,
@@ -82,10 +75,10 @@ export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> =
encrypted_passphrase: encryptedPassphrase, encrypted_passphrase: encryptedPassphrase,
}; };
// 4. Create proxy record // 4. 创建代理记录
const newProxyId = await ProxyRepository.createProxy(proxyData); const newProxyId = await ProxyRepository.createProxy(proxyData);
// 5. Fetch and return the newly created proxy // 5. 获取并返回新创建的代理
const newProxy = await getProxyById(newProxyId); const newProxy = await getProxyById(newProxyId);
if (!newProxy) { if (!newProxy) {
throw new Error('创建代理后无法检索到该代理。'); 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> => { 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); const currentProxy = await ProxyRepository.findProxyById(id);
if (!currentProxy) { if (!currentProxy) {
return null; // Proxy not found return null; // 未找到代理
} }
// 2. Prepare data for update // 2. 准备更新数据
const dataToUpdate: Partial<Omit<ProxyData, 'id' | 'created_at'>> = {}; const dataToUpdate: Partial<Omit<ProxyData, 'id' | 'created_at'>> = {};
let needsCredentialUpdate = false; let needsCredentialUpdate = false;
const newAuthMethod = input.auth_method || currentProxy.auth_method; const newAuthMethod = input.auth_method || currentProxy.auth_method;
// Update standard fields // 更新标准字段
if (input.name !== undefined) dataToUpdate.name = input.name; if (input.name !== undefined) dataToUpdate.name = input.name;
if (input.type !== undefined) dataToUpdate.type = input.type; if (input.type !== undefined) dataToUpdate.type = input.type;
if (input.host !== undefined) dataToUpdate.host = input.host; if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port; 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) { if (input.auth_method && input.auth_method !== currentProxy.auth_method) {
dataToUpdate.auth_method = input.auth_method; dataToUpdate.auth_method = input.auth_method;
needsCredentialUpdate = true; needsCredentialUpdate = true;
// Encrypt new credentials based on the *new* auth_method // 根据 *新* 认证方法加密新凭证
if (input.auth_method === 'password') { if (input.auth_method === 'password') {
if (input.password === undefined) throw new Error('切换到密码认证时需要提供 password。'); if (input.password === undefined) throw new Error('切换到密码认证时需要提供 password。');
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; 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; dataToUpdate.encrypted_passphrase = null;
} else if (input.auth_method === 'key') { } else if (input.auth_method === 'key') {
if (input.private_key === undefined) throw new Error('切换到密钥认证时需要提供 private_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_private_key = input.private_key ? encrypt(input.private_key) : null;
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
dataToUpdate.encrypted_password = null; // Clear old password info dataToUpdate.encrypted_password = null; // 清除旧密码信息
} else { // 'none' } else { // ''
dataToUpdate.encrypted_password = null; dataToUpdate.encrypted_password = null;
dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null; dataToUpdate.encrypted_passphrase = null;
} }
} else { } else {
// Auth method unchanged, update credentials if provided for the current method // 认证方法未更改,如果为当前方法提供了凭证,则更新凭证
if (newAuthMethod === 'password' && input.password !== undefined) { if (newAuthMethod === 'password' && input.password !== undefined) {
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
needsCredentialUpdate = true; needsCredentialUpdate = true;
} else if (newAuthMethod === 'key') { } else if (newAuthMethod === 'key') {
if (input.private_key !== undefined) { if (input.private_key !== undefined) {
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null; 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; needsCredentialUpdate = true;
} else if (input.passphrase !== undefined) { // Only passphrase updated } else if (input.passphrase !== undefined) { // 仅更新密码短语
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true; needsCredentialUpdate = true;
} }
} }
} }
// 3. Update proxy record if there are changes // 3. 如果有更改,则更新代理记录
const hasChanges = Object.keys(dataToUpdate).length > 0; const hasChanges = Object.keys(dataToUpdate).length > 0;
if (hasChanges) { if (hasChanges) {
const updated = await ProxyRepository.updateProxy(id, dataToUpdate); 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); return getProxyById(id);
}; };
@@ -169,6 +162,5 @@ export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<
* *
*/ */
export const deleteProxy = async (id: number): Promise<boolean> => { export const deleteProxy = async (id: number): Promise<boolean> => {
// Repository handles setting foreign keys to NULL in connections table
return ProxyRepository.deleteProxy(id); return ProxyRepository.deleteProxy(id);
}; };
@@ -3,16 +3,16 @@ import {
Setting, Setting,
getSidebarConfig as getSidebarConfigFromRepo, getSidebarConfig as getSidebarConfigFromRepo,
setSidebarConfig as setSidebarConfigInRepo, setSidebarConfig as setSidebarConfigInRepo,
getCaptchaConfig as getCaptchaConfigFromRepo, // <-- Import CAPTCHA repo getter getCaptchaConfig as getCaptchaConfigFromRepo,
setCaptchaConfig as setCaptchaConfigInRepo, // <-- Import CAPTCHA repo setter setCaptchaConfig as setCaptchaConfigInRepo,
} from '../repositories/settings.repository'; } from '../repositories/settings.repository';
import { import {
SidebarConfig, SidebarConfig,
PaneName, PaneName,
UpdateSidebarConfigDto, UpdateSidebarConfigDto,
CaptchaSettings, // <-- Import CAPTCHA types CaptchaSettings,
UpdateCaptchaSettingsDto, // <-- Import CAPTCHA types UpdateCaptchaSettingsDto,
CaptchaProvider, // <-- Import CAPTCHA types CaptchaProvider,
} from '../types/settings.types'; } from '../types/settings.types';
// +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++ // +++ 定义焦点切换完整配置接口 (与前端 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')) 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)); 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; return config as FocusSwitcherFullConfig;
} else { } else {
console.warn('[Service] Invalid full focus switcher config format found in settings. Returning default.'); console.warn('[Service] Invalid full focus switcher config format found in settings. Returning default.');
+1 -11
View File
@@ -3,7 +3,7 @@ import { SocksClient, SocksClientOptions } from 'socks';
import http from 'http'; import http from 'http';
import net from 'net'; import net from 'net';
import * as ConnectionRepository from '../repositories/connection.repository'; 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'; import { decrypt } from '../utils/crypto';
const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒) const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒)
@@ -123,7 +123,6 @@ export const establishSshConnection = (
console.log(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 成功。`); console.log(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 成功。`);
sshClient.removeListener('error', errorHandler); // 成功后移除错误监听器 sshClient.removeListener('error', errorHandler); // 成功后移除错误监听器
// --- 新增:更新 last_connected_at ---
try { try {
const currentTimeSeconds = Math.floor(Date.now() / 1000); const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connDetails.id, currentTimeSeconds); await ConnectionRepository.updateLastConnected(connDetails.id, currentTimeSeconds);
@@ -132,7 +131,6 @@ export const establishSshConnection = (
// 更新失败不应阻止连接成功,但需要记录错误 // 更新失败不应阻止连接成功,但需要记录错误
console.error(`SshService: 更新连接 ${connDetails.id} 的 last_connected_at 失败:`, updateError); console.error(`SshService: 更新连接 ${connDetails.id} 的 last_connected_at 失败:`, updateError);
} }
// --- 结束新增 ---
resolve(sshClient); // 返回 Client 实例 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 { Client } from 'ssh2';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { ClientState } from '../websocket'; // 导入统一的 ClientState import { ClientState } from '../websocket';
import { settingsService } from './settings.service'; // +++ 导入 settingsService +++ import { settingsService } from './settings.service';
// 定义服务器状态的数据结构 (与前端 StatusMonitor.vue 匹配)
interface ServerStatus { interface ServerStatus {
cpuPercent?: number; cpuPercent?: number;
memPercent?: number; memPercent?: number;
@@ -24,7 +24,7 @@ interface ServerStatus {
timestamp: number; // 状态获取时间戳 timestamp: number; // 状态获取时间戳
} }
// Interface for parsed network stats
interface NetworkStats { interface NetworkStats {
[interfaceName: string]: { [interfaceName: string]: {
rx_bytes: number; 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 }>(); const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
@@ -46,16 +46,14 @@ export class StatusMonitorService {
/** /**
* *
* @param sessionId ID * @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); const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient) { if (!state || !state.sshClient) {
//console.warn(`[StatusMonitor] 无法为会话 ${sessionId} 启动状态轮询:状态无效或 SSH 客户端不存在。`);
return; return;
} }
if (state.statusIntervalId) { if (state.statusIntervalId) {
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
return; return;
} }
@@ -70,7 +68,6 @@ export class StatusMonitorService {
intervalMs = 3000; // 出错时回退到 3 秒 intervalMs = 3000; // 出错时回退到 3 秒
} }
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${intervalMs}ms`);
// 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间 // 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间
state.statusIntervalId = setInterval(() => { state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId); this.fetchAndSendServerStatus(sessionId);
@@ -130,35 +127,31 @@ export class StatusMonitorService {
const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release'); const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release');
const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m); const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m);
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown'); 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 { try {
let cpuModelOutput = ''; let cpuModelOutput = '';
try { 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"); 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(); status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim();
} catch (procErr) { } 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 { try {
cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'"); cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim(); status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim();
} catch (lscpuErr) { } 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) { if (!status.cpuModel) {
status.cpuModel = 'Unknown'; status.cpuModel = 'Unknown';
} }
} catch (err) { // Catch any unexpected error during the process } catch (err) {
// console.warn(`[StatusMonitor ${sessionId}] Error getting CPU model:`, err); // --- 移除 console.warn ---
status.cpuModel = 'Unknown'; status.cpuModel = 'Unknown';
} }
// --- Memory and Swap ---
try { try {
const freeOutput = await this.executeSshCommand(sshClient, 'free -m'); const freeOutput = await this.executeSshCommand(sshClient, 'free -m');
const lines = freeOutput.split('\n'); const lines = freeOutput.split('\n');
@@ -186,17 +179,15 @@ export class StatusMonitorService {
} }
} }
} else { status.swapTotal = 0; status.swapUsed = 0; status.swapPercent = 0; } } 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 { try {
// 使用 df -kP / 获取 POSIX 标准格式输出,更稳定 // 使用 df -kP / 获取 POSIX 标准格式输出,更稳定
const dfOutput = await this.executeSshCommand(sshClient, "df -kP /"); const dfOutput = await this.executeSshCommand(sshClient, "df -kP /");
const lines = dfOutput.split('\n'); const lines = dfOutput.split('\n');
if (lines.length >= 2) { if (lines.length >= 2) {
const parts = lines[1].split(/\s+/); // 解析第二行 (数据行) 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(%)
if (parts.length >= 5) { if (parts.length >= 5) {
const total = parseInt(parts[1], 10); const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 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 { try {
const topOutput = await this.executeSshCommand(sshClient, "top -bn1 | grep '%Cpu(s)' | head -n 1"); 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 const idleMatch = topOutput.match(/(\d+\.?\d*)\s+id/); // Adjusted regex for float
@@ -217,16 +207,15 @@ export class StatusMonitorService {
const idlePercent = parseFloat(idleMatch[1]); const idlePercent = parseFloat(idleMatch[1]);
status.cpuPercent = parseFloat((100 - idlePercent).toFixed(1)); status.cpuPercent = parseFloat((100 - idlePercent).toFixed(1));
} }
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn --- } catch (err) { /* 静默处理 */ } //
// --- Load Average ---
try { try {
const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime'); const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime');
const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/); 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])]; if (match) status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])];
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn --- } catch (err) { /* 静默处理 */ }
// --- Network Rates ---
try { try {
const currentStats = await this.parseProcNetDev(sshClient); const currentStats = await this.parseProcNetDev(sshClient);
if (currentStats) { if (currentStats) {
@@ -238,18 +227,18 @@ export class StatusMonitorService {
const currentTx = currentStats[defaultInterface].tx_bytes; const currentTx = currentStats[defaultInterface].tx_bytes;
const prevStats = previousNetStats.get(sessionId); 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; 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.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds));
status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / 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; }
} else { status.netRxRate = 0; status.netTxRate = 0; } // First run or no time diff } else { status.netRxRate = 0; status.netTxRate = 0; }
previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp }); previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp });
} else { /* 静默处理 */ } // --- 移除 console.warn --- } else { /* 静默处理 */ }
} }
} catch (err) { /* 静默处理 */ } // --- 移除 console.warn --- } catch (err) { /* 静默处理 */ }
} catch (error) { } catch (error) {
console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, 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'); output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
} catch (error) { } catch (error) {
// 如果命令失败,记录警告并返回 null // 如果命令失败,记录警告并返回 null
// console.warn("[StatusMonitor] Failed to execute 'cat /proc/net/dev':", error); // --- 移除 console.warn ---
return null; return null;
} }
// 如果命令成功,继续解析 // 如果命令成功,继续解析
@@ -279,18 +268,16 @@ export class StatusMonitorService {
const stats: NetworkStats = {}; const stats: NetworkStats = {};
for (const line of lines) { for (const line of lines) {
const parts = line.trim().split(/:\s+|\s+/); 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 interfaceName = parts[0];
const rx_bytes = parseInt(parts[1], 10); 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)) { if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) {
stats[interfaceName] = { rx_bytes, tx_bytes }; stats[interfaceName] = { rx_bytes, tx_bytes };
} }
} }
return Object.keys(stats).length > 0 ? stats : null; return Object.keys(stats).length > 0 ? stats : null;
} catch (parseError) { } catch (parseError) {
// 如果解析失败,记录错误并返回 null
// console.error("[StatusMonitor] Error parsing /proc/net/dev output:", parseError); // --- 移除 console.error ---
return null; return null;
} }
} }
@@ -307,11 +294,10 @@ export class StatusMonitorService {
const interfaceName = output.trim(); const interfaceName = output.trim();
if (interfaceName) return interfaceName; if (interfaceName) return interfaceName;
// 如果 ip route 没返回有效接口名,也尝试 fallback // 如果 ip route 没返回有效接口名,也尝试 fallback
// console.warn("[StatusMonitor] 'ip route' did not return a valid interface name. Falling back..."); // --- 移除 console.warn ---
} catch (error) { } catch (error) {
// console.warn("[StatusMonitor] Failed to get default interface using 'ip route', falling back:", error); // --- 移除 console.warn ---
// Fallback: 尝试查找第一个非 lo 接口
try { try {
const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev'); const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
const lines = netDevOutput.split('\n').slice(2); const lines = netDevOutput.split('\n').slice(2);
@@ -322,13 +308,12 @@ export class StatusMonitorService {
} }
} }
} catch (fallbackError) { } 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; 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; return null;
} }
@@ -347,16 +332,10 @@ export class StatusMonitorService {
return reject(new Error(`执行命令 '${command}' 失败: ${err.message}`)); return reject(new Error(`执行命令 '${command}' 失败: ${err.message}`));
} }
stream.on('close', (code: number, signal?: string) => { 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()); resolve(output.trim());
}).on('data', (data: Buffer) => { }).on('data', (data: Buffer) => {
output += data.toString('utf8'); output += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => { }).stderr.on('data', (data: Buffer) => {
// --- 移除 console.warn ---
// console.warn(`[StatusMonitor] Command '${command}' stderr: ${data.toString('utf8').trim()}`);
}); });
}); });
}); });
+8 -10
View File
@@ -21,16 +21,15 @@ export const getTagById = async (id: number): Promise<TagData | null> => {
* *
*/ */
export const createTag = async (name: string): Promise<TagData> => { export const createTag = async (name: string): Promise<TagData> => {
// 1. Validate input
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空。'); throw new Error('标签名称不能为空。');
} }
const trimmedName = name.trim(); const trimmedName = name.trim();
// 2. Create tag record
try { try {
const newTagId = await TagRepository.createTag(trimmedName); const newTagId = await TagRepository.createTag(trimmedName);
// 3. Fetch and return the newly created tag
const newTag = await getTagById(newTagId); const newTag = await getTagById(newTagId);
if (!newTag) { if (!newTag) {
throw new Error('创建标签后无法检索到该标签。'); throw new Error('创建标签后无法检索到该标签。');
@@ -40,7 +39,7 @@ export const createTag = async (name: string): Promise<TagData> => {
if (error.message.includes('UNIQUE constraint failed')) { if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`创建标签失败:标签名称 "${trimmedName}" 已存在。`); 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> => { export const updateTag = async (id: number, name: string): Promise<TagData | null> => {
// 1. Validate input
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空。'); throw new Error('标签名称不能为空。');
} }
const trimmedName = name.trim(); const trimmedName = name.trim();
// 2. Update tag record
try { try {
const updated = await TagRepository.updateTag(id, trimmedName); const updated = await TagRepository.updateTag(id, trimmedName);
if (!updated) { if (!updated) {
return null; // Tag not found or not updated return null;
} }
// 3. Fetch and return the updated tag
return getTagById(id); return getTagById(id);
} catch (error: any) { } catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) { if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`更新标签失败:标签名称 "${trimmedName}" 已存在。`); 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> => { export const deleteTag = async (id: number): Promise<boolean> => {
// Repository handles cascading deletes in connection_tags
return TagRepository.deleteTag(id); return TagRepository.deleteTag(id);
}; };
@@ -1,6 +1,5 @@
import * as terminalThemeRepository from '../repositories/terminal-theme.repository'; import * as terminalThemeRepository from '../repositories/terminal-theme.repository';
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types'; import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
// import { validate } from 'class-validator'; // 移除导入
import type { ITheme } from 'xterm'; import type { ITheme } from 'xterm';
/** /**
@@ -85,4 +84,3 @@ export const importTheme = async (themeData: ITheme, name: string): Promise<Term
return createNewTheme(dto); return createNewTheme(dto);
}; };
// 注意:导出功能通常在 Controller 层处理,根据 ID 获取主题数据后,设置响应头并发送 JSON 文件。
@@ -1,6 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { settingsService } from '../services/settings.service'; 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 { ipBlacklistService } from '../services/ip-blacklist.service';
import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types
@@ -41,7 +41,6 @@ export const settingsController = {
'fileManagerRowSizeMultiplier', // +++ 添加文件管理器行大小键 +++ 'fileManagerRowSizeMultiplier', // +++ 添加文件管理器行大小键 +++
'fileManagerColWidths', // +++ 添加文件管理器列宽键 +++ 'fileManagerColWidths', // +++ 添加文件管理器列宽键 +++
'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++ 'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++
// --- REMOVED old width keys ---
]; ];
const filteredSettings: Record<string, string> = {}; const filteredSettings: Record<string, string> = {};
for (const key in settingsToUpdate) { for (const key in settingsToUpdate) {
@@ -74,12 +73,12 @@ export const settingsController = {
*/ */
async getFocusSwitcherSequence(req: Request, res: Response): Promise<void> { async getFocusSwitcherSequence(req: Request, res: Response): Promise<void> {
try { try {
console.log('[Controller] Received request to get focus switcher sequence.'); console.log('[控制器] 收到获取焦点切换顺序的请求。');
const sequence = await settingsService.getFocusSwitcherSequence(); 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); res.json(sequence);
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取焦点切换顺序时出错:', error); console.error('[控制器] 获取焦点切换顺序时出错:', error);
res.status(500).json({ message: '获取焦点切换顺序失败', error: error.message }); res.status(500).json({ message: '获取焦点切换顺序失败', error: error.message });
} }
}, },
@@ -88,11 +87,11 @@ export const settingsController = {
* *
*/ */
async setFocusSwitcherSequence(req: Request, res: Response): Promise<void> { async setFocusSwitcherSequence(req: Request, res: Response): Promise<void> {
console.log('[Controller] Received request to set focus switcher sequence.'); console.log('[控制器] 收到设置焦点切换顺序的请求。');
try { try {
// +++ 修改:获取请求体并验证其是否符合 FocusSwitcherFullConfig 结构 +++ // +++ 修改:获取请求体并验证其是否符合 FocusSwitcherFullConfig 结构 +++
const fullConfig = req.body; const fullConfig = req.body;
console.log('[Controller] Request body fullConfig:', JSON.stringify(fullConfig)); console.log('[控制器] 请求体 fullConfig:', JSON.stringify(fullConfig));
// +++ 验证 FocusSwitcherFullConfig 结构 +++ // +++ 验证 FocusSwitcherFullConfig 结构 +++
if ( if (
@@ -101,23 +100,22 @@ export const settingsController = {
typeof fullConfig.shortcuts === 'object' && fullConfig.shortcuts !== null && 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'))) 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}>) 的对象' }); res.status(400).json({ message: '无效的请求体,必须是包含 sequence (string[]) 和 shortcuts (Record<string, {shortcut?: string}>) 的对象' });
return; return;
} }
console.log('[Controller] Calling settingsService.setFocusSwitcherSequence with validated full config...'); console.log('[控制器] 使用验证后的完整配置调用 settingsService.setFocusSwitcherSequence...');
// +++ 传递验证后的 fullConfig 给服务层 +++ // +++ 传递验证后的 fullConfig 给服务层 +++
await 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 console.log('[控制器] 记录审计操作: FOCUS_SWITCHER_SEQUENCE_UPDATED');
// auditLogService.logAction('FOCUS_SWITCHER_SEQUENCE_UPDATED', { config: fullConfig }); // Removed specific log
console.log('[控制器] 发送成功响应。');
console.log('[Controller] Sending success response.');
res.status(200).json({ message: '焦点切换顺序已成功更新' }); res.status(200).json({ message: '焦点切换顺序已成功更新' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 设置焦点切换顺序时出错:', error); console.error('[控制器] 设置焦点切换顺序时出错:', error);
if (error.message === 'Invalid sequence format provided.') { if (error.message === 'Invalid sequence format provided.') {
res.status(400).json({ message: '设置焦点切换顺序失败: 无效的格式', error: error.message }); res.status(400).json({ message: '设置焦点切换顺序失败: 无效的格式', error: error.message });
} else { } else {
@@ -131,12 +129,12 @@ export const settingsController = {
*/ */
async getNavBarVisibility(req: Request, res: Response): Promise<void> { async getNavBarVisibility(req: Request, res: Response): Promise<void> {
try { try {
console.log('[Controller] Received request to get nav bar visibility.'); console.log('[控制器] 收到获取导航栏可见性的请求。');
const isVisible = await settingsService.getNavBarVisibility(); const isVisible = await settingsService.getNavBarVisibility();
console.log(`[Controller] Sending nav bar visibility to client: ${isVisible}`); console.log(`[控制器] 向客户端发送导航栏可见性: ${isVisible}`);
res.json({ visible: isVisible }); res.json({ visible: isVisible });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取导航栏可见性时出错:', error); console.error('[控制器] 获取导航栏可见性时出错:', error);
res.status(500).json({ message: '获取导航栏可见性失败', error: error.message }); res.status(500).json({ message: '获取导航栏可见性失败', error: error.message });
} }
}, },
@@ -145,27 +143,26 @@ export const settingsController = {
* *
*/ */
async setNavBarVisibility(req: Request, res: Response): Promise<void> { async setNavBarVisibility(req: Request, res: Response): Promise<void> {
console.log('[Controller] Received request to set nav bar visibility.'); console.log('[控制器] 收到设置导航栏可见性的请求。');
try { try {
const { visible } = req.body; const { visible } = req.body;
console.log('[Controller] Request body visible:', visible); console.log('[控制器] 请求体 visible:', visible);
if (typeof visible !== 'boolean') { if (typeof visible !== 'boolean') {
console.warn('[Controller] Invalid visible format received:', visible); console.warn('[控制器] 收到无效的 visible 格式:', visible);
res.status(400).json({ message: '无效的请求体,"visible" 必须是一个布尔值' }); res.status(400).json({ message: '无效的请求体,"visible" 必须是一个布尔值' });
return; return;
} }
console.log('[Controller] Calling settingsService.setNavBarVisibility...'); console.log('[控制器] 调用 settingsService.setNavBarVisibility...');
await settingsService.setNavBarVisibility(visible); 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: '导航栏可见性已成功更新' }); res.status(200).json({ message: '导航栏可见性已成功更新' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 设置导航栏可见性时出错:', error); console.error('[控制器] 设置导航栏可见性时出错:', error);
res.status(500).json({ message: '设置导航栏可见性失败', error: error.message }); res.status(500).json({ message: '设置导航栏可见性失败', error: error.message });
} }
}, },
@@ -175,23 +172,23 @@ export const settingsController = {
*/ */
async getLayoutTree(req: Request, res: Response): Promise<void> { async getLayoutTree(req: Request, res: Response): Promise<void> {
try { try {
console.log('[Controller] Received request to get layout tree.'); console.log('[控制器] 收到获取布局树的请求。');
const layoutJson = await settingsService.getLayoutTree(); const layoutJson = await settingsService.getLayoutTree();
if (layoutJson) { if (layoutJson) {
try { try {
const layout = JSON.parse(layoutJson); const layout = JSON.parse(layoutJson);
console.log('[Controller] Sending layout tree to client.'); console.log('[控制器] 向客户端发送布局树。');
res.json(layout); res.json(layout);
} catch (parseError) { } catch (parseError) {
console.error('[Controller] Failed to parse layout tree JSON from DB:', parseError); console.error('[控制器] 从数据库解析布局树 JSON 失败:', parseError);
res.status(500).json({ message: '获取布局树失败:存储的数据格式无效' }); res.status(500).json({ message: '获取布局树失败:存储的数据格式无效' });
} }
} else { } else {
console.log('[Controller] No layout tree found in settings, sending null.'); console.log('[控制器] 在设置中未找到布局树,发送 null');
res.json(null); res.json(null);
} }
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取布局树时出错:', error); console.error('[控制器] 获取布局树时出错:', error);
res.status(500).json({ message: '获取布局树失败', error: error.message }); res.status(500).json({ message: '获取布局树失败', error: error.message });
} }
}, },
@@ -200,28 +197,28 @@ export const settingsController = {
* *
*/ */
async setLayoutTree(req: Request, res: Response): Promise<void> { async setLayoutTree(req: Request, res: Response): Promise<void> {
console.log('[Controller] Received request to set layout tree.'); console.log('[控制器] 收到设置布局树的请求。');
try { try {
const layoutTree = req.body; const layoutTree = req.body;
if (typeof layoutTree !== 'object' || layoutTree === null) { 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 对象格式的布局树' }); res.status(400).json({ message: '无效的请求体,应为 JSON 对象格式的布局树' });
return; return;
} }
const layoutJson = JSON.stringify(layoutTree); const layoutJson = JSON.stringify(layoutTree);
console.log('[Controller] Calling settingsService.setLayoutTree...'); console.log('[控制器] 调用 settingsService.setLayoutTree...');
await settingsService.setLayoutTree(layoutJson); await settingsService.setLayoutTree(layoutJson);
console.log('[Controller] settingsService.setLayoutTree completed successfully.'); console.log('[控制器] settingsService.setLayoutTree 成功完成。');
// auditLogService.logAction('LAYOUT_TREE_UPDATED'); // auditLogService.logAction('LAYOUT_TREE_UPDATED');
console.log('[Controller] Sending success response.'); console.log('[控制器] 发送成功响应。');
res.status(200).json({ message: '布局树已成功更新' }); res.status(200).json({ message: '布局树已成功更新' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 设置布局树时出错:', error); console.error('[控制器] 设置布局树时出错:', error);
if (error.message === 'Invalid layout tree JSON format.') { if (error.message === 'Invalid layout tree JSON format.') {
res.status(400).json({ message: '设置布局树失败: 无效的 JSON 格式', error: error.message }); res.status(400).json({ message: '设置布局树失败: 无效的 JSON 格式', error: error.message });
} else { } else {
@@ -256,7 +253,6 @@ export const settingsController = {
return; return;
} }
await ipBlacklistService.removeFromBlacklist(ipToDelete); await ipBlacklistService.removeFromBlacklist(ipToDelete);
// auditLogService.logAction('IP_BLACKLIST_REMOVED', { ip: ipToDelete });
res.status(200).json({ message: `IP 地址 ${ipToDelete} 已从黑名单中移除` }); res.status(200).json({ message: `IP 地址 ${ipToDelete} 已从黑名单中移除` });
} catch (error: any) { } catch (error: any) {
console.error(`从 IP 黑名单删除 ${req.params.ip} 时出错:`, error); console.error(`从 IP 黑名单删除 ${req.params.ip} 时出错:`, error);
@@ -269,12 +265,12 @@ export const settingsController = {
*/ */
async getAutoCopyOnSelect(req: Request, res: Response): Promise<void> { async getAutoCopyOnSelect(req: Request, res: Response): Promise<void> {
try { try {
console.log('[Controller] Received request to get auto copy on select setting.'); console.log('[控制器] 收到获取“选中时自动复制”设置的请求。');
const isEnabled = await settingsService.getAutoCopyOnSelect(); const isEnabled = await settingsService.getAutoCopyOnSelect();
console.log(`[Controller] Sending auto copy on select setting to client: ${isEnabled}`); console.log(`[控制器] 向客户端发送“选中时自动复制”设置: ${isEnabled}`);
res.json({ enabled: isEnabled }); res.json({ enabled: isEnabled });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取终端选中自动复制设置时出错:', error); console.error('[控制器] 获取终端选中自动复制设置时出错:', error);
res.status(500).json({ message: '获取终端选中自动复制设置失败', error: error.message }); res.status(500).json({ message: '获取终端选中自动复制设置失败', error: error.message });
} }
}, // *** 确保这里有逗号 *** }, // *** 确保这里有逗号 ***
@@ -283,44 +279,42 @@ export const settingsController = {
* *
*/ */
async setAutoCopyOnSelect(req: Request, res: Response): Promise<void> { async setAutoCopyOnSelect(req: Request, res: Response): Promise<void> {
console.log('[Controller] Received request to set auto copy on select setting.'); console.log('[控制器] 收到设置“选中时自动复制”设置的请求。');
try { try {
const { enabled } = req.body; const { enabled } = req.body;
console.log('[Controller] Request body enabled:', enabled); console.log('[控制器] 请求体 enabled:', enabled);
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
console.warn('[Controller] Invalid enabled format received:', enabled); console.warn('[控制器] 收到无效的 enabled 格式:', enabled);
res.status(400).json({ message: '无效的请求体,"enabled" 必须是一个布尔值' }); res.status(400).json({ message: '无效的请求体,"enabled" 必须是一个布尔值' });
return; return;
} }
console.log('[Controller] Calling settingsService.setAutoCopyOnSelect...'); console.log('[控制器] 调用 settingsService.setAutoCopyOnSelect...');
await settingsService.setAutoCopyOnSelect(enabled); 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: '终端选中自动复制设置已成功更新' }); res.status(200).json({ message: '终端选中自动复制设置已成功更新' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 设置终端选中自动复制时出错:', error); console.error('[控制器] 设置终端选中自动复制时出错:', error);
res.status(500).json({ message: '设置终端选中自动复制失败', error: error.message }); res.status(500).json({ message: '设置终端选中自动复制失败', error: error.message });
} }
}, // *** 确保这里有逗号 *** },
// --- Sidebar Config Controller Methods ---
/** /**
* *
*/ */
async getSidebarConfig(req: Request, res: Response): Promise<void> { async getSidebarConfig(req: Request, res: Response): Promise<void> {
try { try {
console.log('[Controller] Received request to get sidebar config.'); console.log('[控制器] 收到获取侧边栏配置的请求。');
const config = await settingsService.getSidebarConfig(); const config = await settingsService.getSidebarConfig();
console.log('[Controller] Sending sidebar config to client:', config); console.log('[控制器] 向客户端发送侧边栏配置:', config);
res.json(config); res.json(config);
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取侧栏配置时出错:', error); console.error('[控制器] 获取侧栏配置时出错:', error);
res.status(500).json({ message: '获取侧栏配置失败', error: error.message }); res.status(500).json({ message: '获取侧栏配置失败', error: error.message });
} }
}, },
@@ -329,29 +323,28 @@ export const settingsController = {
* *
*/ */
async setSidebarConfig(req: Request, res: Response): Promise<void> { async setSidebarConfig(req: Request, res: Response): Promise<void> {
console.log('[Controller] Received request to set sidebar config.'); console.log('[控制器] 收到设置侧边栏配置的请求。');
try { try {
const configDto: UpdateSidebarConfigDto = req.body; const configDto: UpdateSidebarConfigDto = req.body;
console.log('[Controller] Request body:', configDto); console.log('[控制器] 请求体:', configDto);
// --- DTO Validation (Basic) --- // --- DTO Validation (Basic) ---
// More specific validation happens in the service layer // More specific validation happens in the service layer
if (!configDto || typeof configDto !== 'object' || !Array.isArray(configDto.left) || !Array.isArray(configDto.right)) { 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 对象' }); res.status(400).json({ message: '无效的请求体,应为包含 left 和 right 数组的 JSON 对象' });
return; return;
} }
console.log('[Controller] Calling settingsService.setSidebarConfig...'); console.log('[控制器] 调用 settingsService.setSidebarConfig...');
await settingsService.setSidebarConfig(configDto); 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: '侧栏配置已成功更新' }); res.status(200).json({ message: '侧栏配置已成功更新' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 设置侧栏配置时出错:', error); console.error('[控制器] 设置侧栏配置时出错:', error);
// Handle specific validation errors from the service // Handle specific validation errors from the service
if (error.message.includes('无效的面板名称') || error.message.includes('无效的侧栏配置格式')) { if (error.message.includes('无效的面板名称') || error.message.includes('无效的侧栏配置格式')) {
res.status(400).json({ message: `设置侧栏配置失败: ${error.message}` }); res.status(400).json({ message: `设置侧栏配置失败: ${error.message}` });
@@ -359,19 +352,16 @@ export const settingsController = {
res.status(500).json({ message: '设置侧栏配置失败', error: error.message }); res.status(500).json({ message: '设置侧栏配置失败', error: error.message });
} }
} }
}, // <-- Add comma here },
// --- CAPTCHA Settings Controller Methods ---
/** /**
* CAPTCHA () * CAPTCHA ()
*/ */
async getCaptchaConfig(req: Request, res: Response): Promise<void> { async getCaptchaConfig(req: Request, res: Response): Promise<void> {
try { try {
console.log('[Controller] Received request to get CAPTCHA config.'); console.log('[控制器] 收到获取 CAPTCHA 配置的请求。');
const fullConfig = await settingsService.getCaptchaConfig(); const fullConfig = await settingsService.getCaptchaConfig();
// *** IMPORTANT: Filter out secret keys before sending to frontend ***
const publicConfig = { const publicConfig = {
enabled: fullConfig.enabled, enabled: fullConfig.enabled,
provider: fullConfig.provider, provider: fullConfig.provider,
@@ -379,10 +369,10 @@ async getCaptchaConfig(req: Request, res: Response): Promise<void> {
recaptchaSiteKey: fullConfig.recaptchaSiteKey, recaptchaSiteKey: fullConfig.recaptchaSiteKey,
}; };
console.log('[Controller] Sending public CAPTCHA config to client:', publicConfig); console.log('[控制器] 向客户端发送公共 CAPTCHA 配置:', publicConfig);
res.json(publicConfig); res.json(publicConfig);
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取 CAPTCHA 配置时出错:', error); console.error('[控制器] 获取 CAPTCHA 配置时出错:', error);
res.status(500).json({ message: '获取 CAPTCHA 配置失败', error: error.message }); res.status(500).json({ message: '获取 CAPTCHA 配置失败', error: error.message });
} }
}, },
@@ -391,37 +381,35 @@ async getCaptchaConfig(req: Request, res: Response): Promise<void> {
* CAPTCHA * CAPTCHA
*/ */
async setCaptchaConfig(req: Request, res: Response): Promise<void> { async setCaptchaConfig(req: Request, res: Response): Promise<void> {
console.log('[Controller] Received request to set CAPTCHA config.'); console.log('[控制器] 收到设置 CAPTCHA 配置的请求。');
try { try {
const configDto: UpdateCaptchaSettingsDto = req.body; const configDto: UpdateCaptchaSettingsDto = req.body;
// Mask secrets immediately if logging the DTO console.log('[控制器] 请求体 (DTO, 密钥已屏蔽):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' });
console.log('[Controller] Request body (DTO, secrets masked):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' });
// --- DTO Validation (Basic) ---
if (!configDto || typeof configDto !== 'object') { 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 对象' }); res.status(400).json({ message: '无效的请求体,应为 JSON 对象' });
return; return;
} }
// More specific validation happens in the service layer
console.log('[Controller] Calling settingsService.setCaptchaConfig...');
console.log('[控制器] 调用 settingsService.setCaptchaConfig...');
await settingsService.setCaptchaConfig(configDto); 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 配置已成功更新' }); res.status(200).json({ message: 'CAPTCHA 配置已成功更新' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 设置 CAPTCHA 配置时出错:', error); console.error('[控制器] 设置 CAPTCHA 配置时出错:', error);
// Handle specific validation errors from the service // 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}` }); res.status(400).json({ message: `设置 CAPTCHA 配置失败: ${error.message}` });
} else { } else {
res.status(500).json({ message: '设置 CAPTCHA 配置失败', error: error.message }); 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 express from 'express';
import { settingsController } from './settings.controller'; import { settingsController } from './settings.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // 导入认证中间件 import { isAuthenticated } from '../auth/auth.middleware';
const router = express.Router(); const router = express.Router();
// +++ 新增:CAPTCHA 配置路由 (公开获取) +++
// GET /api/v1/settings/captcha - 获取公共 CAPTCHA 配置 (不含密钥) // GET /api/v1/settings/captcha - 获取公共 CAPTCHA 配置 (不含密钥)
router.get('/captcha', settingsController.getCaptchaConfig); router.get('/captcha', settingsController.getCaptchaConfig);
+7 -10
View File
@@ -1,6 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import path from 'path'; // 需要 path 用于处理文件名 import path from 'path';
import { clientStates } from '../websocket'; // Import the exported clientStates map import { clientStates } from '../websocket';
/** /**
* (GET /api/v1/sftp/download) * (GET /api/v1/sftp/download)
@@ -25,13 +25,11 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
// 查找与当前用户会话关联的活动 WebSocket 连接和 SFTP 会话 // 查找与当前用户会话关联的活动 WebSocket 连接和 SFTP 会话
let userSftpSession = null; let userSftpSession = null;
// 注意:这种查找方式效率不高,实际应用中可能需要更优化的结构来按 userId 查找连接 // 注意:这种查找方式效率不高,实际应用中可能需要更优化的结构来按 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()) { for (const [sessionId, state] of clientStates.entries()) {
const ws = state.ws; // Get the WebSocket instance from the state const ws = state.ws;
const connData = state; // Use the entire state object const connData = state;
// 假设 AuthenticatedWebSocket 上存储了 userId // 假设 AuthenticatedWebSocket 上存储了 userId
if (ws.userId === userId && connData.sftp) { // Access userId directly from AuthenticatedWebSocket if (ws.userId === userId && connData.sftp) {
// 这里简单地取第一个找到的匹配连接,没有处理 connectionId 的匹配 // 这里简单地取第一个找到的匹配连接,没有处理 connectionId 的匹配
// TODO: 需要一种方式将 HTTP 请求与特定的 WebSocket/SSH/SFTP 会话关联起来 // TODO: 需要一种方式将 HTTP 请求与特定的 WebSocket/SSH/SFTP 会话关联起来
// 临时方案:假设一个用户只有一个活动的 SSH/SFTP 会话 // 临时方案:假设一个用户只有一个活动的 SSH/SFTP 会话
@@ -87,7 +85,7 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
// 监听响应对象的 close 事件,确保流被正确关闭 (虽然 pipe 通常会处理) // 监听响应对象的 close 事件,确保流被正确关闭 (虽然 pipe 通常会处理)
res.on('close', () => { res.on('close', () => {
console.log(`SFTP 下载流关闭 (用户 ${userId}, 路径 ${remotePath})`); console.log(`SFTP 下载流关闭 (用户 ${userId}, 路径 ${remotePath})`);
// readStream.destroy(); // 可选:显式销毁流
}); });
console.log(`SFTP 开始下载 (用户 ${userId}, 路径 ${remotePath})`); console.log(`SFTP 开始下载 (用户 ${userId}, 路径 ${remotePath})`);
@@ -104,5 +102,4 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
} }
}; };
// 其他 SFTP 控制器函数 (例如上传)
// export const uploadFile = ...
+1 -2
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { isAuthenticated } from '../auth/auth.middleware'; import { isAuthenticated } from '../auth/auth.middleware';
import { downloadFile } from './sftp.controller'; // 稍后创建 import { downloadFile } from './sftp.controller';
const router = Router(); const router = Router();
@@ -10,6 +10,5 @@ router.use(isAuthenticated);
// GET /api/v1/sftp/download?connectionId=...&remotePath=... // GET /api/v1/sftp/download?connectionId=...&remotePath=...
router.get('/download', downloadFile); router.get('/download', downloadFile);
// 未来可以添加其他 SFTP 相关 REST API (如果需要,例如上传的大文件断点续传初始化)
export default router; export default router;
+4 -4
View File
@@ -1,8 +1,8 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as TagService from '../services/tag.service'; 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) * (POST /api/v1/tags)
@@ -23,7 +23,7 @@ export const createTag = async (req: Request, res: Response): Promise<void> => {
} catch (error: any) { } catch (error: any) {
console.error('Controller: 创建标签时发生错误:', error); console.error('Controller: 创建标签时发生错误:', error);
if (error.message.includes('已存在')) { if (error.message.includes('已存在')) {
res.status(409).json({ message: error.message }); // Conflict res.status(409).json({ message: error.message });
} else { } else {
res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' }); res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' });
} }
@@ -95,7 +95,7 @@ export const updateTag = async (req: Request, res: Response): Promise<void> => {
} catch (error: any) { } catch (error: any) {
console.error(`Controller: 更新标签 ${tagId} 时发生错误:`, error); console.error(`Controller: 更新标签 ${tagId} 时发生错误:`, error);
if (error.message.includes('已存在')) { if (error.message.includes('已存在')) {
res.status(409).json({ message: error.message }); // Conflict res.status(409).json({ message: error.message });
} else if (error.message.includes('不能为空')) { } else if (error.message.includes('不能为空')) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件 import { isAuthenticated } from '../auth/auth.middleware';
import { import {
createTag, createTag,
getTags, getTags,
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import * as terminalThemeService from '../services/terminal-theme.service'; import * as terminalThemeService from '../services/terminal-theme.service';
import { CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types'; import { CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
import type { ITheme } from 'xterm'; import type { ITheme } from 'xterm';
import multer from 'multer'; // 用于处理文件上传 import multer from 'multer';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import * as themeController from './terminal-theme.controller'; import * as themeController from './terminal-theme.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // 修正导入名称 import { isAuthenticated } from '../auth/auth.middleware';
const router = express.Router(); const router = express.Router();
+6 -6
View File
@@ -8,13 +8,13 @@ export type AuditLogActionType =
| '2FA_ENABLED' | '2FA_ENABLED'
| '2FA_DISABLED' | '2FA_DISABLED'
| 'PASSKEY_REGISTERED' | 'PASSKEY_REGISTERED'
| 'PASSKEY_DELETED' // Assuming deletion is possible later | 'PASSKEY_DELETED'
// Connections // Connections
| 'CONNECTION_CREATED' | 'CONNECTION_CREATED'
| 'CONNECTION_UPDATED' | 'CONNECTION_UPDATED'
| 'CONNECTION_DELETED' | 'CONNECTION_DELETED'
| 'CONNECTION_TESTED' // Maybe log test attempts? | 'CONNECTION_TESTED'
| 'CONNECTIONS_IMPORTED' | 'CONNECTIONS_IMPORTED'
| 'CONNECTIONS_EXPORTED' | 'CONNECTIONS_EXPORTED'
@@ -31,8 +31,8 @@ export type AuditLogActionType =
// Settings // Settings
| 'SETTINGS_UPDATED' // General settings update | 'SETTINGS_UPDATED' // General settings update
| 'IP_WHITELIST_UPDATED' // Specific setting update | 'IP_WHITELIST_UPDATED' // Specific setting update
| 'CAPTCHA_SETTINGS_UPDATED' // Specific setting update for CAPTCHA | 'CAPTCHA_SETTINGS_UPDATED'
// | 'FOCUS_SWITCHER_SEQUENCE_UPDATED' // Removed
// Notifications // Notifications
| 'NOTIFICATION_SETTING_CREATED' | 'NOTIFICATION_SETTING_CREATED'
@@ -53,9 +53,9 @@ export type AuditLogActionType =
// System/Error // System/Error
| 'SERVER_STARTED' | 'SERVER_STARTED'
| 'SERVER_ERROR' // Log significant backend errors | 'SERVER_ERROR'
| 'DATABASE_MIGRATION' | 'DATABASE_MIGRATION'
| 'ADMIN_SETUP_COMPLETE'; // *** 新增:初始管理员设置完成 *** | 'ADMIN_SETUP_COMPLETE';
// 审计日志条目的结构 (从数据库读取时) // 审计日志条目的结构 (从数据库读取时)
export interface AuditLogEntry { export interface AuditLogEntry {
+11 -13
View File
@@ -1,8 +1,8 @@
// Centralized types for Connection feature
export interface ConnectionBase { export interface ConnectionBase {
id: number; id: number;
name: string | null; // Allow name to be null name: string | null;
host: string; host: string;
port: number; port: number;
username: string; username: string;
@@ -17,22 +17,21 @@ export interface ConnectionWithTags extends ConnectionBase {
tag_ids: number[]; tag_ids: number[];
} }
// Input type for creating a connection (from controller)
export interface CreateConnectionInput { export interface CreateConnectionInput {
name?: string; // Name is now optional name?: string;
host: string; host: string;
port?: number; // Optional, defaults in service/repo port?: number;
username: string; username: string;
auth_method: 'password' | 'key'; auth_method: 'password' | 'key';
password?: string; // Optional depending on auth_method password?: string;
private_key?: string; // Optional depending on auth_method private_key?: string;
passphrase?: string; // Optional for key auth passphrase?: string;
proxy_id?: number | null; proxy_id?: number | null;
tag_ids?: number[]; tag_ids?: number[];
} }
// Input type for updating a connection (from controller)
// All fields are optional except potentially auth_method related ones
export interface UpdateConnectionInput { export interface UpdateConnectionInput {
name?: string; name?: string;
host?: string; host?: string;
@@ -41,13 +40,12 @@ export interface UpdateConnectionInput {
auth_method?: 'password' | 'key'; auth_method?: 'password' | 'key';
password?: string; password?: string;
private_key?: string; private_key?: string;
passphrase?: string; // Use undefined to signal no change, null/empty string to clear passphrase?: string;
proxy_id?: number | null; proxy_id?: number | null;
tag_ids?: number[]; 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 { export interface FullConnectionData {
id: number; id: number;
name: string | null; name: string | null;
@@ -8,15 +8,11 @@ export type NotificationEvent =
| 'CONNECTIONS_IMPORTED' | 'CONNECTIONS_EXPORTED' | 'CONNECTIONS_IMPORTED' | 'CONNECTIONS_EXPORTED'
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED' | 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_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' | 'NOTIFICATION_SETTING_CREATED' | 'NOTIFICATION_SETTING_UPDATED' | 'NOTIFICATION_SETTING_DELETED'
| 'SFTP_ACTION' | 'SFTP_ACTION'
// SSH Actions
| 'SSH_CONNECT_SUCCESS' | 'SSH_CONNECT_FAILURE' | 'SSH_SHELL_FAILURE' | 'SSH_CONNECT_SUCCESS' | 'SSH_CONNECT_FAILURE' | 'SSH_SHELL_FAILURE'
// System/Error
| 'SERVER_STARTED' | 'SERVER_ERROR' | 'DATABASE_MIGRATION' | 'ADMIN_SETUP_COMPLETE'; | '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 { export interface WebhookConfig {
url: string; url: string;
+110 -116
View File
@@ -2,16 +2,14 @@ import WebSocket, { WebSocketServer } from 'ws';
import http from 'http'; import http from 'http';
import { Request, RequestHandler } from 'express'; import { Request, RequestHandler } from 'express';
import { Client, ClientChannel } from 'ssh2'; import { Client, ClientChannel } from 'ssh2';
import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一的会话 ID import { v4 as uuidv4 } from 'uuid';
import { getDbInstance } from './database/connection'; // Updated import path, use getDbInstance import { getDbInstance } from './database/connection';
import { decrypt } from './utils/crypto';
import { SftpService } from './services/sftp.service'; import { SftpService } from './services/sftp.service';
import { StatusMonitorService } from './services/status-monitor.service'; import { StatusMonitorService } from './services/status-monitor.service';
import * as SshService from './services/ssh.service'; import * as SshService from './services/ssh.service';
import { DockerService } from './services/docker.service'; // 导入 DockerService import { DockerService } from './services/docker.service';
import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService import { AuditLogService } from './services/audit.service';
import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType import { settingsService } from './services/settings.service';
import { settingsService } from './services/settings.service'; // +++ 修正导入路径 +++
// 扩展 WebSocket 类型以包含会话 ID // 扩展 WebSocket 类型以包含会话 ID
interface AuthenticatedWebSocket extends WebSocket { interface AuthenticatedWebSocket extends WebSocket {
@@ -35,15 +33,14 @@ export interface ClientState { // 导出以便 Service 可以导入
ipAddress?: string; // 添加 IP 地址字段 ipAddress?: string; // 添加 IP 地址字段
} }
// --- Interfaces (保持与前端一致) ---
// --- FIX: Move PortInfo definition before its usage ---
interface PortInfo { interface PortInfo {
IP?: string; IP?: string;
PrivatePort: number; PrivatePort: number;
PublicPort?: number; PublicPort?: number;
Type: 'tcp' | 'udp' | string; Type: 'tcp' | 'udp' | string;
} }
// --- End FIX ---
// --- Docker Interfaces (Ensure this matches frontend and DockerService) --- // --- Docker Interfaces (Ensure this matches frontend and DockerService) ---
// Stats 接口 // Stats 接口
@@ -72,16 +69,14 @@ interface DockerContainer {
Labels: Record<string, string>; Labels: Record<string, string>;
stats?: DockerStats | null; // 可选的 stats 字段 stats?: DockerStats | null; // 可选的 stats 字段
} }
// --- End Docker Interfaces ---
// --- 新增:解析 Ports 字符串的辅助函数 --- // --- 新增:解析 Ports 字符串的辅助函数 ---
function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined function parsePortsString(portsString: string | undefined | null): PortInfo[] {
if (!portsString) { if (!portsString) {
return []; return [];
} }
const ports: PortInfo[] = []; // Now PortInfo is defined 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(', '); const entries = portsString.split(', ');
for (const entry of entries) { for (const entry of entries) {
@@ -89,17 +84,16 @@ function parsePortsString(portsString: string | undefined | null): PortInfo[] {
let publicPart = ''; let publicPart = '';
let privatePart = ''; let privatePart = '';
if (parts.length === 2) { // Format like "IP:PublicPort->PrivatePort/Type" or "PublicPort->PrivatePort/Type" if (parts.length === 2) {
publicPart = parts[0]; publicPart = parts[0];
privatePart = parts[1]; privatePart = parts[1];
} else if (parts.length === 1) { // Format like "PrivatePort/Type" } else if (parts.length === 1) {
privatePart = parts[0]; privatePart = parts[0];
} else { } else {
console.warn(`[WebSocket] Skipping unparsable port entry: ${entry}`); console.warn(`[WebSocket] Skipping unparsable port entry: ${entry}`);
continue; continue;
} }
// Parse Private Part (e.g., "80/tcp")
const privateMatch = privatePart.match(/^(\d+)\/(tcp|udp|\w+)$/); const privateMatch = privatePart.match(/^(\d+)\/(tcp|udp|\w+)$/);
if (!privateMatch) { if (!privateMatch) {
console.warn(`[WebSocket] Skipping unparsable private port part: ${privatePart}`); 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 ip: string | undefined = undefined;
let publicPort: number | undefined = undefined; let publicPort: number | undefined = undefined;
// Parse Public Part (e.g., "0.0.0.0:8080" or ":::8080" or just "8080")
if (publicPart) { 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) { if (publicMatch) {
ip = publicMatch[1] || undefined; // IP might be undefined if only port is specified ip = publicMatch[1] || undefined;
publicPort = parseInt(publicMatch[2], 10); publicPort = parseInt(publicMatch[2], 10);
} else { } else {
console.warn(`[WebSocket] Skipping unparsable public port part: ${publicPart}`); 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; return ports;
} }
// --- 结束辅助函数 ---
// 存储所有活动客户端的状态 (key: sessionId) // 存储所有活动客户端的状态 (key: sessionId)
export const clientStates = new Map<string, ClientState>(); // Export clientStates export const clientStates = new Map<string, ClientState>();
// --- 服务实例化 --- // --- 服务实例化 ---
// 将 clientStates 传递给需要访问共享状态的服务 // 将 clientStates 传递给需要访问共享状态的服务
@@ -183,11 +177,11 @@ const cleanupClientConnection = (sessionId: string | undefined) => {
console.log(`WebSocket: 会话 ${sessionId} 已清理。`); console.log(`WebSocket: 会话 ${sessionId} 已清理。`);
} else { } 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[] }> => { const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available: boolean; containers: DockerContainer[] }> => {
if (!state || !state.sshClient) { if (!state || !state.sshClient) {
throw new Error('SSH client is not available in the current state.'); 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[] = []; let allContainers: DockerContainer[] = [];
const statsMap = new Map<string, DockerStats>(); 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 { try {
const versionCommand = "docker version --format '{{.Server.Version}}'"; const versionCommand = "docker version --format '{{.Server.Version}}'";
console.log(`[fetchRemoteDockerStatus] Executing: ${versionCommand} on session ${state.ws.sessionId}`); 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.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
stream.on('close', (code: number | null) => { stream.on('close', (code: number | null) => {
// Resolve even if code is non-zero, check stderr
resolve({ stdout, stderr }); resolve({ stdout, stderr });
}); });
stream.on('error', (execErr: Error) => reject(execErr)); stream.on('error', (execErr: Error) => reject(execErr));
}); });
}); });
// Check stderr for common errors indicating Docker is unavailable or inaccessible
if (versionStderr.includes('command not found') || if (versionStderr.includes('command not found') ||
versionStderr.includes('permission denied') || versionStderr.includes('permission denied') ||
versionStderr.includes('Cannot connect to the Docker daemon')) { 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()}`); 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) { } 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()}`); 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()) { if (versionStdout.trim()) {
console.log(`[fetchRemoteDockerStatus] Docker version check successful on session ${state.ws.sessionId}. Version: ${versionStdout.trim()}`); console.log(`[fetchRemoteDockerStatus] Docker version check successful on session ${state.ws.sessionId}. Version: ${versionStdout.trim()}`);
isDockerCmdAvailable = true; isDockerCmdAvailable = true;
} else { } 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.`); console.warn(`[fetchRemoteDockerStatus] Docker version check on session ${state.ws.sessionId} produced no output, assuming Docker unavailable.`);
return { available: false, containers: [] }; return { available: false, containers: [] };
} }
} catch (error: any) { } catch (error: any) {
console.error(`[fetchRemoteDockerStatus] Error executing docker version for session ${state.ws.sessionId}:`, error); 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: [] }; return { available: false, containers: [] };
} }
// If version check failed, we already returned. If it passed, isDockerCmdAvailable is true.
// --- 2. Get basic container info ---
try { try {
const psCommand = "docker ps -a --no-trunc --format '{{json .}}'"; const psCommand = "docker ps -a --no-trunc --format '{{json .}}'";
console.log(`[fetchRemoteDockerStatus] Executing: ${psCommand} on session ${state.ws.sessionId}`); 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.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
stream.on('close', (code: number | null) => { stream.on('close', (code: number | null) => {
// Don't reject on non-zero code here, check stderr
resolve({ stdout, stderr }); resolve({ stdout, stderr });
}); });
stream.on('error', (execErr: Error) => reject(execErr)); 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 if (psStderr.includes('command not found') ||
psStderr.includes('permission denied') || // Could still happen if permissions differ psStderr.includes('permission denied') ||
psStderr.includes('Cannot connect to the Docker daemon')) { // Should not happen 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()}`); 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: [] }; return { available: false, containers: [] };
} else if (psStderr) { } else if (psStderr) {
console.warn(`[fetchRemoteDockerStatus] Docker ps command stderr on session ${state.ws.sessionId}: ${psStderr.trim()}`); 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') : []; const lines = psStdout.trim() ? psStdout.trim().split('\n') : [];
allContainers = lines allContainers = lines
.map(line => { .map(line => {
try { try {
const data = JSON.parse(line); const data = JSON.parse(line);
// Map raw data to DockerContainer interface (lowercase id)
const container: DockerContainer = { const container: DockerContainer = {
id: data.ID, // Map ID to lowercase id id: data.ID,
Names: typeof data.Names === 'string' ? data.Names.split(',') : (data.Names || []), Names: typeof data.Names === 'string' ? data.Names.split(',') : (data.Names || []),
Image: data.Image || '', Image: data.Image || '',
ImageID: data.ImageID || '', ImageID: data.ImageID || '',
Command: data.Command || '', Command: data.Command || '',
Created: data.CreatedAt || 0, // Check if CreatedAt exists Created: data.CreatedAt || 0,
State: data.State || 'unknown', State: data.State || 'unknown',
Status: data.Status || '', Status: data.Status || '',
Ports: parsePortsString(data.Ports), Ports: parsePortsString(data.Ports),
Labels: data.Labels || {}, Labels: data.Labels || {},
stats: null // Initialize stats as null stats: null
}; };
return container; return container;
} catch (parseError) { } catch (parseError) {
@@ -306,19 +300,19 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
} catch (error: any) { } catch (error: any) {
console.error(`[fetchRemoteDockerStatus] Error executing docker ps for session ${state.ws.sessionId}:`, error); 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: [] }; 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); const runningContainerIds = allContainers.filter(c => c.State === 'running').map(c => c.id);
if (runningContainerIds.length > 0) { if (runningContainerIds.length > 0) {
try { try {
// Construct command to get stats only for running containers
const statsCommand = `docker stats ${runningContainerIds.join(' ')} --no-stream --format '{{json .}}'`; const statsCommand = `docker stats ${runningContainerIds.join(' ')} --no-stream --format '{{json .}}'`;
console.log(`[fetchRemoteDockerStatus] Executing: ${statsCommand} on session ${state.ws.sessionId}`); console.log(`[fetchRemoteDockerStatus] Executing: ${statsCommand} on session ${state.ws.sessionId}`);
const { stdout: statsStdout, stderr: statsStderr } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { 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.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
stream.on('close', (code: number | null) => { stream.on('close', (code: number | null) => {
// Don't reject on non-zero code, check stderr
resolve({ stdout, stderr }); resolve({ stdout, stderr });
}); });
stream.on('error', (execErr: Error) => reject(execErr)); stream.on('error', (execErr: Error) => reject(execErr));
@@ -337,7 +331,7 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
}); });
if (statsStderr) { 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()}`); 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 { try {
const statsData = JSON.parse(line) as DockerStats; const statsData = JSON.parse(line) as DockerStats;
if (statsData.ID) { if (statsData.ID) {
// Use the ID from stats data (usually short ID) as the key
statsMap.set(statsData.ID, statsData); statsMap.set(statsData.ID, statsData);
} }
} catch (parseError) { } catch (parseError) {
@@ -354,32 +348,32 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
} }
}); });
} catch (error: any) { } 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); console.warn(`[fetchRemoteDockerStatus] Error executing docker stats for session ${state.ws.sessionId}:`, error);
} }
} else { } else {
console.log(`[fetchRemoteDockerStatus] No running containers found on session ${state.ws.sessionId}, skipping docker stats.`); console.log(`[fetchRemoteDockerStatus] No running containers found on session ${state.ws.sessionId}, skipping docker stats.`);
} }
// --- 4. Merge stats into containers ---
allContainers.forEach(container => { allContainers.forEach(container => {
const shortId = container.id.substring(0, 12); // docker stats often uses short ID const shortId = container.id.substring(0, 12);
const stats = statsMap.get(container.id) || statsMap.get(shortId); // Try matching long and short ID const stats = statsMap.get(container.id) || statsMap.get(shortId);
if (stats) { if (stats) {
container.stats = stats; container.stats = stats;
} }
}); });
// If we reached here, Docker is considered available (version check passed)
return { available: true, containers: allContainers }; 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 wss = new WebSocketServer({ noServer: true });
const db = await getDbInstance(); // 获取数据库实例 (use await and getDbInstance) const db = await getDbInstance();
const DOCKER_STATUS_INTERVAL = 2000; // Poll Docker status every 2 seconds const DOCKER_STATUS_INTERVAL = 2000;
// --- 心跳检测 --- // --- 心跳检测 ---
const heartbeatInterval = setInterval(() => { 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}, 原始): `, data.toString()); // 添加原始数据日志 (尝试 utf8)
console.log(`SSH Data (会话: ${newSessionId}, Hex): `, data.toString('hex')); // 添加 Hex 日志 console.log(`SSH Data (会话: ${newSessionId}, Hex): `, data.toString('hex')); // 添加 Hex 日志
// ------------------------------------
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); 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} 正在启动状态监控...`); console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`);
statusMonitorService.startStatusPolling(newSessionId); statusMonitorService.startStatusPolling(newSessionId);
// 8. Start Docker status polling (using setting)
console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`); console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`);
// --- Get interval from settings ---
let dockerPollIntervalMs = 2000; // Default interval let dockerPollIntervalMs = 2000;
try { try {
const intervalSetting = await settingsService.getSetting('dockerStatusIntervalSeconds'); const intervalSetting = await settingsService.getSetting('dockerStatusIntervalSeconds');
if (intervalSetting) { if (intervalSetting) {
@@ -563,32 +557,32 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
} catch (settingError) { } catch (settingError) {
console.error(`[Docker Polling] Error fetching interval setting for session ${newSessionId}. Using default ${dockerPollIntervalMs}ms:`, 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 dockerIntervalId = setInterval(async () => {
const currentState = clientStates.get(newSessionId); // Re-fetch state const currentState = clientStates.get(newSessionId);
if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) { if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) {
console.log(`[Docker Polling] Session ${newSessionId} no longer valid or WS closed. Stopping poll.`); console.log(`[Docker Polling] Session ${newSessionId} no longer valid or WS closed. Stopping poll.`);
clearInterval(dockerIntervalId); clearInterval(dockerIntervalId);
return; return;
} }
try { try {
// console.log(`[Docker Polling] Fetching status for session ${newSessionId}...`);
const statusPayload = await fetchRemoteDockerStatus(currentState); const statusPayload = await fetchRemoteDockerStatus(currentState);
if (currentState.ws.readyState === WebSocket.OPEN) { if (currentState.ws.readyState === WebSocket.OPEN) {
currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload }));
} }
} catch (error: any) { } catch (error: any) {
console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error); 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; newState.dockerStatusIntervalId = dockerIntervalId;
// 9. Trigger initial Docker status fetch immediately
(async () => { (async () => {
const currentState = clientStates.get(newSessionId); const currentState = clientStates.get(newSessionId);
if (currentState && currentState.ws.readyState === WebSocket.OPEN) { if (currentState && currentState.ws.readyState === WebSocket.OPEN) {
@@ -601,7 +595,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
} catch (error: any) { } catch (error: any) {
console.error(`[Docker Initial Fetch] Error fetching Docker status for session ${newSessionId}:`, error); console.error(`[Docker Initial Fetch] Error fetching Docker status for session ${newSessionId}:`, error);
if (currentState.ws.readyState === WebSocket.OPEN) { if (currentState.ws.readyState === WebSocket.OPEN) {
// Send specific error type for initial fetch failure
const errorMessage = error.message || 'Unknown error during initial fetch'; const errorMessage = error.message || 'Unknown error during initial fetch';
const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon'); const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon');
if (isUnavailable) { 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}` })); ws.send(JSON.stringify({ type: 'ssh:error', payload: `连接失败: ${connectError.message}` }));
} }
break; break;
} // end case 'ssh:connect' }
// --- SSH 输入 --- // --- SSH 输入 ---
case 'ssh:input': { case 'ssh:input': {
@@ -683,7 +677,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
break; break;
} }
// --- REFACTORED: Handle Docker Status Request ---
case 'docker:get_status': { case 'docker:get_status': {
if (!state) { if (!state) {
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动会话状态。`); 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} 请求 (手动触发)...`); console.log(`WebSocket: 处理来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求 (手动触发)...`);
try { try {
// Call the reusable function
const statusPayload = await fetchRemoteDockerStatus(state); const statusPayload = await fetchRemoteDockerStatus(state);
ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload }));
} catch (error: any) { } catch (error: any) {
console.error(`WebSocket: 手动执行远程 Docker 状态命令失败 for session ${sessionId}:`, error); console.error(`WebSocket: 手动执行远程 Docker 状态命令失败 for session ${sessionId}:`, error);
const errorMessage = error.message || 'Unknown error fetching status'; 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'); const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon');
if (isUnavailable) { if (isUnavailable) {
ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } })); 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; break;
} // end case 'docker:get_status' (Refactored) }
// --- NEW: Handle Docker Command Execution ---
case 'docker:command': { case 'docker:command': {
if (!state || !state.sshClient) { if (!state || !state.sshClient) {
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`); 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: Validation PASSED for docker:command.`); // 增加成功日志
console.log(`WebSocket: Processing command '${command}' for container '${containerId}' on session ${sessionId}...`); console.log(`WebSocket: Processing command '${command}' for container '${containerId}' on session ${sessionId}...`);
try { try {
// Sanitize containerId (basic) - more robust validation might be needed
const cleanContainerId = containerId.replace(/[^a-zA-Z0-9_-]/g, ''); const cleanContainerId = containerId.replace(/[^a-zA-Z0-9_-]/g, '');
if (!cleanContainerId) throw new Error('Invalid container ID format after sanitization.'); 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 'start': dockerCliCommand = `docker start ${cleanContainerId}`; break;
case 'stop': dockerCliCommand = `docker stop ${cleanContainerId}`; break; case 'stop': dockerCliCommand = `docker stop ${cleanContainerId}`; break;
case 'restart': dockerCliCommand = `docker restart ${cleanContainerId}`; break; case 'restart': dockerCliCommand = `docker restart ${cleanContainerId}`; break;
case 'remove': dockerCliCommand = `docker rm -f ${cleanContainerId}`; break; // Use -f for remove case 'remove': dockerCliCommand = `docker rm -f ${cleanContainerId}`; break;
default: throw new Error(`Unsupported command: ${command}`); // Should be caught by earlier validation default: throw new Error(`Unsupported command: ${command}`);
} }
// Execute command remotely
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
state.sshClient.exec(dockerCliCommand, { pty: false }, (err, stream) => { state.sshClient.exec(dockerCliCommand, { pty: false }, (err, stream) => {
if (err) return reject(err); 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.'}`)); reject(new Error(`Command failed with code ${code}. ${stderr || 'No stderr output.'}`));
} }
}); });
// Add type annotation for execErr
stream.on('error', (execErr: Error) => reject(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(() => { setTimeout(() => {
if (clientStates.has(sessionId!)) { // Check if session still exists if (clientStates.has(sessionId!)) {
ws.send(JSON.stringify({ type: 'request_docker_status_update' })); // Ask frontend to re-request ws.send(JSON.stringify({ type: 'request_docker_status_update' }));
// Or directly trigger backend fetch and push:
// handleDockerGetStatus(ws, state); // Need to refactor get_status logic into a reusable function
} }
}, 500); }, 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}` } })); ws.send(JSON.stringify({ type: 'docker:command:error', payload: { command, containerId, message: `Failed to execute remote command: ${error.message}` } }));
} }
break; break;
} // end case 'docker:command' }
// --- SFTP Cases ---
case 'sftp:readdir': case 'sftp:readdir':
case 'sftp:stat': case 'sftp:stat':
case 'sftp:readfile': 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' } })); ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId, remotePath 或 size' } }));
return; return;
} }
// TODO: Add audit log for SFTP upload start?
sftpService.startUpload(sessionId, payload.uploadId, payload.remotePath, payload.size); sftpService.startUpload(sessionId, payload.uploadId, payload.remotePath, payload.size);
break; 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' } })); ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId' } }));
return; return;
} }
// TODO: Add audit log for SFTP upload cancel?
sftpService.cancelUpload(sessionId, payload.uploadId); sftpService.cancelUpload(sessionId, payload.uploadId);
break; break;
} }
// --- NEW CASE: Handle docker:get_stats ---
case '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 连接。`); 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.' } })); 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) { if (!payload || !payload.containerId) {
console.warn(`WebSocket: Invalid payload for docker:get_stats in session ${sessionId}:`, payload); 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 .}}'`; const command = `docker stats ${containerId} --no-stream --format '{{json .}}'`;
try { try {
// --- FIX: Use sshClient.exec directly ---
const execResult = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { const execResult = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@@ -939,33 +933,33 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
stream.on('data', (data: Buffer) => { stdout += data.toString(); }); stream.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
stream.on('close', (code: number | null) => { 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 }); resolve({ stdout, stderr });
}); });
stream.on('error', (execErr: Error) => reject(execErr)); stream.on('error', (execErr: Error) => reject(execErr));
}); });
}); });
// --- End FIX ---
if (execResult.stderr) { 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}`); 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.' } })); 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) { if (!execResult.stdout) {
console.warn(`WebSocket: No stats output for container ${containerId} in session ${sessionId}. Might be stopped or error occurred.`); 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) { if (!execResult.stderr) {
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: 'No stats data received (container might be stopped).' } })); 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 { try {
const statsData = JSON.parse(execResult.stdout.trim()); 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 } })); ws.send(JSON.stringify({ type: 'docker:stats:update', payload: { containerId, stats: statsData } }));
} catch (parseError) { } catch (parseError) {
console.error(`WebSocket: Failed to parse docker stats JSON for ${containerId} in session ${sessionId}: ${execResult.stdout}`, 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); 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.' } })); ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: error.message || 'Failed to fetch Docker stats.' } }));
} }
break; // Break after handling the case break;
} // --- END CASE: docker:get_stats --- }
default: default:
@@ -1016,4 +1010,4 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
return wss; return wss;
}; };
// --- 移除旧的辅助函数 ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB