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