调整代码结构
This commit is contained in:
@@ -1,670 +0,0 @@
|
||||
import fs from 'fs/promises'; // 使用 promises API
|
||||
import path from 'path';
|
||||
import * as appearanceRepository from '../repositories/appearance.repository';
|
||||
import { AppearanceSettings, UpdateAppearanceDto } from '../types/appearance.types';
|
||||
import * as terminalThemeRepository from '../terminal-themes/terminal-theme.repository';
|
||||
import axios from 'axios';
|
||||
import sanitize from 'sanitize-filename'; // 用于清理文件名
|
||||
|
||||
// 预设 HTML 主题的存储路径 (作为只读预设)
|
||||
const PRESET_HTML_THEMES_DIR = path.join(__dirname, '../../html-presets/');
|
||||
|
||||
const USER_CUSTOM_HTML_THEMES_DIR = path.join(__dirname, '../../data/custom_html_theme/');
|
||||
|
||||
|
||||
// 确保预设 html-themes 目录存在
|
||||
const ensurePresetHtmlThemesDirExists = async () => { // Renamed
|
||||
try {
|
||||
await fs.access(PRESET_HTML_THEMES_DIR);
|
||||
} catch (error) {
|
||||
// 目录不存在,创建它
|
||||
await fs.mkdir(PRESET_HTML_THEMES_DIR, { recursive: true });
|
||||
console.log(`[AppearanceService] Created preset html-themes directory at ${PRESET_HTML_THEMES_DIR}`);
|
||||
}
|
||||
};
|
||||
// 在服务初始化时确保目录存在
|
||||
ensurePresetHtmlThemesDirExists();
|
||||
|
||||
// 确保用户自定义 custom_html_theme 目录存在
|
||||
const ensureUserCustomHtmlThemesDirExists = async () => {
|
||||
try {
|
||||
await fs.access(USER_CUSTOM_HTML_THEMES_DIR);
|
||||
} catch (error) {
|
||||
// 目录不存在,创建它
|
||||
await fs.mkdir(USER_CUSTOM_HTML_THEMES_DIR, { recursive: true });
|
||||
console.log(`[AppearanceService] Created user custom_html_theme directory at ${USER_CUSTOM_HTML_THEMES_DIR}`);
|
||||
}
|
||||
};
|
||||
// 在服务初始化时确保目录存在
|
||||
ensureUserCustomHtmlThemesDirExists();
|
||||
|
||||
|
||||
/**
|
||||
* 获取外观设置
|
||||
* @returns Promise<AppearanceSettings>
|
||||
*/
|
||||
export const getSettings = async (): Promise<AppearanceSettings> => {
|
||||
const settings = await appearanceRepository.getAppearanceSettings();
|
||||
// 为 terminalBackgroundOverlayOpacity 提供默认值
|
||||
if (settings.terminalBackgroundOverlayOpacity === undefined || settings.terminalBackgroundOverlayOpacity === null) {
|
||||
settings.terminalBackgroundOverlayOpacity = 0.5; // 默认透明度为 0.5
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新外观设置
|
||||
* @param settingsDto 更新数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
*/
|
||||
export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise<boolean> => {
|
||||
// 验证 activeTerminalThemeId (如果提供了)
|
||||
if (settingsDto.activeTerminalThemeId !== undefined && settingsDto.activeTerminalThemeId !== null) {
|
||||
const themeIdNum = settingsDto.activeTerminalThemeId; // ID is now number | null
|
||||
// 验证 ID 是否为有效的数字
|
||||
if (typeof themeIdNum !== 'number') {
|
||||
console.error(`[AppearanceService] 收到的 activeTerminalThemeId 不是有效的数字: ${themeIdNum}`);
|
||||
throw new Error(`无效的终端主题 ID 类型,应为数字。`);
|
||||
}
|
||||
try {
|
||||
// 直接使用数字 ID 调用 findThemeById 进行验证
|
||||
const themeExists = await terminalThemeRepository.findThemeById(themeIdNum);
|
||||
if (!themeExists) {
|
||||
console.warn(`[AppearanceService] 尝试更新为不存在的终端主题数字 ID: ${themeIdNum}`);
|
||||
throw new Error(`指定的终端主题 ID 不存在: ${themeIdNum}`);
|
||||
}
|
||||
console.log(`[AppearanceService] 终端主题数字 ID ${themeIdNum} 验证通过。`);
|
||||
} catch (e: any) {
|
||||
console.error(`[AppearanceService] 验证终端主题数字 ID (${themeIdNum}) 时出错:`, e.message);
|
||||
throw new Error(`验证终端主题 ID 时出错: ${e.message || themeIdNum}`);
|
||||
}
|
||||
} else if (settingsDto.hasOwnProperty('activeTerminalThemeId') && settingsDto.activeTerminalThemeId === null) {
|
||||
// 处理显式设置为 null (表示重置为默认/无用户主题)
|
||||
console.log(`[AppearanceService] 接收到将 activeTerminalThemeId 设置为 null 的请求。`);
|
||||
// 仓库层会处理 null
|
||||
}
|
||||
|
||||
// 验证 terminalFontSize (如果提供了)
|
||||
if (settingsDto.terminalFontSize !== undefined && settingsDto.terminalFontSize !== null) {
|
||||
const size = Number(settingsDto.terminalFontSize);
|
||||
if (isNaN(size) || size <= 0) {
|
||||
throw new Error(`无效的终端字体大小: ${settingsDto.terminalFontSize}。必须是一个正数。`);
|
||||
}
|
||||
// 可以选择将验证后的数字类型赋值回 DTO,以确保类型正确传递给仓库层
|
||||
settingsDto.terminalFontSize = size;
|
||||
}
|
||||
|
||||
// 验证 terminalFontSizeMobile (如果提供了)
|
||||
if (settingsDto.terminalFontSizeMobile !== undefined && settingsDto.terminalFontSizeMobile !== null) {
|
||||
const size = Number(settingsDto.terminalFontSizeMobile);
|
||||
if (isNaN(size) || size <= 0) {
|
||||
throw new Error(`无效的移动端终端字体大小: ${settingsDto.terminalFontSizeMobile}。必须是一个正数。`);
|
||||
}
|
||||
// 确保类型正确传递给仓库层
|
||||
settingsDto.terminalFontSizeMobile = size;
|
||||
}
|
||||
|
||||
// 验证 editorFontSize (如果提供了)
|
||||
if (settingsDto.editorFontSize !== undefined && settingsDto.editorFontSize !== null) {
|
||||
const size = Number(settingsDto.editorFontSize);
|
||||
if (isNaN(size) || size <= 0) {
|
||||
throw new Error(`无效的编辑器字体大小: ${settingsDto.editorFontSize}。必须是一个正数。`);
|
||||
}
|
||||
// 确保类型正确传递给仓库层
|
||||
settingsDto.editorFontSize = size;
|
||||
}
|
||||
|
||||
// 验证 editorFontFamily (如果提供了)
|
||||
if (settingsDto.hasOwnProperty('editorFontFamily')) {
|
||||
if (settingsDto.editorFontFamily === null) {
|
||||
// 允许用户将字体设置为空 (null),表示重置或使用默认
|
||||
// 无需额外操作,仓库层会处理 null
|
||||
} else if (typeof settingsDto.editorFontFamily === 'string') {
|
||||
const fontFamily = settingsDto.editorFontFamily;
|
||||
// 校验字体名称格式和长度
|
||||
if (fontFamily.length > 255) {
|
||||
throw new Error('编辑器字体名称过长,最多允许 255 个字符。');
|
||||
}
|
||||
|
||||
if (fontFamily.trim() === '' && fontFamily !== '') {
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
// 如果提供了 editorFontFamily 但不是 string 或 null
|
||||
throw new Error('无效的编辑器字体名称类型,应为字符串或 null。');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 terminalBackgroundOverlayOpacity (如果提供了)
|
||||
if (settingsDto.terminalBackgroundOverlayOpacity !== undefined && settingsDto.terminalBackgroundOverlayOpacity !== null) {
|
||||
const opacity = Number(settingsDto.terminalBackgroundOverlayOpacity);
|
||||
if (isNaN(opacity) || opacity < 0 || opacity > 1) {
|
||||
throw new Error(`无效的终端背景蒙版透明度: ${settingsDto.terminalBackgroundOverlayOpacity}。必须是一个 0 到 1 之间的数字。`);
|
||||
}
|
||||
settingsDto.terminalBackgroundOverlayOpacity = opacity; // 确保类型正确
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 验证 terminal_custom_html (如果提供了)
|
||||
if (settingsDto.hasOwnProperty('terminal_custom_html')) {
|
||||
if (settingsDto.terminal_custom_html === null || settingsDto.terminal_custom_html === undefined || typeof settingsDto.terminal_custom_html === 'string') {
|
||||
// 允许为空字符串、null 或 undefined (将被视为空)
|
||||
if (typeof settingsDto.terminal_custom_html === 'string' && settingsDto.terminal_custom_html.length > 10240) { // 10KB 限制
|
||||
throw new Error('自定义终端 HTML 过长,最多允许 10240 个字符。');
|
||||
}
|
||||
} else {
|
||||
throw new Error('无效的自定义终端 HTML 类型,应为字符串。');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 remoteHtmlPresetsUrl (如果提供了)
|
||||
if (settingsDto.hasOwnProperty('remoteHtmlPresetsUrl')) {
|
||||
const url = settingsDto.remoteHtmlPresetsUrl;
|
||||
if (url === null || url === undefined) {
|
||||
// 允许设置为 null 或 undefined (将被视为空)
|
||||
settingsDto.remoteHtmlPresetsUrl = null; // 统一为 null
|
||||
} else if (typeof url === 'string') {
|
||||
if (url.trim() === '') {
|
||||
settingsDto.remoteHtmlPresetsUrl = null; // 空字符串也视为空
|
||||
} else {
|
||||
// 可选:添加更严格的 URL 格式验证
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
// 暂时只做简单检查,允许非 GitHub URL,因为前端可能有其他用途
|
||||
// throw new Error('无效的远程 HTML 主题仓库链接格式,应以 http:// 或 https:// 开头。');
|
||||
}
|
||||
if (url.length > 1024) { // 限制 URL 长度
|
||||
throw new Error('远程 HTML 主题仓库链接过长,最多允许 1024 个字符。');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('无效的远程 HTML 主题仓库链接类型,应为字符串或 null。');
|
||||
}
|
||||
}
|
||||
|
||||
return appearanceRepository.updateAppearanceSettings(settingsDto);
|
||||
};
|
||||
/**
|
||||
* 移除页面背景图片
|
||||
* 1. 获取当前设置中的文件路径
|
||||
* 2. 如果路径存在,删除文件系统中的文件
|
||||
* 3. 更新数据库中的路径为空字符串
|
||||
*/
|
||||
export const removePageBackground = async (): Promise<boolean> => {
|
||||
const currentSettings = await getSettings();
|
||||
const filePath = currentSettings.pageBackgroundImage;
|
||||
|
||||
if (filePath) {
|
||||
// 构建文件的绝对路径
|
||||
// 注意:这里的路径拼接逻辑需要与上传时的逻辑一致
|
||||
// 假设 filePath 是相对于项目根目录的 /uploads/backgrounds/xxx
|
||||
const absolutePath = path.join(__dirname, '../../', filePath); // 调整相对路径层级
|
||||
|
||||
try {
|
||||
await fs.unlink(absolutePath);
|
||||
console.log(`[AppearanceService] 已删除页面背景文件: ${absolutePath}`);
|
||||
} catch (error: any) {
|
||||
// 如果文件不存在或其他删除错误,记录日志但继续执行以清空数据库记录
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`[AppearanceService] 尝试删除页面背景文件但未找到: ${absolutePath}`);
|
||||
} else {
|
||||
console.error(`[AppearanceService] 删除页面背景文件时出错 (${absolutePath}):`, error);
|
||||
// 可以选择抛出错误,或者仅记录并继续
|
||||
// throw new Error(`删除页面背景文件失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[AppearanceService] 没有页面背景文件路径需要删除。');
|
||||
}
|
||||
|
||||
// 无论文件删除是否成功(或文件是否存在),都尝试清空数据库记录
|
||||
return updateSettings({ pageBackgroundImage: '' });
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除终端背景图片
|
||||
* 1. 获取当前设置中的文件路径
|
||||
* 2. 如果路径存在,删除文件系统中的文件
|
||||
* 3. 更新数据库中的路径为空字符串
|
||||
*/
|
||||
export const removeTerminalBackground = async (): Promise<boolean> => {
|
||||
const currentSettings = await getSettings();
|
||||
const filePath = currentSettings.terminalBackgroundImage;
|
||||
|
||||
if (filePath) {
|
||||
const absolutePath = path.join(__dirname, '../../', filePath); // 调整相对路径层级
|
||||
|
||||
try {
|
||||
await fs.unlink(absolutePath);
|
||||
console.log(`[AppearanceService] 已删除终端背景文件: ${absolutePath}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`[AppearanceService] 尝试删除终端背景文件但未找到: ${absolutePath}`);
|
||||
} else {
|
||||
console.error(`[AppearanceService] 删除终端背景文件时出错 (${absolutePath}):`, error);
|
||||
// throw new Error(`删除终端背景文件失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[AppearanceService] 没有终端背景文件路径需要删除。');
|
||||
}
|
||||
|
||||
// 无论文件删除是否成功(或文件是否存在),都尝试清空数据库记录
|
||||
return updateSettings({ terminalBackgroundImage: '' });
|
||||
};
|
||||
|
||||
|
||||
// --- 自定义 HTML 背景主题服务方法 ---
|
||||
|
||||
// -- 本地 HTML 主题管理 --
|
||||
|
||||
/**
|
||||
* 验证并清理主题文件名 (通用函数)
|
||||
* @param themeName 原始主题文件名
|
||||
* @returns 清理后的安全文件名
|
||||
* @throws Error 如果文件名无效或包含路径遍历字符
|
||||
*/
|
||||
const sanitizeThemeNameInternal = (themeName: string): string => { // Renamed for clarity
|
||||
if (!themeName || typeof themeName !== 'string') {
|
||||
throw new Error('主题文件名不能为空且必须是字符串。');
|
||||
}
|
||||
// 进一步清理,确保文件名安全
|
||||
const safeName = sanitize(themeName);
|
||||
if (safeName !== themeName || themeName.includes('/') || themeName.includes('\\') || themeName.includes('..')) {
|
||||
// Sanitize 会移除或替换非法字符,如果清理后的名字和原名不同,或原名包含路径字符,则认为非法。
|
||||
// 额外检查 '..' 防止即使 sanitize 未移除(不太可能)的情况。
|
||||
console.warn(`[AppearanceService] 检测到潜在不安全的主题文件名: ${themeName}, 清理后: ${safeName}`);
|
||||
throw new Error(`主题文件名 "${themeName}" 包含非法字符或路径。`);
|
||||
}
|
||||
if (!safeName.endsWith('.html')) {
|
||||
throw new Error('主题文件名必须以 .html 结尾。');
|
||||
}
|
||||
if (safeName.length > 255) { // 合理的文件名长度限制
|
||||
throw new Error('主题文件名过长。');
|
||||
}
|
||||
return safeName;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有预设 HTML 主题的名称列表
|
||||
* @returns Promise<Array<{ name: string, type: 'preset' }>> 主题对象列表
|
||||
*/
|
||||
export const listPresetHtmlThemes = async (): Promise<Array<{ name: string, type: 'preset' }>> => {
|
||||
try {
|
||||
await ensurePresetHtmlThemesDirExists(); // 确保目录存在
|
||||
const files = await fs.readdir(PRESET_HTML_THEMES_DIR);
|
||||
return files
|
||||
.filter(file => file.endsWith('.html'))
|
||||
.map(name => ({ name, type: 'preset' as const })); // Add type
|
||||
} catch (error: any) {
|
||||
console.error('[AppearanceService] 列出预设 HTML 主题失败:', error);
|
||||
if (error.code === 'ENOENT') {
|
||||
// 目录不存在
|
||||
console.warn(`[AppearanceService] 预设 HTML 主题目录 (${PRESET_HTML_THEMES_DIR}) 未找到。`);
|
||||
return [];
|
||||
}
|
||||
throw new Error('无法列出预设 HTML 主题。');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定预设 HTML 主题的内容
|
||||
* @param themeName 主题文件名 (例如: my-theme.html)
|
||||
* @returns Promise<string> 主题的 HTML 内容
|
||||
*/
|
||||
export const getPresetHtmlThemeContent = async (themeName: string): Promise<string> => { // Renamed
|
||||
const safeThemeName = sanitizeThemeNameInternal(themeName); // Use internal sanitizer
|
||||
const filePath = path.join(PRESET_HTML_THEMES_DIR, safeThemeName);
|
||||
try {
|
||||
await ensurePresetHtmlThemesDirExists(); // 确保目录存在
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 获取预设 HTML 主题 "${safeThemeName}" 内容失败:`, error);
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`预设 HTML 主题 "${safeThemeName}" 未找到。`);
|
||||
}
|
||||
throw new Error(`无法获取预设 HTML 主题 "${safeThemeName}" 的内容。`);
|
||||
}
|
||||
};
|
||||
|
||||
// -- 用户自定义 HTML 主题管理 --
|
||||
|
||||
/**
|
||||
* 获取所有用户自定义 HTML 主题的名称列表
|
||||
* @returns Promise<Array<{ name: string, type: 'custom' }>> 主题对象列表
|
||||
*/
|
||||
export const listUserCustomHtmlThemes = async (): Promise<Array<{ name: string, type: 'custom' }>> => {
|
||||
try {
|
||||
await ensureUserCustomHtmlThemesDirExists();
|
||||
const files = await fs.readdir(USER_CUSTOM_HTML_THEMES_DIR);
|
||||
return files
|
||||
.filter(file => file.endsWith('.html'))
|
||||
.map(name => ({ name, type: 'custom' as const })); // Add type
|
||||
} catch (error: any) {
|
||||
console.error('[AppearanceService] 列出用户自定义 HTML 主题失败:', error);
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`[AppearanceService] 用户自定义 HTML 主题目录 (${USER_CUSTOM_HTML_THEMES_DIR}) 未找到。`);
|
||||
return [];
|
||||
}
|
||||
throw new Error('无法列出用户自定义 HTML 主题。');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定用户自定义 HTML 主题的内容
|
||||
* @param themeName 主题文件名 (例如: my-custom-theme.html)
|
||||
* @returns Promise<string> 主题的 HTML 内容
|
||||
*/
|
||||
export const getUserCustomHtmlThemeContent = async (themeName: string): Promise<string> => {
|
||||
const safeThemeName = sanitizeThemeNameInternal(themeName);
|
||||
const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName);
|
||||
try {
|
||||
await ensureUserCustomHtmlThemesDirExists();
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 获取用户自定义 HTML 主题 "${safeThemeName}" 内容失败:`, error);
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 未找到。`);
|
||||
}
|
||||
throw new Error(`无法获取用户自定义 HTML 主题 "${safeThemeName}" 的内容。`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新的用户自定义 HTML 主题
|
||||
* @param themeName 主题文件名 (例如: my-custom-theme.html)
|
||||
* @param content HTML 内容
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const createUserCustomHtmlTheme = async (themeName: string, content: string): Promise<void> => {
|
||||
const safeThemeName = sanitizeThemeNameInternal(themeName);
|
||||
const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName);
|
||||
try {
|
||||
await ensureUserCustomHtmlThemesDirExists(); // 确保目录存在
|
||||
// 检查文件是否已存在
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
// 文件已存在
|
||||
throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 已存在。`);
|
||||
} catch (accessError: any) {
|
||||
// 文件不存在,可以创建
|
||||
if (accessError.code !== 'ENOENT') {
|
||||
throw accessError; // 其他 access 错误
|
||||
}
|
||||
}
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
console.log(`[AppearanceService] 用户自定义 HTML 主题 "${safeThemeName}" 创建成功。`);
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 创建用户自定义 HTML 主题 "${safeThemeName}" 失败:`, error);
|
||||
throw error; // 重新抛出原始错误或包装后的错误
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新指定用户自定义 HTML 主题的内容
|
||||
* @param themeName 主题文件名 (例如: my-custom-theme.html)
|
||||
* @param content 新的 HTML 内容
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const updateUserCustomHtmlTheme = async (themeName: string, content: string): Promise<void> => {
|
||||
const safeThemeName = sanitizeThemeNameInternal(themeName);
|
||||
const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName);
|
||||
try {
|
||||
await ensureUserCustomHtmlThemesDirExists(); // 确保目录存在
|
||||
// 确保文件存在才能更新
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (accessError: any) {
|
||||
if (accessError.code === 'ENOENT') {
|
||||
throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 未找到,无法更新。`);
|
||||
}
|
||||
throw accessError;
|
||||
}
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
console.log(`[AppearanceService] 用户自定义 HTML 主题 "${safeThemeName}" 更新成功。`);
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 更新用户自定义 HTML 主题 "${safeThemeName}" 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除指定的用户自定义 HTML 主题文件
|
||||
* @param themeName 主题文件名 (例如: my-custom-theme.html)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const deleteUserCustomHtmlTheme = async (themeName: string): Promise<void> => {
|
||||
const safeThemeName = sanitizeThemeNameInternal(themeName);
|
||||
const filePath = path.join(USER_CUSTOM_HTML_THEMES_DIR, safeThemeName);
|
||||
try {
|
||||
await ensureUserCustomHtmlThemesDirExists(); // 确保目录存在
|
||||
await fs.unlink(filePath);
|
||||
console.log(`[AppearanceService] 用户自定义 HTML 主题 "${safeThemeName}" 删除成功。`);
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 删除用户自定义 HTML 主题 "${safeThemeName}" 失败:`, error);
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`用户自定义 HTML 主题 "${safeThemeName}" 未找到,无法删除。`);
|
||||
}
|
||||
throw new Error(`无法删除用户自定义 HTML 主题 "${safeThemeName}"。`);
|
||||
}
|
||||
};
|
||||
|
||||
// -- 合并主题列表 --
|
||||
|
||||
/**
|
||||
* 获取所有 HTML 主题 (预设和用户自定义)
|
||||
* @returns Promise<Array<{ name: string, type: 'preset' | 'custom' }>>
|
||||
*/
|
||||
export const listAllHtmlThemes = async (): Promise<Array<{ name: string, type: 'preset' | 'custom' }>> => {
|
||||
try {
|
||||
const presetThemes = await listPresetHtmlThemes();
|
||||
const customThemes = await listUserCustomHtmlThemes();
|
||||
return [...presetThemes, ...customThemes];
|
||||
} catch (error) {
|
||||
console.error('[AppearanceService] 列出所有 HTML 主题失败:', error);
|
||||
throw new Error('无法列出所有 HTML 主题。');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 现有本地 HTML 主题函数调整/重命名 ---
|
||||
// 为了兼容现有的 appearance.store.ts 调用,暂时保留这些导出名,但内部调用新的对应函数。
|
||||
// 建议后续步骤修改 appearance.store.ts 去调用新的、更明确的函数名 (e.g., listPresetHtmlThemes, createUserCustomHtmlTheme).
|
||||
|
||||
/**
|
||||
* @deprecated Use createUserCustomHtmlTheme instead. This function now creates a USER CUSTOM theme.
|
||||
* The 'local' in its name is misleading under the new system.
|
||||
*/
|
||||
export const createLocalHtmlPreset = async (themeName: string, content: string): Promise<void> => {
|
||||
console.warn("[AppearanceService] createLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using createUserCustomHtmlTheme.");
|
||||
return createUserCustomHtmlTheme(themeName, content);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use updateUserCustomHtmlTheme instead. This function now updates a USER CUSTOM theme.
|
||||
* The 'local' in its name is misleading under the new system.
|
||||
*/
|
||||
export const updateLocalHtmlPreset = async (themeName: string, content: string): Promise<void> => {
|
||||
console.warn("[AppearanceService] updateLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using updateUserCustomHtmlTheme.");
|
||||
return updateUserCustomHtmlTheme(themeName, content);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteUserCustomHtmlTheme instead. This function now deletes a USER CUSTOM theme.
|
||||
* The 'local' in its name is misleading under the new system.
|
||||
*/
|
||||
export const deleteLocalHtmlPreset = async (themeName: string): Promise<void> => {
|
||||
console.warn("[AppearanceService] deleteLocalHtmlPreset is deprecated and now operates on user custom themes. Consider using deleteUserCustomHtmlTheme.");
|
||||
return deleteUserCustomHtmlTheme(themeName);
|
||||
};
|
||||
|
||||
|
||||
// -- 远程 GitHub HTML 主题管理 --
|
||||
|
||||
/**
|
||||
* 获取当前存储的远程仓库链接
|
||||
* @returns Promise<string | null> 远程仓库 URL 或 null
|
||||
*/
|
||||
export const getRemoteHtmlPresetsRepositoryUrl = async (): Promise<string | null> => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return settings.remoteHtmlPresetsUrl !== undefined ? settings.remoteHtmlPresetsUrl : null;
|
||||
} catch (error: any) {
|
||||
console.error('[AppearanceService] 获取远程 HTML 主题仓库链接失败:', error);
|
||||
throw new Error('无法获取远程 HTML 主题仓库链接。');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新远程仓库链接
|
||||
* @param url 新的远程仓库 URL (可以是 null 或空字符串来清除)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const updateRemoteHtmlPresetsRepositoryUrl = async (url: string | null): Promise<void> => {
|
||||
try {
|
||||
// 验证 URL 格式 (可选, 但推荐)
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
// 简单的 URL 验证,可以根据需要增强
|
||||
if (!url.startsWith('https://github.com/') && !url.startsWith('http://github.com/')) {
|
||||
// 允许其他 git 仓库源?目前按计划仅 GitHub
|
||||
// throw new Error('无效的 GitHub 仓库链接格式。应形如 https://github.com/user/repo/tree/branch/path');
|
||||
}
|
||||
} else if (url === '') {
|
||||
// 如果是空字符串,则视为 null,表示清除
|
||||
url = null;
|
||||
} else if (url !== null) {
|
||||
throw new Error('无效的 URL 值。');
|
||||
}
|
||||
|
||||
await updateSettings({ remoteHtmlPresetsUrl: url });
|
||||
console.log(`[AppearanceService] 远程 HTML 主题仓库链接更新为: ${url}`);
|
||||
} catch (error: any) {
|
||||
console.error('[AppearanceService] 更新远程 HTML 主题仓库链接失败:', error);
|
||||
throw error; // 重新抛出,让控制器处理
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 解析 GitHub 仓库 URL,提取 user, repo, path 和 ref (分支/tag/commit)
|
||||
* @param repoUrl 例如: https://github.com/user/repo/tree/main/path/to/themes
|
||||
* @returns { user: string, repo: string, path: string, ref: string } 或 null
|
||||
*/
|
||||
const parseGitHubRepoUrl = (repoUrl: string): { user: string; repo: string; repoPath: string; ref: string } | null => {
|
||||
// 改进的正则表达式以更好地处理不同的 GitHub URL 格式
|
||||
const githubUrlRegex = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+)\/?(.*?)|\/?(.*))?$/;
|
||||
const match = repoUrl.match(githubUrlRegex);
|
||||
|
||||
if (match) {
|
||||
const user = match[1];
|
||||
const repo = match[2];
|
||||
let ref = match[3]; // 分支/tag 从 /tree/部分提取
|
||||
let repoPath = match[4]; // 路径在 /tree/之后
|
||||
|
||||
if (ref === undefined && repoPath === undefined) {
|
||||
// 处理 https://github.com/user/repo 这种形式, ref 和 path 从第五个捕获组获取
|
||||
ref = 'HEAD'; // 默认为 HEAD (通常是默认分支)
|
||||
repoPath = match[5] || ''; // 如果路径为空,则为空字符串
|
||||
} else {
|
||||
// 如果 /tree/ 部分存在
|
||||
ref = ref || 'HEAD'; // 如果 ref 未定义(例如 URL 以 /tree/ 结尾),默认为 HEAD
|
||||
repoPath = repoPath || ''; // 如果路径为空,则为空字符串
|
||||
}
|
||||
// 移除路径末尾的斜杠
|
||||
repoPath = repoPath.replace(/\/$/, '');
|
||||
|
||||
return { user, repo, ref, repoPath };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 获取远程仓库的主题列表 (文件名)
|
||||
* @param repoUrl 可选的仓库 URL。如果不提供,则使用已保存的链接。
|
||||
* @returns Promise<Array<{ name: string, downloadUrl: string | null }>> 主题对象列表
|
||||
*/
|
||||
export const listRemoteHtmlPresets = async (repoUrl?: string): Promise<Array<{ name: string, downloadUrl: string | null }>> => {
|
||||
let urlToFetch = repoUrl;
|
||||
if (!urlToFetch) {
|
||||
const savedUrl = await getRemoteHtmlPresetsRepositoryUrl();
|
||||
if (!savedUrl) {
|
||||
throw new Error('未提供远程仓库链接,且未找到已保存的链接。');
|
||||
}
|
||||
urlToFetch = savedUrl;
|
||||
}
|
||||
|
||||
const parsed = parseGitHubRepoUrl(urlToFetch);
|
||||
if (!parsed) {
|
||||
throw new Error(`无效的 GitHub 仓库链接格式: ${urlToFetch}`);
|
||||
}
|
||||
|
||||
const { user, repo, ref, repoPath } = parsed;
|
||||
// GitHub API 端点获取目录内容
|
||||
const apiUrl = `https://api.github.com/repos/${user}/${repo}/contents/${repoPath}?ref=${ref}`;
|
||||
|
||||
try {
|
||||
console.log(`[AppearanceService] 正在从 GitHub API 获取远程主题列表: ${apiUrl}`);
|
||||
const response = await axios.get(apiUrl, {
|
||||
headers: { 'Accept': 'application/vnd.github.v3+json' }
|
||||
// 对于公共仓库,通常不需要 token
|
||||
});
|
||||
|
||||
if (response.status === 200 && Array.isArray(response.data)) {
|
||||
const htmlFiles = response.data
|
||||
.filter(item => item.type === 'file' && item.name.endsWith('.html'))
|
||||
.map(item => ({
|
||||
name: item.name,
|
||||
downloadUrl: item.download_url // GitHub API 通常会提供 download_url
|
||||
}));
|
||||
console.log(`[AppearanceService] 成功获取 ${htmlFiles.length} 个远程 HTML 主题。`);
|
||||
return htmlFiles;
|
||||
} else {
|
||||
console.error(`[AppearanceService] 从 GitHub API 获取主题列表失败: 状态 ${response.status}`, response.data);
|
||||
throw new Error(`无法从 GitHub (${urlToFetch}) 获取主题列表。状态: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 请求 GitHub API (${apiUrl}) 时出错:`, error.response?.data || error.message);
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
throw new Error(`远程仓库路径未找到: ${urlToFetch} (API: ${apiUrl})`);
|
||||
}
|
||||
throw new Error(`请求 GitHub API 获取主题列表时出错: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取远程仓库中指定主题的 HTML 内容
|
||||
* @param fileUrl GitHub API 返回的 download_url 或可构造的 raw 文件链接
|
||||
* @returns Promise<string> 主题的 HTML 内容
|
||||
*/
|
||||
export const getRemoteHtmlPresetContent = async (fileUrl: string): Promise<string> => {
|
||||
if (!fileUrl || typeof fileUrl !== 'string') {
|
||||
throw new Error('无效的远程文件 URL。');
|
||||
}
|
||||
// 基本的 URL 校验,确保它看起来像一个可下载的链接
|
||||
if (!fileUrl.startsWith('http://') && !fileUrl.startsWith('https://')) {
|
||||
throw new Error('文件 URL 必须是有效的 HTTP/HTTPS 链接。');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[AppearanceService] 正在从远程 URL 获取主题内容: ${fileUrl}`);
|
||||
const response = await axios.get(fileUrl, {
|
||||
responseType: 'text', // 确保获取的是文本内容
|
||||
});
|
||||
|
||||
if (response.status === 200 && typeof response.data === 'string') {
|
||||
console.log(`[AppearanceService] 成功从 ${fileUrl} 获取主题内容。`);
|
||||
return response.data;
|
||||
} else {
|
||||
console.error(`[AppearanceService] 从 ${fileUrl} 获取内容失败: 状态 ${response.status}`, response.data);
|
||||
throw new Error(`无法从远程 URL (${fileUrl}) 获取内容。状态: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[AppearanceService] 请求远程文件内容 (${fileUrl}) 时出错:`, error.response?.data || error.message);
|
||||
throw new Error(`请求远程文件内容时出错: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { AuditLogRepository } from '../audit/audit.repository';
|
||||
import { AuditLogActionType, AuditLogEntry } from '../types/audit.types';
|
||||
|
||||
export class AuditLogService {
|
||||
private repository: AuditLogRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new AuditLogRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条审计日志
|
||||
* @param actionType 操作类型
|
||||
* @param details 可选的详细信息 (对象或字符串)
|
||||
*/
|
||||
async logAction(actionType: AuditLogActionType, details?: Record<string, any> | string | null): Promise<void> {
|
||||
// 在这里可以添加额外的逻辑,例如:
|
||||
// - 检查是否需要记录此类型的日志 (基于配置)
|
||||
// - 格式化 details
|
||||
// - 异步执行,不阻塞主流程
|
||||
try {
|
||||
// 使用 'await' 确保日志记录完成(如果需要保证顺序或处理错误)
|
||||
// 或者不使用 'await' 让其在后台执行
|
||||
await this.repository.addLog(actionType, details);
|
||||
} catch (error) {
|
||||
// Repository 内部已经处理了错误打印,这里可以根据需要再次处理或忽略
|
||||
console.error(`[Audit Service] Failed to log action ${actionType}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审计日志列表
|
||||
* @param limit 每页数量
|
||||
* @param offset 偏移量
|
||||
* @param actionType 可选的操作类型过滤
|
||||
* @param startDate 可选的开始时间戳 (秒)
|
||||
* @param endDate 可选的结束时间戳 (秒)
|
||||
*/
|
||||
async getLogs(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
actionType?: AuditLogActionType,
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
searchTerm?: string // 添加 searchTerm 参数
|
||||
): Promise<{ logs: AuditLogEntry[], total: number }> {
|
||||
// 将 searchTerm 传递给 repository
|
||||
return this.repository.getLogs(limit, offset, actionType, startDate, endDate, searchTerm);
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { settingsService } from './settings.service';
|
||||
|
||||
// CAPTCHA 验证 API 端点
|
||||
const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify';
|
||||
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; // v2
|
||||
|
||||
export class CaptchaService {
|
||||
|
||||
/**
|
||||
* 验证提供的 CAPTCHA 令牌。
|
||||
* 根据系统设置自动选择合适的提供商进行验证。
|
||||
* @param token - 从前端获取的 CAPTCHA 令牌 (h-captcha-response 或 g-recaptcha-response)
|
||||
* @returns Promise<boolean> - 令牌是否有效
|
||||
* @throws Error 如果配置无效或验证请求失败
|
||||
*/
|
||||
async verifyToken(token: string): Promise<boolean> {
|
||||
if (!token) {
|
||||
console.warn('[CaptchaService] 验证失败:未提供令牌。');
|
||||
return false; // 没有令牌,直接视为无效
|
||||
}
|
||||
|
||||
const captchaConfig = await settingsService.getCaptchaConfig();
|
||||
|
||||
if (!captchaConfig.enabled) {
|
||||
console.log('[CaptchaService] CAPTCHA 未启用,跳过验证。');
|
||||
return true; // 未启用则视为验证通过
|
||||
}
|
||||
|
||||
switch (captchaConfig.provider) {
|
||||
case 'hcaptcha':
|
||||
if (!captchaConfig.hcaptchaSecretKey) {
|
||||
throw new Error('hCaptcha 配置无效:缺少 Secret Key。');
|
||||
}
|
||||
return this._verifyHCaptcha(token, captchaConfig.hcaptchaSecretKey);
|
||||
case 'recaptcha':
|
||||
if (!captchaConfig.recaptchaSecretKey) {
|
||||
throw new Error('Google reCAPTCHA 配置无效:缺少 Secret Key。');
|
||||
}
|
||||
return this._verifyReCaptcha(token, captchaConfig.recaptchaSecretKey);
|
||||
case 'none':
|
||||
console.log('[CaptchaService] CAPTCHA 提供商设置为 "none",跳过验证。');
|
||||
return true; // 提供商为 none 也视为通过
|
||||
default:
|
||||
console.error(`[CaptchaService] 未知的 CAPTCHA 提供商: ${captchaConfig.provider}`);
|
||||
throw new Error(`未知的 CAPTCHA 提供商配置: ${captchaConfig.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证提供的 CAPTCHA 凭据 (Site Key 和 Secret Key)。
|
||||
* @param provider - CAPTCHA 提供商 ('hcaptcha' 或 'recaptcha')
|
||||
* @param siteKey - Site Key
|
||||
* @param secretKey - Secret Key
|
||||
* @returns Promise<boolean> - 凭据是否有效
|
||||
* @throws Error 如果提供商不受支持或验证请求失败
|
||||
*/
|
||||
async verifyCredentials(provider: 'hcaptcha' | 'recaptcha', siteKey: string, secretKey: string): Promise<boolean> {
|
||||
if (!siteKey || !secretKey) {
|
||||
console.warn(`[CaptchaService] 凭据验证失败:${provider} 的 Site Key 或 Secret Key 为空。`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用一个固定的、已知的无效令牌或一个不太可能有效的测试令牌
|
||||
const testToken = 'static_test_token_for_credential_verification_NexusTerminal';
|
||||
|
||||
console.log(`[CaptchaService] 正在验证 ${provider} 凭据 (SiteKey: ${siteKey.substring(0, 5)}...)`);
|
||||
|
||||
try {
|
||||
let success = false;
|
||||
if (provider === 'hcaptcha') {
|
||||
success = await this._verifyHCaptcha(testToken, secretKey, siteKey, true);
|
||||
} else if (provider === 'recaptcha') {
|
||||
success = await this._verifyReCaptcha(testToken, secretKey, siteKey, true);
|
||||
} else {
|
||||
throw new Error(`不支持的 CAPTCHA 提供商: ${provider}`);
|
||||
}
|
||||
return success;
|
||||
} catch (error: any) {
|
||||
// _verifyHCaptcha/_verifyReCaptcha 在凭据检查模式下会抛出特定错误
|
||||
console.error(`[CaptchaService] ${provider} 凭据验证期间发生错误:`, error.message);
|
||||
return false; // 任何在验证方法内部捕获并重新抛出的错误都意味着凭据无效
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 调用 hCaptcha API 验证令牌。
|
||||
* @param token - h-captcha-response 令牌
|
||||
* @param secretKey - hCaptcha Secret Key
|
||||
* @param siteKey - (可选) hCaptcha Site Key, 用于凭据验证模式
|
||||
* @param isCredentialVerification - (可选) 是否为凭据验证模式
|
||||
* @returns Promise<boolean> - 令牌/凭据是否有效
|
||||
*/
|
||||
private async _verifyHCaptcha(token: string, secretKey: string, siteKey?: string, isCredentialVerification = false): Promise<boolean> {
|
||||
const mode = isCredentialVerification ? "凭据" : "令牌";
|
||||
console.log(`[CaptchaService] 正在验证 hCaptcha ${mode}...`);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('secret', secretKey);
|
||||
params.append('response', token);
|
||||
if (siteKey) { // hCaptcha 的 siteverify 也接受 sitekey 参数
|
||||
params.append('sitekey', siteKey);
|
||||
}
|
||||
|
||||
const response = await axios.post(HCAPTCHA_VERIFY_URL, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
console.log(`[CaptchaService] hCaptcha ${mode}验证响应:`, response.data);
|
||||
const errorCodes: string[] = response.data['error-codes'] || [];
|
||||
|
||||
if (response.data && response.data.success === true) {
|
||||
console.log(`[CaptchaService] hCaptcha ${mode}验证成功。`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[CaptchaService] hCaptcha ${mode}验证失败:`, errorCodes.join(', ') || '未知错误');
|
||||
if (isCredentialVerification) {
|
||||
// 对于凭据验证,如果错误不是关于密钥本身的,我们可能仍然认为密钥“可能”有效。
|
||||
// 关键的错误代码是 'invalid-input-secret'。'invalid-sitekey' 也是一个明确的凭据错误。
|
||||
// 其他错误,如 'missing-input-response', 'invalid-input-response' 是关于测试令牌的,可以忽略。
|
||||
if (errorCodes.includes('invalid-input-secret') || errorCodes.includes('invalid-sitekey')) {
|
||||
throw new Error(`hCaptcha 凭据无效: ${errorCodes.join(', ')}`);
|
||||
}
|
||||
// 如果没有明确的密钥错误,并且不是成功,对于凭据验证,这仍可能意味着密钥组合是“可查询的”
|
||||
// 但为了更严格,我们也可以返回 false,或根据具体错误代码决定。
|
||||
// 此处,如果不是特定的密钥错误,我们乐观地认为凭据本身“可能”没问题,只是测试令牌无效。
|
||||
// 然而,更安全的方式是,任何非 success 都视为凭据验证失败,除非 API 设计允许区分。
|
||||
// 为了符合“校验成功才能保存”,这里如果 success 为 false,即使没有特定密钥错误,也应该返回 false
|
||||
// 或者抛出错误让上层决定。我们在此处抛出,由 verifyCredentials 捕获并返回 false.
|
||||
if (errorCodes.length > 0 && !errorCodes.includes('invalid-input-secret') && !errorCodes.includes('invalid-sitekey')) {
|
||||
// 例如: 'invalid-input-response', 'sitekey-secret-mismatch' (如果 sitekey 和 secret 不匹配但格式正确)
|
||||
// 'sitekey-secret-mismatch' 也是一个凭据问题
|
||||
if (errorCodes.includes('sitekey-secret-mismatch')) {
|
||||
throw new Error(`hCaptcha 凭据无效: sitekey 与 secret 不匹配`);
|
||||
}
|
||||
// 如果是 'invalid-input-response' 这类关于测试令牌的错误,我们认为密钥“可能”是对的。
|
||||
// 但前端期望布尔值,如果不是 success:true,这里就返回false,表示“未严格验证通过”
|
||||
console.warn(`[CaptchaService] hCaptcha ${mode}验证失败,但错误可能与测试令牌有关而非密钥本身: ${errorCodes.join(', ')}`);
|
||||
return false; // 对于凭据验证,如果不是true,就严格返回false
|
||||
}
|
||||
return false; // 其他所有情况的失败
|
||||
}
|
||||
return false; // 令牌验证失败
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || '未知网络错误';
|
||||
console.error(`[CaptchaService] 调用 hCaptcha ${mode}验证 API 时出错:`, errorMessage, error.response?.data || '');
|
||||
// 抛出错误,让上层处理
|
||||
throw new Error(`hCaptcha ${mode}验证请求失败: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 Google reCAPTCHA API 验证令牌。
|
||||
* @param token - g-recaptcha-response 令牌
|
||||
* @param secretKey - Google reCAPTCHA Secret Key
|
||||
* @param siteKey - (可选) Google reCAPTCHA Site Key, reCAPTCHA 的 siteverify 不直接使用 sitekey 作为参数,但保留以保持接口一致性
|
||||
* @param isCredentialVerification - (可选) 是否为凭据验证模式
|
||||
* @returns Promise<boolean> - 令牌/凭据是否有效
|
||||
*/
|
||||
private async _verifyReCaptcha(token: string, secretKey: string, siteKey?: string, isCredentialVerification = false): Promise<boolean> {
|
||||
const mode = isCredentialVerification ? "凭据" : "令牌";
|
||||
console.log(`[CaptchaService] 正在验证 Google reCAPTCHA ${mode}... (SiteKey: ${siteKey ? siteKey.substring(0,5)+'...' : 'N/A'})`);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('secret', secretKey);
|
||||
params.append('response', token);
|
||||
// reCAPTCHA 的 siteverify API 不像 hCaptcha 那样直接接受 sitekey 参数
|
||||
// sitekey 的验证是隐式通过 secretKey 的。
|
||||
|
||||
const response = await axios.post(RECAPTCHA_VERIFY_URL, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
console.log(`[CaptchaService] Google reCAPTCHA ${mode}验证响应:`, response.data);
|
||||
const errorCodes: string[] = response.data['error-codes'] || [];
|
||||
|
||||
if (response.data && response.data.success === true) {
|
||||
console.log(`[CaptchaService] Google reCAPTCHA ${mode}验证成功。`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[CaptchaService] Google reCAPTCHA ${mode}验证失败:`, errorCodes.join(', ') || '未知错误');
|
||||
if (isCredentialVerification) {
|
||||
// 对于凭据验证,关注与密钥相关的错误
|
||||
// 例如: 'invalid-input-secret', 'bad-request' (有时可能由错误的密钥导致), 'invalid-keys' (如果API支持)
|
||||
// 'missing-input-response', 'invalid-input-response' 是关于测试令牌的。
|
||||
if (errorCodes.includes('invalid-input-secret') || errorCodes.includes('invalid-keys') /* hypothetical */) {
|
||||
throw new Error(`Google reCAPTCHA 凭据无效: ${errorCodes.join(', ')}`);
|
||||
}
|
||||
// 如果是 'missing-input-response', 'invalid-input-response'
|
||||
// reCAPTCHA 倾向于对无效密钥返回 success: false 和 "invalid-input-secret"
|
||||
// 如果没有明确的密钥错误,并且不是 success,严格返回 false
|
||||
console.warn(`[CaptchaService] Google reCAPTCHA ${mode}验证失败,但错误可能与测试令牌有关而非密钥本身: ${errorCodes.join(', ')}`);
|
||||
return false; // 对于凭据验证,如果不是true,就严格返回false
|
||||
}
|
||||
return false; // 令牌验证失败
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || '未知网络错误';
|
||||
console.error(`[CaptchaService] 调用 Google reCAPTCHA ${mode}验证 API 时出错:`, errorMessage, error.response?.data || '');
|
||||
throw new Error(`Google reCAPTCHA ${mode}验证请求失败: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出一个单例供其他服务使用
|
||||
export const captchaService = new CaptchaService();
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as CommandHistoryRepository from '../command-history/command-history.repository';
|
||||
import { CommandHistoryEntry } from '../command-history/command-history.repository';
|
||||
|
||||
/**
|
||||
* 添加一条命令历史记录
|
||||
* @param command - 要添加的命令
|
||||
* @returns 返回添加记录的 ID
|
||||
*/
|
||||
export const addCommandHistory = async (command: string): Promise<number> => {
|
||||
// 可以在这里添加额外的业务逻辑,例如校验命令格式、长度限制等
|
||||
if (!command || command.trim().length === 0) {
|
||||
throw new Error('命令不能为空');
|
||||
}
|
||||
|
||||
// 调用 upsertCommand 来处理插入或更新时间戳
|
||||
return CommandHistoryRepository.upsertCommand(command.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有命令历史记录
|
||||
* @returns 返回所有历史记录条目数组,按时间戳升序
|
||||
*/
|
||||
export const getAllCommandHistory = async (): Promise<CommandHistoryEntry[]> => {
|
||||
return CommandHistoryRepository.getAllCommands();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 删除一条命令历史记录
|
||||
* @param id - 要删除的记录 ID
|
||||
* @returns 返回是否成功删除 (删除行数 > 0)
|
||||
*/
|
||||
export const deleteCommandHistoryById = async (id: number): Promise<boolean> => {
|
||||
// deleteCommandById now directly returns boolean indicating success
|
||||
const success = await CommandHistoryRepository.deleteCommandById(id);
|
||||
return success;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有命令历史记录
|
||||
* @returns 返回删除的记录条数
|
||||
*/
|
||||
export const clearAllCommandHistory = async (): Promise<number> => {
|
||||
return CommandHistoryRepository.clearAllCommands();
|
||||
};
|
||||
@@ -1,660 +0,0 @@
|
||||
import * as ConnectionRepository from '../connections/connection.repository';
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
import { AuditLogService } from './audit.service';
|
||||
import * as SshKeyService from './ssh_key.service';
|
||||
import {
|
||||
ConnectionBase,
|
||||
ConnectionWithTags,
|
||||
CreateConnectionInput,
|
||||
UpdateConnectionInput,
|
||||
FullConnectionData,
|
||||
ConnectionWithTags as ConnectionWithTagsType // Alias to avoid conflict with variable name
|
||||
} from '../types/connection.types';
|
||||
|
||||
export type { ConnectionBase, ConnectionWithTags, CreateConnectionInput, UpdateConnectionInput };
|
||||
|
||||
/**
|
||||
* 辅助函数:验证 jump_chain 并处理与 proxy_id 的互斥关系
|
||||
* @param jumpChain 输入的 jump_chain
|
||||
* @param proxyId 输入的 proxy_id
|
||||
* @param connectionId 当前正在操作的连接ID (仅在更新时提供)
|
||||
* @returns 处理过的 jump_chain (null 如果无效或应被忽略)
|
||||
* @throws Error 如果验证失败
|
||||
*/
|
||||
const _validateAndProcessJumpChain = async (
|
||||
jumpChain: number[] | null | undefined,
|
||||
proxyId: number | null | undefined,
|
||||
connectionId?: number
|
||||
): Promise<number[] | null> => {
|
||||
|
||||
if (!jumpChain || jumpChain.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validatedChain: number[] = [];
|
||||
for (const id of jumpChain) {
|
||||
if (typeof id !== 'number') {
|
||||
throw new Error('jump_chain 中的 ID 必须是数字。');
|
||||
}
|
||||
if (connectionId && id === connectionId) {
|
||||
throw new Error(`jump_chain 不能包含当前连接自身的 ID (${connectionId})。`);
|
||||
}
|
||||
const existingConnection = await ConnectionRepository.findConnectionByIdWithTags(id);
|
||||
if (!existingConnection) {
|
||||
throw new Error(`jump_chain 中的连接 ID ${id} 未找到。`);
|
||||
}
|
||||
if (existingConnection.type !== 'SSH') {
|
||||
throw new Error(`jump_chain 中的连接 ID ${id} (${existingConnection.name}) 不是 SSH 类型。`);
|
||||
}
|
||||
validatedChain.push(id);
|
||||
}
|
||||
return validatedChain.length > 0 ? validatedChain : null;
|
||||
};
|
||||
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
/**
|
||||
* 获取所有连接(包含标签)
|
||||
*/
|
||||
export const getAllConnections = async (): Promise<ConnectionWithTags[]> => {
|
||||
// Repository now returns ConnectionWithTags including 'type'
|
||||
// Explicit type assertion to ensure compatibility
|
||||
return ConnectionRepository.findAllConnectionsWithTags() as Promise<ConnectionWithTags[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个连接(包含标签)
|
||||
*/
|
||||
export const getConnectionById = async (id: number): Promise<ConnectionWithTags | null> => {
|
||||
// Repository now returns ConnectionWithTags including 'type'
|
||||
// Explicit type assertion to ensure compatibility
|
||||
return ConnectionRepository.findConnectionByIdWithTags(id) as Promise<ConnectionWithTags | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新连接
|
||||
*/
|
||||
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
|
||||
// +++ Define a local type alias for clarity, including ssh_key_id +++
|
||||
type ConnectionDataForRepo = Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'> & { jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null };
|
||||
|
||||
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
|
||||
|
||||
// 0. 处理和验证 jump_chain
|
||||
const processedJumpChain = await _validateAndProcessJumpChain(input.jump_chain, input.proxy_id);
|
||||
|
||||
|
||||
// 1. 验证输入 (包含 type)
|
||||
// Convert type to uppercase for validation and consistency
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety
|
||||
if (!connectionType || !['SSH', 'RDP', 'VNC'].includes(connectionType)) {
|
||||
throw new Error('必须提供有效的连接类型 (SSH, RDP 或 VNC)。');
|
||||
}
|
||||
if (!input.host || !input.username) {
|
||||
throw new Error('缺少必要的连接信息 (host, username)。');
|
||||
}
|
||||
// Type-specific validation using the uppercase version
|
||||
if (connectionType === 'SSH') {
|
||||
if (!input.auth_method || !['password', 'key'].includes(input.auth_method)) {
|
||||
throw new Error('SSH 连接必须提供有效的认证方式 (password 或 key)。');
|
||||
}
|
||||
if (input.auth_method === 'password' && !input.password) {
|
||||
throw new Error('SSH 密码认证方式需要提供 password。');
|
||||
}
|
||||
// If using ssh_key_id, private_key is not required in the input
|
||||
if (input.auth_method === 'key' && !input.ssh_key_id && !input.private_key) {
|
||||
throw new Error('SSH 密钥认证方式需要提供 private_key 或选择一个已保存的密钥 (ssh_key_id)。');
|
||||
}
|
||||
if (input.auth_method === 'key' && input.ssh_key_id && input.private_key) {
|
||||
throw new Error('不能同时提供 private_key 和 ssh_key_id。');
|
||||
}
|
||||
} else if (connectionType === 'RDP') {
|
||||
if (!input.password) {
|
||||
throw new Error('RDP 连接需要提供 password。');
|
||||
}
|
||||
// For RDP, we'll ignore auth_method, private_key, passphrase from input if provided
|
||||
} else if (connectionType === 'VNC') {
|
||||
if (!input.password) {
|
||||
throw new Error('VNC 连接需要提供 password。');
|
||||
}
|
||||
// For VNC, auth_method is implicitly 'password'.
|
||||
// ssh_key_id, private_key, passphrase are not applicable.
|
||||
if (input.auth_method && input.auth_method !== 'password') {
|
||||
throw new Error('VNC 连接的认证方式必须是 password。');
|
||||
}
|
||||
if (input.ssh_key_id || input.private_key) {
|
||||
throw new Error('VNC 连接不支持 SSH 密钥认证。');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理凭证和 ssh_key_id (根据 type)
|
||||
let encryptedPassword = null;
|
||||
let encryptedPrivateKey = null;
|
||||
let encryptedPassphrase = null;
|
||||
let sshKeyIdToSave: number | null = null; // +++ Variable for ssh_key_id +++
|
||||
// Default to 'password' for DB compatibility, especially for RDP
|
||||
let authMethodForDb: 'password' | 'key' = 'password';
|
||||
|
||||
if (connectionType === 'SSH') {
|
||||
authMethodForDb = input.auth_method!; // Already validated above
|
||||
if (input.auth_method === 'password') {
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
sshKeyIdToSave = null; // Password auth cannot use ssh_key_id
|
||||
} else { // auth_method is 'key'
|
||||
if (input.ssh_key_id) {
|
||||
// Validate the provided ssh_key_id
|
||||
const keyExists = await SshKeyService.getSshKeyDbRowById(input.ssh_key_id);
|
||||
if (!keyExists) {
|
||||
throw new Error(`提供的 SSH 密钥 ID ${input.ssh_key_id} 无效或不存在。`);
|
||||
}
|
||||
sshKeyIdToSave = input.ssh_key_id;
|
||||
// When using ssh_key_id, connection's own key fields should be null
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
} else if (input.private_key) {
|
||||
// Encrypt the provided private key and passphrase
|
||||
encryptedPrivateKey = encrypt(input.private_key!);
|
||||
if (input.passphrase) {
|
||||
encryptedPassphrase = encrypt(input.passphrase);
|
||||
}
|
||||
sshKeyIdToSave = null; // Ensure ssh_key_id is null if providing key directly
|
||||
} else {
|
||||
// This case should be caught by validation above, but as a safeguard:
|
||||
throw new Error('SSH 密钥认证方式内部错误:未提供 private_key 或 ssh_key_id。');
|
||||
}
|
||||
}
|
||||
} else if (connectionType === 'RDP') { // RDP
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
// authMethodForDb remains 'password' for RDP
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
} else { // VNC
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
authMethodForDb = 'password'; // VNC always uses password auth
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
}
|
||||
|
||||
// 3. 准备仓库数据
|
||||
let defaultPort = 22; // Default for SSH
|
||||
if (connectionType === 'RDP') {
|
||||
defaultPort = 3389;
|
||||
} else if (connectionType === 'VNC') {
|
||||
defaultPort = 5900; // Default VNC port
|
||||
}
|
||||
// +++ Explicitly type connectionData using the local alias +++
|
||||
const connectionData: ConnectionDataForRepo = {
|
||||
name: input.name || '',
|
||||
type: connectionType,
|
||||
host: input.host,
|
||||
port: input.port ?? defaultPort, // Use type-specific default port
|
||||
username: input.username,
|
||||
auth_method: authMethodForDb, // Use determined auth method
|
||||
encrypted_password: encryptedPassword,
|
||||
encrypted_private_key: encryptedPrivateKey, // Null if using ssh_key_id or RDP
|
||||
encrypted_passphrase: encryptedPassphrase, // Null if using ssh_key_id or RDP
|
||||
ssh_key_id: sshKeyIdToSave, // +++ Add ssh_key_id +++
|
||||
notes: input.notes ?? null, // Add notes field
|
||||
proxy_id: input.proxy_id ?? null, // 直接使用输入的 proxy_id
|
||||
proxy_type: input.proxy_type ?? null, // 新增 proxy_type
|
||||
jump_chain: processedJumpChain,
|
||||
};
|
||||
// Remove ssh_key_id property if it's null before logging/saving if repository expects exact type match without optional nulls
|
||||
const finalConnectionData = { ...connectionData };
|
||||
if (finalConnectionData.ssh_key_id === null) {
|
||||
delete (finalConnectionData as any).ssh_key_id; // Adjust based on repository function signature if needed
|
||||
}
|
||||
console.log('[Service:createConnection] Data being passed to ConnectionRepository.createConnection:', JSON.stringify(finalConnectionData, null, 2)); // Log data before saving
|
||||
|
||||
// 4. 在仓库中创建连接记录
|
||||
// Pass the potentially modified finalConnectionData
|
||||
const newConnectionId = await ConnectionRepository.createConnection(finalConnectionData as Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>);
|
||||
|
||||
// 5. 处理标签
|
||||
const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? [];
|
||||
if (tagIds.length > 0) {
|
||||
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
|
||||
}
|
||||
|
||||
// 6. 记录审计操作
|
||||
const newConnection = await getConnectionById(newConnectionId);
|
||||
if (!newConnection) {
|
||||
// 如果创建成功,这理论上不应该发生
|
||||
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
|
||||
throw new Error('创建连接后无法检索到该连接。');
|
||||
}
|
||||
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, type: newConnection.type, name: newConnection.name, host: newConnection.host }); // Add type to audit log
|
||||
|
||||
// 7. 返回新创建的带标签的连接
|
||||
return newConnection;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新连接信息
|
||||
*/
|
||||
export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise<ConnectionWithTags | null> => {
|
||||
// 1. 获取当前连接数据(包括加密字段)以进行比较
|
||||
const currentFullConnection = await ConnectionRepository.findFullConnectionById(id);
|
||||
if (!currentFullConnection) {
|
||||
return null; // 未找到连接
|
||||
}
|
||||
|
||||
// 2. 准备更新数据
|
||||
// Explicitly type dataToUpdate to match the repository's expected input, including ssh_key_id, jump_chain and proxy_type
|
||||
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null; jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
// Determine the final type, converting input type to uppercase if provided
|
||||
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type;
|
||||
|
||||
// 处理 jump_chain 和 proxy_id
|
||||
if (input.jump_chain !== undefined || input.proxy_id !== undefined) {
|
||||
const currentProxyId = input.proxy_id !== undefined ? input.proxy_id : currentFullConnection.proxy_id;
|
||||
|
||||
let jumpChainFromDb: number[] | null = null;
|
||||
if (currentFullConnection.jump_chain) { // currentFullConnection.jump_chain is string | null
|
||||
try {
|
||||
jumpChainFromDb = JSON.parse(currentFullConnection.jump_chain) as number[];
|
||||
} catch (e) {
|
||||
console.error(`[Service:updateConnection] Failed to parse jump_chain from DB for connection ${id}: ${currentFullConnection.jump_chain}`, e);
|
||||
// Treat as null if parsing fails, or consider throwing an error
|
||||
jumpChainFromDb = null;
|
||||
}
|
||||
}
|
||||
const currentJumpChainForValidation: number[] | null | undefined = input.jump_chain !== undefined ? input.jump_chain : jumpChainFromDb;
|
||||
|
||||
const processedJumpChain = await _validateAndProcessJumpChain(currentJumpChainForValidation, currentProxyId, id);
|
||||
|
||||
dataToUpdate.jump_chain = processedJumpChain;
|
||||
// 直接使用 currentProxyId,不再因为 jump_chain 存在而将其设为 null
|
||||
dataToUpdate.proxy_id = currentProxyId;
|
||||
}
|
||||
|
||||
|
||||
// 更新非凭证字段
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name || '';
|
||||
// Update type if changed, using the uppercase version
|
||||
if (input.type !== undefined && targetType !== currentFullConnection.type) dataToUpdate.type = targetType;
|
||||
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.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes update
|
||||
// proxy_id 的处理已移至 jump_chain 逻辑块中
|
||||
// if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id;
|
||||
if (input.proxy_type !== undefined) dataToUpdate.proxy_type = input.proxy_type; // 新增 proxy_type 更新
|
||||
// Handle ssh_key_id update (can be set to null or a new ID)
|
||||
if (input.ssh_key_id !== undefined) dataToUpdate.ssh_key_id = input.ssh_key_id;
|
||||
|
||||
// 处理认证方法更改或凭证更新 (根据 targetType)
|
||||
// Use the validated targetType for logic
|
||||
if (targetType === 'SSH') {
|
||||
const currentAuthMethod = currentFullConnection.auth_method;
|
||||
const inputAuthMethod = input.auth_method;
|
||||
|
||||
// Determine the final auth method for SSH
|
||||
const finalAuthMethod = inputAuthMethod || currentAuthMethod;
|
||||
if (finalAuthMethod !== currentAuthMethod) {
|
||||
dataToUpdate.auth_method = finalAuthMethod; // Update auth_method if it changed
|
||||
}
|
||||
|
||||
if (finalAuthMethod === 'password') {
|
||||
// If switching to password or updating password
|
||||
if (input.password !== undefined) { // Check if password was provided in input
|
||||
if (!input.password && finalAuthMethod !== currentAuthMethod) {
|
||||
// Switching to password requires a password
|
||||
throw new Error('切换到密码认证时需要提供 password。');
|
||||
}
|
||||
// Encrypt if password is not empty, otherwise set to null (to clear)
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// When switching to password, clear key fields and ssh_key_id
|
||||
if (finalAuthMethod !== currentAuthMethod) {
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // Clear ssh_key_id when switching to password
|
||||
}
|
||||
} else { // finalAuthMethod is 'key'
|
||||
// Handle ssh_key_id selection or direct key input
|
||||
if (input.ssh_key_id !== undefined) {
|
||||
// User selected a stored key
|
||||
if (input.ssh_key_id === null) {
|
||||
// User explicitly wants to clear the stored key association
|
||||
dataToUpdate.ssh_key_id = null;
|
||||
// If clearing ssh_key_id, we might need a direct key, but validation should handle this?
|
||||
// Or assume clearing means switching back to direct key input (which might be empty)
|
||||
// Let's assume clearing ssh_key_id means we expect a direct key or nothing
|
||||
if (input.private_key === undefined) {
|
||||
// If no direct key provided when clearing ssh_key_id, clear connection's key fields
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
} else {
|
||||
// Encrypt the direct key provided alongside clearing ssh_key_id
|
||||
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
}
|
||||
} else {
|
||||
// Validate the provided ssh_key_id
|
||||
const keyExists = await SshKeyService.getSshKeyDbRowById(input.ssh_key_id);
|
||||
if (!keyExists) {
|
||||
throw new Error(`提供的 SSH 密钥 ID ${input.ssh_key_id} 无效或不存在。`);
|
||||
}
|
||||
dataToUpdate.ssh_key_id = input.ssh_key_id;
|
||||
// Clear direct key fields when selecting a stored key
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
}
|
||||
needsCredentialUpdate = true; // Changing key source is a credential update
|
||||
} else if (input.private_key !== undefined) {
|
||||
// User provided a direct key
|
||||
if (!input.private_key && finalAuthMethod !== currentAuthMethod) {
|
||||
// Switching to key requires a private key if not using ssh_key_id
|
||||
throw new Error('切换到密钥认证时需要提供 private_key 或选择一个已保存的密钥。');
|
||||
}
|
||||
// Encrypt if key is not empty, otherwise set to null (to clear)
|
||||
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||
// Update passphrase only if direct key was provided OR passphrase itself was provided
|
||||
if (input.passphrase !== undefined) {
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
} else if (input.private_key) {
|
||||
// If only private_key is provided, clear passphrase
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
}
|
||||
dataToUpdate.ssh_key_id = null; // Clear ssh_key_id when providing direct key
|
||||
needsCredentialUpdate = true;
|
||||
} else if (input.passphrase !== undefined && !input.ssh_key_id && currentFullConnection.encrypted_private_key) {
|
||||
// Only passphrase provided, and not using ssh_key_id, and a direct key already exists
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
|
||||
// When switching to key, clear password field
|
||||
if (finalAuthMethod !== currentAuthMethod) {
|
||||
dataToUpdate.encrypted_password = null;
|
||||
}
|
||||
}
|
||||
} else if (targetType === 'RDP') { // targetType is 'RDP'
|
||||
// RDP only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to RDP or updating RDP
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id
|
||||
}
|
||||
} else { // targetType is 'VNC'
|
||||
// VNC only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to VNC or updating VNC
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果有更改,则更新连接记录
|
||||
const hasNonTagChanges = Object.keys(dataToUpdate).length > 0;
|
||||
let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段
|
||||
if (hasNonTagChanges) {
|
||||
updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段
|
||||
console.log(`[Service:updateConnection] Data being passed to ConnectionRepository.updateConnection for ID ${id}:`, JSON.stringify(dataToUpdate, null, 2)); // ADD THIS LOG
|
||||
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
|
||||
if (!updated) {
|
||||
// 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践
|
||||
throw new Error('更新连接记录失败。');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 如果 tag_ids 已更新,则将其添加到审计日志
|
||||
if (input.tag_ids !== undefined) {
|
||||
updatedFieldsForAudit.push('tag_ids');
|
||||
}
|
||||
|
||||
|
||||
// 5. 如果进行了任何更改,则记录审计操作
|
||||
if (hasNonTagChanges || input.tag_ids !== undefined) {
|
||||
// Add type to audit log if it was updated
|
||||
const auditDetails: any = { connectionId: id, updatedFields: updatedFieldsForAudit };
|
||||
if (dataToUpdate.type) {
|
||||
auditDetails.newType = dataToUpdate.type;
|
||||
}
|
||||
auditLogService.logAction('CONNECTION_UPDATED', auditDetails);
|
||||
}
|
||||
|
||||
// 6. 获取并返回更新后的连接
|
||||
return getConnectionById(id);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 删除连接
|
||||
*/
|
||||
export const deleteConnection = async (id: number): Promise<boolean> => {
|
||||
const deleted = await ConnectionRepository.deleteConnection(id);
|
||||
if (deleted) {
|
||||
// 删除成功后记录审计操作
|
||||
auditLogService.logAction('CONNECTION_DELETED', { connectionId: id });
|
||||
}
|
||||
return deleted;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取连接信息(包含标签)以及解密后的凭证(如果适用)
|
||||
* @param id 连接 ID
|
||||
* @returns 包含 ConnectionWithTags 和解密后密码/密钥的对象,或 null
|
||||
*/
|
||||
export const getConnectionWithDecryptedCredentials = async (
|
||||
id: number
|
||||
): Promise<{ connection: ConnectionWithTags; decryptedPassword?: string; decryptedPrivateKey?: string; decryptedPassphrase?: string } | null> => {
|
||||
// 1. 获取完整的连接数据(包含加密字段和可能的 ssh_key_id)
|
||||
const fullConnectionDbRow = await ConnectionRepository.findFullConnectionById(id);
|
||||
if (!fullConnectionDbRow) {
|
||||
console.log(`[Service:getConnWithDecrypt] Connection not found for ID: ${id}`);
|
||||
return null;
|
||||
}
|
||||
// Convert DbRow to the stricter FullConnectionData type expected by the service/types file
|
||||
// Handle potential undefined by defaulting to null
|
||||
const fullConnection: FullConnectionData = {
|
||||
...fullConnectionDbRow,
|
||||
encrypted_password: fullConnectionDbRow.encrypted_password ?? null,
|
||||
encrypted_private_key: fullConnectionDbRow.encrypted_private_key ?? null, // May be null if using ssh_key_id
|
||||
encrypted_passphrase: fullConnectionDbRow.encrypted_passphrase ?? null, // May be null if using ssh_key_id
|
||||
ssh_key_id: fullConnectionDbRow.ssh_key_id ?? null, // +++ Include ssh_key_id +++
|
||||
// Ensure other fields match FullConnectionData if necessary
|
||||
} as FullConnectionData & { ssh_key_id: number | null }; // Type assertion
|
||||
|
||||
// 2. 获取带标签的连接数据(用于返回给调用者)
|
||||
const connectionWithTags: ConnectionWithTags | null = await ConnectionRepository.findConnectionByIdWithTags(id);
|
||||
if (!connectionWithTags) {
|
||||
// This shouldn't happen if findFullConnectionById succeeded, but good practice to check
|
||||
console.error(`[Service:getConnWithDecrypt] Mismatch: Full connection found but tagged connection not found for ID: ${id}`);
|
||||
// Consider throwing an error or returning a specific error state
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 解密凭证
|
||||
let decryptedPassword: string | undefined = undefined;
|
||||
let decryptedPrivateKey: string | undefined = undefined;
|
||||
let decryptedPassphrase: string | undefined = undefined;
|
||||
|
||||
try {
|
||||
// Decrypt password if method is 'password' and encrypted password exists
|
||||
if (fullConnection.auth_method === 'password' && fullConnection.encrypted_password) {
|
||||
decryptedPassword = decrypt(fullConnection.encrypted_password);
|
||||
}
|
||||
// Decrypt key and passphrase if method is 'key'
|
||||
else if (fullConnection.auth_method === 'key') {
|
||||
if (fullConnection.ssh_key_id) {
|
||||
// +++ If using ssh_key_id, fetch and decrypt the stored key +++
|
||||
console.log(`[Service:getConnWithDecrypt] Connection ${id} uses stored SSH key ID: ${fullConnection.ssh_key_id}. Fetching key...`);
|
||||
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(fullConnection.ssh_key_id);
|
||||
if (!storedKeyDetails) {
|
||||
// This indicates an inconsistency, as the ssh_key_id should be valid
|
||||
console.error(`[Service:getConnWithDecrypt] Error: Connection ${id} references non-existent SSH key ID ${fullConnection.ssh_key_id}`);
|
||||
throw new Error(`关联的 SSH 密钥 (ID: ${fullConnection.ssh_key_id}) 未找到。`);
|
||||
}
|
||||
decryptedPrivateKey = storedKeyDetails.privateKey;
|
||||
decryptedPassphrase = storedKeyDetails.passphrase;
|
||||
console.log(`[Service:getConnWithDecrypt] Successfully fetched and decrypted stored SSH key ${fullConnection.ssh_key_id} for connection ${id}.`);
|
||||
} else if (fullConnection.encrypted_private_key) {
|
||||
// Decrypt the key stored directly in the connection record
|
||||
decryptedPrivateKey = decrypt(fullConnection.encrypted_private_key);
|
||||
// Only decrypt passphrase if it exists alongside the direct key
|
||||
if (fullConnection.encrypted_passphrase) {
|
||||
decryptedPassphrase = decrypt(fullConnection.encrypted_passphrase);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Service:getConnWithDecrypt] Connection ${id} uses key auth but has neither ssh_key_id nor encrypted_private_key.`);
|
||||
// No key available to decrypt
|
||||
}
|
||||
}
|
||||
} catch (error: any) { // Catch decryption or key fetching errors
|
||||
console.error(`[Service:getConnWithDecrypt] Failed to decrypt credentials for connection ID ${id}:`, error);
|
||||
// Decide how to handle decryption errors. Throw? Return null password?
|
||||
// For now, we'll log and continue, returning undefined credentials.
|
||||
// Consider throwing an error if credentials are required but decryption fails.
|
||||
// Or return a specific error structure: return { error: 'Decryption failed' };
|
||||
}
|
||||
|
||||
console.log(`[Service:getConnWithDecrypt] Returning data for ID: ${id}, Auth Method: ${fullConnection.auth_method}`);
|
||||
return {
|
||||
connection: connectionWithTags,
|
||||
decryptedPassword,
|
||||
decryptedPrivateKey,
|
||||
decryptedPassphrase,
|
||||
};
|
||||
};
|
||||
// 注意:testConnection、importConnections、exportConnections 逻辑
|
||||
// 将分别移至 SshService 和 ImportExportService。
|
||||
|
||||
|
||||
/**
|
||||
* 克隆连接
|
||||
* @param originalId 要克隆的原始连接 ID
|
||||
* @param newName 新连接的名称
|
||||
* @returns 克隆后的新连接信息(包含标签)
|
||||
*/
|
||||
export const cloneConnection = async (originalId: number, newName: string): Promise<ConnectionWithTags> => {
|
||||
// 1. 检查新名称是否已存在
|
||||
const existingByName = await ConnectionRepository.findConnectionByName(newName);
|
||||
if (existingByName) {
|
||||
throw new Error(`名称为 "${newName}" 的连接已存在。`);
|
||||
}
|
||||
|
||||
// 2. 获取原始连接的完整数据(包括加密字段和 ssh_key_id)
|
||||
const originalFullConnection = await ConnectionRepository.findFullConnectionById(originalId);
|
||||
if (!originalFullConnection) {
|
||||
throw new Error(`ID 为 ${originalId} 的原始连接未找到。`);
|
||||
}
|
||||
|
||||
// 3. 准备新连接的数据
|
||||
// 使用 Omit 来排除不需要的字段,并确保类型正确
|
||||
const dataForNewConnection: Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'> = {
|
||||
name: newName,
|
||||
type: originalFullConnection.type,
|
||||
host: originalFullConnection.host,
|
||||
port: originalFullConnection.port,
|
||||
username: originalFullConnection.username,
|
||||
auth_method: originalFullConnection.auth_method,
|
||||
encrypted_password: originalFullConnection.encrypted_password ?? null,
|
||||
encrypted_private_key: originalFullConnection.encrypted_private_key ?? null,
|
||||
encrypted_passphrase: originalFullConnection.encrypted_passphrase ?? null,
|
||||
ssh_key_id: originalFullConnection.ssh_key_id ?? null, // 保留原始的 ssh_key_id
|
||||
proxy_id: originalFullConnection.proxy_id ?? null,
|
||||
proxy_type: originalFullConnection.proxy_type ?? null, // 新增 proxy_type 复制
|
||||
notes: originalFullConnection.notes ?? null, // 确保 notes 被复制
|
||||
jump_chain: originalFullConnection.jump_chain ? JSON.parse(originalFullConnection.jump_chain) as number[] : null, // 复制并解析 jump_chain
|
||||
// 移除不存在的 RDP 字段复制
|
||||
// ...(originalFullConnection.rdp_security && { rdp_security: originalFullConnection.rdp_security }),
|
||||
// ...(originalFullConnection.rdp_ignore_cert !== undefined && { rdp_ignore_cert: originalFullConnection.rdp_ignore_cert }),
|
||||
};
|
||||
|
||||
// 4. 创建新连接记录
|
||||
const newConnectionId = await ConnectionRepository.createConnection(dataForNewConnection);
|
||||
|
||||
// 5. 复制原始连接的标签
|
||||
const originalTags = await ConnectionRepository.findConnectionTags(originalId);
|
||||
if (originalTags.length > 0) {
|
||||
const tagIds = originalTags.map(tag => tag.id);
|
||||
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
|
||||
}
|
||||
|
||||
// 6. 记录审计操作
|
||||
const clonedConnection = await getConnectionById(newConnectionId);
|
||||
if (!clonedConnection) {
|
||||
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after cloning from ${originalId}.`);
|
||||
throw new Error('克隆连接后无法检索到该连接。');
|
||||
}
|
||||
// 使用 CONNECTION_CREATED 事件,但添加额外信息表明是克隆操作
|
||||
auditLogService.logAction('CONNECTION_CREATED', {
|
||||
connectionId: clonedConnection.id,
|
||||
type: clonedConnection.type,
|
||||
name: clonedConnection.name,
|
||||
host: clonedConnection.host,
|
||||
clonedFromId: originalId // 添加克隆来源信息
|
||||
});
|
||||
|
||||
// 7. 返回新创建的带标签的连接
|
||||
return clonedConnection;
|
||||
};
|
||||
// 注意:updateConnectionTags 现在主要由 updateConnection 内部调用,
|
||||
// 或者可以保留用于单独更新单个连接标签的场景(如果需要的话)。
|
||||
// 为了解决嵌套事务问题,我们添加一个新的批量添加函数。
|
||||
|
||||
/**
|
||||
* 为指定的一组连接添加一个标签
|
||||
* @param connectionIds 连接 ID 数组
|
||||
* @param tagId 要添加的标签 ID
|
||||
*/
|
||||
export const addTagToConnections = async (connectionIds: number[], tagId: number): Promise<void> => {
|
||||
// 1. 验证 tagId 是否有效(可选,但建议)
|
||||
// const tagExists = await TagRepository.findTagById(tagId); // 需要导入 TagRepository
|
||||
// if (!tagExists) {
|
||||
// throw new Error(`标签 ID ${tagId} 不存在。`);
|
||||
// }
|
||||
|
||||
// 2. 调用仓库层批量添加标签
|
||||
try {
|
||||
await ConnectionRepository.addTagToMultipleConnections(connectionIds, tagId);
|
||||
|
||||
// 记录审计日志 (可以考虑为批量操作定义新的审计类型)
|
||||
// TODO: 定义 'CONNECTIONS_TAG_ADDED' 审计日志类型
|
||||
// auditLogService.logAction('CONNECTIONS_TAG_ADDED', { connectionIds, tagId });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Service: 为连接 ${connectionIds.join(', ')} 添加标签 ${tagId} 时发生错误:`, error);
|
||||
throw error; // 重新抛出错误
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新指定连接的标签关联 (保留此函数用于可能的其他用途,但主要逻辑转移到 addTagToConnections)
|
||||
* @param connectionId 连接 ID
|
||||
* @param tagIds 新的标签 ID 数组
|
||||
* @returns boolean 指示操作是否成功(找到连接并尝试更新)
|
||||
*/
|
||||
export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise<boolean> => {
|
||||
try {
|
||||
const updated = await ConnectionRepository.updateConnectionTags(connectionId, tagIds);
|
||||
return updated;
|
||||
} catch (error: any) {
|
||||
console.error(`Service: 更新连接 ${connectionId} 的标签时发生错误:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,211 +0,0 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// --- Interfaces (与前端 DockerManager.vue 中的定义保持一致) ---
|
||||
// 理想情况下,这些类型应该放在共享的 types 包中
|
||||
interface PortInfo {
|
||||
IP?: string;
|
||||
PrivatePort: number;
|
||||
PublicPort?: number;
|
||||
Type: 'tcp' | 'udp' | string;
|
||||
}
|
||||
|
||||
// 与前端一致的 Stats 接口
|
||||
interface DockerStats {
|
||||
ID: string; // Docker stats 返回的是 ID
|
||||
Name: string;
|
||||
CPUPerc: string;
|
||||
MemUsage: string;
|
||||
MemPerc: string;
|
||||
NetIO: string;
|
||||
BlockIO: string;
|
||||
PIDs: string;
|
||||
}
|
||||
|
||||
interface DockerContainer {
|
||||
Id: string; // docker ps 返回的是 Id
|
||||
Names: string[];
|
||||
Image: string;
|
||||
ImageID: string;
|
||||
Command: string;
|
||||
Created: number;
|
||||
State: 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead' | string;
|
||||
Status: string;
|
||||
Ports: PortInfo[];
|
||||
Labels: Record<string, string>;
|
||||
stats?: DockerStats | null; // 添加可选的 stats 字段
|
||||
// 根据 `docker ps --format '{{json .}}'` 的输出添加其他需要的字段
|
||||
}
|
||||
|
||||
// 定义命令类型
|
||||
export type DockerCommand = 'start' | 'stop' | 'restart' | 'remove'; // 使用实际的 docker 命令
|
||||
|
||||
// @Service() // Removed typedi decorator
|
||||
export class DockerService {
|
||||
private isDockerAvailableCache: boolean | null = null;
|
||||
private readonly commandTimeout = 15000; // 15 秒超时
|
||||
|
||||
/**
|
||||
* 检查 Docker CLI 是否可用。包含缓存以避免重复检查。
|
||||
*/
|
||||
async checkDockerAvailability(): Promise<boolean> {
|
||||
if (this.isDockerAvailableCache !== null) {
|
||||
return this.isDockerAvailableCache;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试执行一个简单的 docker 命令,如 docker version
|
||||
await execAsync('docker version', { timeout: 2000 }); // 5秒超时
|
||||
this.isDockerAvailableCache = true;
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
|
||||
this.isDockerAvailableCache = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Docker 容器的状态 (包括已停止的)。
|
||||
*/
|
||||
async getContainerStatus(): Promise<{ available: boolean; containers: DockerContainer[] }> {
|
||||
const available = await this.checkDockerAvailability();
|
||||
if (!available) {
|
||||
return { available: false, containers: [] };
|
||||
}
|
||||
|
||||
let allContainers: DockerContainer[] = [];
|
||||
const statsMap = new Map<string, DockerStats>();
|
||||
|
||||
// 1. 获取所有容器的基本信息
|
||||
try {
|
||||
const { stdout: psStdout } = await execAsync("docker ps -a --no-trunc --format '{{json .}}'", { timeout: this.commandTimeout });
|
||||
const lines = psStdout.trim().split('\n');
|
||||
allContainers = lines
|
||||
.map(line => {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (typeof data.Names === 'string') {
|
||||
data.Names = data.Names.split(',');
|
||||
}
|
||||
if (!Array.isArray(data.Ports)) {
|
||||
data.Ports = [];
|
||||
}
|
||||
// 初始化 stats 为 null
|
||||
data.stats = null;
|
||||
return data as DockerContainer;
|
||||
} catch (parseError) {
|
||||
console.error(`[DockerService] Failed to parse container JSON line: ${line}`, { error: parseError });
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((container): container is DockerContainer => container !== null);
|
||||
} catch (error: any) {
|
||||
console.error('[DockerService] Failed to execute "docker ps"', { error: error.message, stderr: error.stderr });
|
||||
this.isDockerAvailableCache = false;
|
||||
return { available: false, containers: [] };
|
||||
}
|
||||
|
||||
// 2. 获取正在运行容器的统计信息
|
||||
try {
|
||||
// --no-stream 获取一次性快照
|
||||
const { stdout: statsStdout } = await execAsync("docker stats --no-stream --format '{{json .}}'", { timeout: this.commandTimeout });
|
||||
const statsLines = statsStdout.trim().split('\n');
|
||||
statsLines.forEach(line => {
|
||||
try {
|
||||
const statsData = JSON.parse(line) as DockerStats;
|
||||
// docker stats 返回的 ID 可能与 docker ps 的 Id 字段匹配
|
||||
// 注意:docker stats 可能返回短 ID,而 docker ps -a --no-trunc 返回长 ID
|
||||
// 实际应用中可能需要处理 ID 匹配问题,这里假设它们能直接匹配或通过 Name 匹配
|
||||
// 为了简化,我们优先使用 ID 匹配
|
||||
if (statsData.ID) {
|
||||
// 尝试直接用 ID 作为 key (可能是短 ID)
|
||||
// 如果 statsData.ID 是短 ID,而 allContainers 的 Id 是长 ID,这里可能匹配不上
|
||||
// 一个更健壮的方法是先从 allContainers 构建一个 Name -> ID 的映射
|
||||
// 但这里我们先简化处理,假设 ID 能匹配上
|
||||
statsMap.set(statsData.ID, statsData);
|
||||
// 也可以考虑用 Name 匹配作为备选
|
||||
// if (statsData.Name) statsMap.set(statsData.Name, statsData);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error(`[DockerService] Failed to parse stats JSON line: ${line}`, { error: parseError });
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 获取 stats 失败不应阻止返回容器列表,只是 stats 会是 null
|
||||
console.warn('[DockerService] Failed to execute "docker stats"', { error: error.message, stderr: error.stderr });
|
||||
}
|
||||
|
||||
// 3. 合并统计信息到容器列表
|
||||
allContainers.forEach(container => {
|
||||
// 尝试用容器的长 ID 或短 ID (前12位) 或 Name 去匹配 statsMap
|
||||
const shortId = container.Id.substring(0, 12);
|
||||
const stats = statsMap.get(container.Id) || statsMap.get(shortId) || statsMap.get(container.Names[0]); // 尝试多种匹配方式
|
||||
if (stats) {
|
||||
container.stats = stats;
|
||||
}
|
||||
});
|
||||
|
||||
return { available: true, containers: allContainers };
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定的容器执行命令。
|
||||
* @param containerId 容器 ID
|
||||
* @param command 命令 ('start', 'stop', 'restart', 'remove')
|
||||
*/
|
||||
async executeContainerCommand(containerId: string, command: DockerCommand): Promise<void> {
|
||||
const available = await this.checkDockerAvailability();
|
||||
if (!available) {
|
||||
throw new Error('Docker is not available.');
|
||||
}
|
||||
|
||||
// 参数校验和清理,防止命令注入
|
||||
const cleanContainerId = containerId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (!cleanContainerId) {
|
||||
throw new Error('Invalid container ID format.');
|
||||
}
|
||||
|
||||
let dockerCliCommand: string;
|
||||
switch (command) {
|
||||
case 'start':
|
||||
dockerCliCommand = `docker start ${cleanContainerId}`;
|
||||
break;
|
||||
case 'stop':
|
||||
dockerCliCommand = `docker stop ${cleanContainerId}`;
|
||||
break;
|
||||
case 'restart':
|
||||
dockerCliCommand = `docker restart ${cleanContainerId}`;
|
||||
break;
|
||||
case 'remove':
|
||||
// 使用 -f 强制删除正在运行的容器,对应前端的 'down' 意图
|
||||
dockerCliCommand = `docker rm -f ${cleanContainerId}`;
|
||||
break;
|
||||
default:
|
||||
// 防止未知的命令类型
|
||||
console.error(`[DockerService] Received unknown command type: ${command}`); // Use console.error
|
||||
throw new Error(`Unsupported Docker command: ${command}`);
|
||||
}
|
||||
|
||||
console.log(`[DockerService] Executing command: ${dockerCliCommand}`); // Use console.log
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(dockerCliCommand, { timeout: this.commandTimeout });
|
||||
if (stderr) {
|
||||
// Docker 命令有时会将正常信息输出到 stderr (例如 rm 返回容器 ID)
|
||||
// 但也可能包含错误信息
|
||||
console.warn(`[DockerService] Command "${dockerCliCommand}" produced stderr:`, { stderr }); // Use console.warn
|
||||
// 可以根据 stderr 内容判断是否真的是错误
|
||||
if (stderr.toLowerCase().includes('error') || stderr.toLowerCase().includes('failed')) {
|
||||
throw new Error(`Docker command failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
console.log(`[DockerService] Command "${dockerCliCommand}" executed successfully.`, { stdout }); // Use console.log
|
||||
} catch (error: any) {
|
||||
console.error(`[DockerService] Failed to execute command "${dockerCliCommand}"`, { error: error.message, stderr: error.stderr }); // Use console.error
|
||||
// 抛出错误,让 Controller 层处理并返回给前端
|
||||
throw new Error(`Failed to execute Docker command "${command}": ${error.stderr || error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import * as FavoritePathsRepository from '../favorite-paths/favorite-paths.repository';
|
||||
import { FavoritePath } from '../favorite-paths/favorite-paths.repository';
|
||||
|
||||
// 定义排序类型
|
||||
export type FavoritePathSortBy = 'name' | 'last_used_at';
|
||||
|
||||
/**
|
||||
* 添加收藏路径
|
||||
* @param name - 路径名称 (可选)
|
||||
* @param path - 路径内容
|
||||
* @returns 返回添加记录的 ID
|
||||
*/
|
||||
export const addFavoritePath = async (name: string | null, path: string): Promise<number> => {
|
||||
if (!path || path.trim().length === 0) {
|
||||
throw new Error('路径内容不能为空');
|
||||
}
|
||||
// 如果 name 是空字符串,则视为 null
|
||||
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
||||
const favoritePathId = await FavoritePathsRepository.addFavoritePath(finalName, path.trim());
|
||||
return favoritePathId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新收藏路径
|
||||
* @param id - 要更新的记录 ID
|
||||
* @param name - 新的路径名称 (可选)
|
||||
* @param path - 新的路径内容
|
||||
* @returns 返回是否成功更新 (更新行数 > 0)
|
||||
*/
|
||||
export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise<boolean> => {
|
||||
if (!path || path.trim().length === 0) {
|
||||
throw new Error('路径内容不能为空');
|
||||
}
|
||||
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
||||
const pathUpdated = await FavoritePathsRepository.updateFavoritePath(id, finalName, path.trim());
|
||||
return pathUpdated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除收藏路径
|
||||
* @param id - 要删除的记录 ID
|
||||
* @returns 返回是否成功删除 (删除行数 > 0)
|
||||
*/
|
||||
export const deleteFavoritePath = async (id: number): Promise<boolean> => {
|
||||
const changes = await FavoritePathsRepository.deleteFavoritePath(id);
|
||||
return changes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有收藏路径,并按指定方式排序
|
||||
* @param sortBy - 排序字段 ('name' 或 'usage_count')
|
||||
* @returns 返回排序后的收藏路径数组
|
||||
*/
|
||||
export const getAllFavoritePaths = async (sortBy: FavoritePathSortBy = 'name'): Promise<FavoritePath[]> => {
|
||||
return FavoritePathsRepository.getAllFavoritePaths(sortBy);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新收藏路径的上次使用时间
|
||||
* @param id - 收藏路径的ID
|
||||
* @returns Promise<boolean> - 操作是否成功
|
||||
*/
|
||||
export const updateFavoritePathLastUsed = async (id: number): Promise<boolean> => {
|
||||
// 未来可能在这里添加额外的业务逻辑或验证
|
||||
return FavoritePathsRepository.updateFavoritePathLastUsedAt(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个收藏路径
|
||||
* @param id - 记录 ID
|
||||
* @returns 返回找到的收藏路径,或 undefined
|
||||
*/
|
||||
export const getFavoritePathById = async (id: number): Promise<FavoritePath | undefined> => {
|
||||
return FavoritePathsRepository.findFavoritePathById(id);
|
||||
};
|
||||
@@ -1,223 +0,0 @@
|
||||
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import { settingsService } from './settings.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
|
||||
const notificationService = new NotificationService(); // 实例化 NotificationService
|
||||
|
||||
// 黑名单相关设置的 Key
|
||||
const MAX_LOGIN_ATTEMPTS_KEY = 'maxLoginAttempts';
|
||||
const LOGIN_BAN_DURATION_KEY = 'loginBanDuration'; // 单位:秒
|
||||
|
||||
// 与 ipWhitelist.middleware.ts 保持一致
|
||||
const LOCAL_IPS = [
|
||||
'127.0.0.1', // IPv4 本地回环
|
||||
'::1', // IPv6 本地回环
|
||||
'localhost' // 本地主机名 (虽然通常解析为上面两者,但也包含以防万一)
|
||||
];
|
||||
|
||||
// 黑名单条目接口
|
||||
interface IpBlacklistEntry {
|
||||
ip: string;
|
||||
attempts: number;
|
||||
last_attempt_at: number;
|
||||
blocked_until: number | null;
|
||||
}
|
||||
|
||||
|
||||
type DbIpBlacklistRow = IpBlacklistEntry;
|
||||
|
||||
export class IpBlacklistService {
|
||||
|
||||
/**
|
||||
* 获取指定 IP 的黑名单记录
|
||||
* @param ip IP 地址
|
||||
* @returns 黑名单记录或 undefined
|
||||
*/
|
||||
private async getEntry(ip: string): Promise<IpBlacklistEntry | undefined> {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const row = await getDbRow<DbIpBlacklistRow>(db, 'SELECT * FROM ip_blacklist WHERE ip = ?', [ip]);
|
||||
return row; // Returns undefined if not found
|
||||
} catch (err: any) {
|
||||
console.error(`[IP Blacklist] 查询 IP ${ip} 时出错:`, err.message);
|
||||
throw new Error('数据库查询失败'); // Re-throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 IP 是否当前被封禁
|
||||
* @param ip IP 地址
|
||||
* @returns 如果被封禁则返回 true,否则返回 false
|
||||
*/
|
||||
async isBlocked(ip: string): Promise<boolean> {
|
||||
// 首先检查功能是否启用
|
||||
if (!(await settingsService.isIpBlacklistEnabled())) {
|
||||
// console.log('[IP Blacklist] 功能已禁用,跳过 isBlocked 检查。');
|
||||
return false; // 如果禁用,则认为 IP 未被阻止
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await this.getEntry(ip);
|
||||
if (!entry) {
|
||||
return false; // 不在黑名单中
|
||||
}
|
||||
// 检查封禁时间是否已过
|
||||
if (entry.blocked_until && entry.blocked_until > Math.floor(Date.now() / 1000)) {
|
||||
console.log(`[IP Blacklist] IP ${ip} 当前被封禁,直到 ${new Date(entry.blocked_until * 1000).toISOString()}`);
|
||||
return true; // 仍在封禁期内
|
||||
}
|
||||
return false;
|
||||
} catch (error: any) { // Catch errors from getEntry
|
||||
console.error(`[IP Blacklist] 检查 IP ${ip} 封禁状态时出错:`, error.message);
|
||||
return false; // 出错时默认不封禁
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次登录失败尝试
|
||||
* 如果达到阈值,则封禁该 IP
|
||||
* @param ip IP 地址
|
||||
*/
|
||||
async recordFailedAttempt(ip: string): Promise<void> {
|
||||
// 首先检查功能是否启用
|
||||
if (!(await settingsService.isIpBlacklistEnabled())) {
|
||||
// console.log('[IP Blacklist] 功能已禁用,跳过 recordFailedAttempt。');
|
||||
return; // 如果禁用,则不记录失败尝试
|
||||
}
|
||||
|
||||
if (LOCAL_IPS.includes(ip)) {
|
||||
console.log(`[IP Blacklist] 检测到本地 IP ${ip} 登录失败,跳过黑名单处理。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const maxAttemptsStr = await settingsService.getSetting(MAX_LOGIN_ATTEMPTS_KEY);
|
||||
const banDurationStr = await settingsService.getSetting(LOGIN_BAN_DURATION_KEY);
|
||||
|
||||
const maxAttempts = parseInt(maxAttemptsStr || '5', 10) || 5;
|
||||
const banDuration = parseInt(banDurationStr || '300', 10) || 300;
|
||||
|
||||
const entry = await this.getEntry(ip);
|
||||
|
||||
if (entry) {
|
||||
const newAttempts = entry.attempts + 1;
|
||||
let blockedUntil = entry.blocked_until;
|
||||
let shouldNotify = false;
|
||||
|
||||
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} 再次登录失败,当前已处于封禁状态。`);
|
||||
}
|
||||
|
||||
await runDb(db,
|
||||
'UPDATE ip_blacklist SET attempts = ?, last_attempt_at = ?, blocked_until = ? WHERE ip = ?',
|
||||
[newAttempts, now, blockedUntil, ip]
|
||||
);
|
||||
|
||||
if (shouldNotify && blockedUntil) {
|
||||
notificationService.sendNotification('IP_BLOCKED', {
|
||||
ip: ip,
|
||||
attempts: newAttempts,
|
||||
duration: banDuration,
|
||||
blockedUntil: new Date(blockedUntil * 1000).toISOString()
|
||||
}).catch(err => console.error(`[IP Blacklist] 发送 IP_BLACKLISTED 通知失败 for IP ${ip}:`, err));
|
||||
}
|
||||
|
||||
} else {
|
||||
// Insert new record
|
||||
let blockedUntil: number | null = null;
|
||||
const attempts = 1;
|
||||
let shouldNotify = false;
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
blockedUntil = now + banDuration;
|
||||
shouldNotify = true;
|
||||
console.warn(`[IP Blacklist] IP ${ip} 首次登录失败即达到阈值 ${maxAttempts},将被封禁 ${banDuration} 秒。`);
|
||||
}
|
||||
|
||||
await runDb(db,
|
||||
'INSERT INTO ip_blacklist (ip, attempts, last_attempt_at, blocked_until) VALUES (?, ?, ?, ?)',
|
||||
[ip, attempts, now, blockedUntil]
|
||||
);
|
||||
|
||||
if (shouldNotify && blockedUntil) {
|
||||
notificationService.sendNotification('IP_BLOCKED', {
|
||||
ip: ip,
|
||||
attempts: attempts,
|
||||
duration: banDuration,
|
||||
blockedUntil: new Date(blockedUntil * 1000).toISOString()
|
||||
}).catch(err => console.error(`[IP Blacklist] 发送 IP_BLACKLISTED 通知失败 for IP ${ip}:`, err));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[IP Blacklist] 记录 IP ${ip} 失败尝试时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定 IP 的失败尝试次数和封禁状态 (例如登录成功后调用)
|
||||
* @param ip IP 地址
|
||||
*/
|
||||
async resetAttempts(ip: string): Promise<void> {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
await runDb(db, 'DELETE FROM ip_blacklist WHERE ip = ?', [ip]);
|
||||
console.log(`[IP Blacklist] 已重置 IP ${ip} 的失败尝试记录。`);
|
||||
} catch (error: any) {
|
||||
console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有黑名单记录 (用于管理界面)
|
||||
* @param limit 每页数量
|
||||
* @param offset 偏移量
|
||||
*/
|
||||
async getBlacklist(limit: number = 50, offset: number = 0): Promise<{ entries: IpBlacklistEntry[], total: number }> {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const entries = await allDb<DbIpBlacklistRow>(db,
|
||||
'SELECT * FROM ip_blacklist ORDER BY last_attempt_at DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset]
|
||||
);
|
||||
const countRow = await getDbRow<{ count: number }>(db, 'SELECT COUNT(*) as count FROM ip_blacklist');
|
||||
const total = countRow?.count ?? 0;
|
||||
return { entries, total };
|
||||
} catch (error: any) {
|
||||
console.error('[IP Blacklist] 获取黑名单列表时出错:', error.message);
|
||||
return { entries: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从黑名单中删除一个 IP (解除封禁)
|
||||
* @param ip IP 地址
|
||||
* @returns Promise<boolean> 是否成功删除
|
||||
*/
|
||||
async removeFromBlacklist(ip: string): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, 'DELETE FROM ip_blacklist WHERE ip = ?', [ip]);
|
||||
if (result.changes > 0) {
|
||||
console.log(`[IP Blacklist] 已从黑名单中删除 IP ${ip}。`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[IP Blacklist] 尝试删除 IP ${ip},但该 IP 不在黑名单中。`);
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, error.message);
|
||||
throw new Error(`从黑名单删除 IP ${ip} 时出错`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const ipBlacklistService = new IpBlacklistService();
|
||||
@@ -1,79 +0,0 @@
|
||||
import notificationProcessorService, { ProcessedNotification } from './notification.processor.service';
|
||||
import { NotificationChannelType, NotificationChannelConfig } from '../types/notification.types';
|
||||
|
||||
// 1. 定义通知发送器接口
|
||||
export interface INotificationSender {
|
||||
send(notification: ProcessedNotification): Promise<void>;
|
||||
}
|
||||
|
||||
// 导入具体的发送器实现
|
||||
import telegramSenderService from './senders/telegram.sender.service';
|
||||
import emailSenderService from './senders/email.sender.service';
|
||||
import webhookSenderService from './senders/webhook.sender.service';
|
||||
|
||||
|
||||
class NotificationDispatcherService {
|
||||
// 使用 Map 来存储不同渠道类型的发送器实例
|
||||
private senders: Map<NotificationChannelType, INotificationSender>;
|
||||
|
||||
constructor() {
|
||||
this.senders = new Map();
|
||||
// 注册具体的发送器实例
|
||||
this.registerSender('telegram', telegramSenderService);
|
||||
this.registerSender('email', emailSenderService);
|
||||
this.registerSender('webhook', webhookSenderService);
|
||||
|
||||
this.listenForNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个通知发送器实例
|
||||
* @param channelType 渠道类型
|
||||
* @param sender 发送器实例
|
||||
*/
|
||||
registerSender(channelType: NotificationChannelType, sender: INotificationSender) {
|
||||
if (this.senders.has(channelType)) {
|
||||
console.warn(`[NotificationDispatcher] 通道类型 '${channelType}' 的发送器已注册。将进行覆盖。`);
|
||||
}
|
||||
this.senders.set(channelType, sender);
|
||||
console.log(`[NotificationDispatcher] 已为通道类型 '${channelType}' 注册发送器。`);
|
||||
}
|
||||
|
||||
private listenForNotifications() {
|
||||
notificationProcessorService.on('sendNotification', (processedNotification: ProcessedNotification) => {
|
||||
// 使用 setImmediate 避免阻塞
|
||||
setImmediate(() => {
|
||||
this.dispatchNotification(processedNotification).catch(error => {
|
||||
console.error(`[NotificationDispatcher] 分发通道 ${processedNotification.channelType} 的通知时出错:`, error);
|
||||
});
|
||||
});
|
||||
});
|
||||
console.log('[NotificationDispatcher] 正在监听处理后的通知。');
|
||||
}
|
||||
|
||||
private async dispatchNotification(notification: ProcessedNotification) {
|
||||
const sender = this.senders.get(notification.channelType);
|
||||
|
||||
if (!sender) {
|
||||
console.warn(`[NotificationDispatcher] 没有为通道类型注册发送器: ${notification.channelType}。跳过通知。`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[NotificationDispatcher] 正在通过 ${notification.channelType} 分发通知`);
|
||||
try {
|
||||
await sender.send(notification);
|
||||
console.log(`[NotificationDispatcher] 已成功通过 ${notification.channelType} 发送通知`);
|
||||
} catch (error) {
|
||||
console.error(`[NotificationDispatcher] 通过 ${notification.channelType} 发送通知失败:`, error);
|
||||
// 这里可以添加失败重试或记录失败状态的逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例并导出
|
||||
const notificationDispatcherService = new NotificationDispatcherService();
|
||||
|
||||
// 导出接口,以便其他发送器可以实现它
|
||||
// (或者将接口移到 types 文件中)
|
||||
|
||||
export default notificationDispatcherService;
|
||||
@@ -1,241 +0,0 @@
|
||||
import eventService, { AppEventType, AppEventPayload } from './event.service';
|
||||
import { NotificationSettingsRepository } from '../notifications/notification.repository';
|
||||
import { NotificationSetting, NotificationEvent, NotificationChannelType, WebhookConfig, EmailConfig, TelegramConfig, NotificationChannelConfig } from '../types/notification.types';
|
||||
import i18next, { i18nInitializationPromise } from '../i18n';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// 定义处理后的通知数据结构
|
||||
export interface ProcessedNotification {
|
||||
channelType: NotificationChannelType;
|
||||
config: NotificationChannelConfig; // 包含发送所需的配置,如 URL, Token, SMTP 等
|
||||
subject?: string; // 主要用于 Email
|
||||
body: string; // 格式化后的通知内容主体
|
||||
rawPayload: AppEventPayload; // 原始事件负载,可能需要传递给发送器
|
||||
}
|
||||
|
||||
|
||||
class NotificationProcessorService extends EventEmitter {
|
||||
private repository: NotificationSettingsRepository;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.repository = new NotificationSettingsRepository();
|
||||
this.initialize();
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
console.log('[NotificationProcessor] 等待 i18n 初始化...');
|
||||
await i18nInitializationPromise;
|
||||
console.log('[NotificationProcessor] i18n 初始化完成。正在注册事件监听器...');
|
||||
this.registerEventListeners();
|
||||
this.isInitialized = true;
|
||||
console.log('[NotificationProcessor] 初始化完成。');
|
||||
} catch (error) {
|
||||
console.error('[NotificationProcessor] 因 i18n 错误导致初始化失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private registerEventListeners() {
|
||||
if (this.isInitialized) {
|
||||
console.warn('[NotificationProcessor] 尝试多次注册监听器。');
|
||||
return;
|
||||
}
|
||||
// 监听所有 AppEventType 事件
|
||||
Object.values(AppEventType).forEach(eventType => {
|
||||
if (eventType !== AppEventType.TestNotification) {
|
||||
eventService.onEvent(eventType, (payload) => {
|
||||
// 使用 setImmediate 或 process.nextTick 避免阻塞事件循环
|
||||
setImmediate(() => {
|
||||
this.processStandardEvent(eventType, payload).catch(error => {
|
||||
console.error(`[NotificationProcessor] 处理事件 ${eventType} 时出错:`, error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
eventService.onEvent(AppEventType.TestNotification, (payload) => {
|
||||
setImmediate(() => {
|
||||
this.processTestEvent(payload).catch(error => {
|
||||
console.error(`[NotificationProcessor] 处理测试事件时出错:`, error);
|
||||
});
|
||||
});
|
||||
});
|
||||
console.log('[NotificationProcessor] 已注册监听器。');
|
||||
}
|
||||
|
||||
private async processStandardEvent(eventType: AppEventType, payload: AppEventPayload) {
|
||||
if (!this.isInitialized) {
|
||||
console.warn(`[NotificationProcessor] 在初始化完成前收到事件 ${eventType}。跳过处理。`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NotificationProcessor] 收到标准事件: ${eventType}`, payload);
|
||||
const eventKey = eventType as NotificationEvent; // 类型转换,假设 AppEventType 和 NotificationEvent 对应
|
||||
|
||||
try {
|
||||
const applicableSettings = await this.repository.getEnabledByEvent(eventKey);
|
||||
console.log(`[NotificationProcessor] 找到 ${applicableSettings.length} 个适用于事件 ${eventKey} 的设置`);
|
||||
|
||||
if (applicableSettings.length === 0) {
|
||||
return; // 没有配置需要处理
|
||||
}
|
||||
|
||||
// TODO: 获取用户语言偏好,目前硬编码为 'zh-CN'
|
||||
const userLang = 'zh-CN'; // 后续应从用户设置或请求中获取
|
||||
|
||||
// 1. 翻译事件名称
|
||||
const translatedEvent = i18next.t(`event.${eventKey}`, { lng: userLang, defaultValue: eventKey });
|
||||
|
||||
|
||||
for (const setting of applicableSettings) {
|
||||
this.processSingleSetting(setting, eventType, payload, translatedEvent, userLang);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[NotificationProcessor] 获取事件 ${eventKey} 的设置失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async processTestEvent(payload: AppEventPayload) {
|
||||
if (!this.isInitialized) {
|
||||
console.warn(`[NotificationProcessor] 在初始化完成前收到测试事件。跳过处理。`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NotificationProcessor] 收到测试事件`, payload);
|
||||
const { testTargetConfig, testTargetChannelType } = payload.details || {};
|
||||
|
||||
if (!testTargetConfig || !testTargetChannelType) {
|
||||
console.error('[NotificationProcessor] 测试事件负载缺少 testTargetConfig 或 testTargetChannelType。');
|
||||
return;
|
||||
}
|
||||
|
||||
const mockSetting: NotificationSetting = {
|
||||
id: -1,
|
||||
name: 'Test Setting',
|
||||
enabled: true,
|
||||
channel_type: testTargetChannelType,
|
||||
config: testTargetConfig,
|
||||
enabled_events: [AppEventType.TestNotification as NotificationEvent],
|
||||
};
|
||||
|
||||
const userLang = 'zh-CN'; // TODO: Get user language preference
|
||||
const translatedEvent = i18next.t(`event.${AppEventType.TestNotification}`, { lng: userLang, defaultValue: AppEventType.TestNotification });
|
||||
|
||||
|
||||
this.processSingleSetting(mockSetting, AppEventType.TestNotification, payload, translatedEvent, userLang);
|
||||
}
|
||||
|
||||
private processSingleSetting(
|
||||
setting: NotificationSetting,
|
||||
eventType: AppEventType,
|
||||
payload: AppEventPayload,
|
||||
translatedEvent: string,
|
||||
userLang: string
|
||||
) {
|
||||
try {
|
||||
|
||||
const processedNotification = this.prepareNotificationContent(
|
||||
setting,
|
||||
eventType,
|
||||
payload,
|
||||
translatedEvent,
|
||||
userLang
|
||||
);
|
||||
|
||||
if (processedNotification) {
|
||||
this.emit('sendNotification', processedNotification);
|
||||
console.log(`[NotificationProcessor] 正在为 ${setting.channel_type} 发送 sendNotification (设置 ID: ${setting.id}, 事件: ${eventType})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[NotificationProcessor] 为设置 ID ${setting.id} 和事件 ${eventType} 准备通知时出错:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private prepareNotificationContent(
|
||||
setting: NotificationSetting,
|
||||
eventType: AppEventType,
|
||||
payload: AppEventPayload,
|
||||
translatedEvent: string, // The already translated event name (e.g., "登录成功")
|
||||
lang: string
|
||||
): ProcessedNotification | null {
|
||||
|
||||
const baseInterpolationData = {
|
||||
event: translatedEvent,
|
||||
rawEvent: eventType,
|
||||
timestamp: payload.timestamp.toISOString(),
|
||||
details: typeof payload.details === 'object' ? JSON.stringify(payload.details, null, 2) : (payload.details || ''),
|
||||
userId: payload.userId || 'N/A',
|
||||
...(typeof payload.details === 'object' ? payload.details : {}),
|
||||
settingId: payload.details?.settingId,
|
||||
settingName: payload.details?.name,
|
||||
settingType: payload.details?.type,
|
||||
};
|
||||
|
||||
|
||||
let subject: string | undefined = undefined;
|
||||
let body: string = '';
|
||||
|
||||
const genericSubject = `通知: {event}`;
|
||||
const genericEmailBody = `<p>事件: {event}</p><p>时间: {timestamp}</p><p>用户ID: {userId}</p><p>详情:</p><pre>{details}</pre>`;
|
||||
const genericWebhookBody = JSON.stringify({ event: '{event}', timestamp: '{timestamp}', userId: '{userId}', details: '{details}' });
|
||||
const genericTelegramBody = `*{event}*\n时间: {timestamp}\n用户ID: {userId}\n详情:\n\`\`\`\n{details}\n\`\`\``;
|
||||
|
||||
|
||||
switch (setting.channel_type) {
|
||||
case 'email':
|
||||
const emailConfig = setting.config as EmailConfig;
|
||||
subject = translatedEvent;
|
||||
|
||||
const bodyTemplate = emailConfig?.bodyTemplate || genericEmailBody;
|
||||
body = this.interpolate(bodyTemplate, baseInterpolationData);
|
||||
break;
|
||||
|
||||
case 'webhook':
|
||||
const webhookConfig = setting.config as WebhookConfig;
|
||||
const webhookTemplate = webhookConfig.bodyTemplate || genericWebhookBody;
|
||||
body = this.interpolate(webhookTemplate, baseInterpolationData);
|
||||
break;
|
||||
|
||||
case 'telegram':
|
||||
const telegramConfig = setting.config as TelegramConfig;
|
||||
const telegramTemplate = telegramConfig.messageTemplate || genericTelegramBody;
|
||||
body = this.interpolate(telegramTemplate, baseInterpolationData);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[NotificationProcessor] 不支持的通道类型: ${setting.channel_type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channelType: setting.channel_type,
|
||||
config: setting.config,
|
||||
subject: subject,
|
||||
body: body,
|
||||
rawPayload: payload
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的字符串模板插值替换
|
||||
* @param template 模板字符串,例如 "Hello {name}"
|
||||
* @param data 数据对象,例如 { name: "World" }
|
||||
* @returns 替换后的字符串
|
||||
*/
|
||||
private interpolate(template: string, data: Record<string, any>): string {
|
||||
if (!template) return '';
|
||||
// 使用正则表达式全局替换 {key} 格式的占位符
|
||||
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
// 如果 data 中存在对应的 key,则返回值,否则返回原始匹配(例如 "{unknownKey}")
|
||||
return data.hasOwnProperty(key) && data[key] !== null && data[key] !== undefined ? String(data[key]) : match;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例并导出
|
||||
const notificationProcessorService = new NotificationProcessorService();
|
||||
|
||||
export default notificationProcessorService;
|
||||
@@ -1,908 +0,0 @@
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { NotificationSettingsRepository } from "../notifications/notification.repository";
|
||||
import {
|
||||
NotificationSetting,
|
||||
NotificationEvent,
|
||||
NotificationPayload,
|
||||
WebhookConfig,
|
||||
EmailConfig,
|
||||
TelegramConfig,
|
||||
NotificationChannelConfig,
|
||||
NotificationChannelType,
|
||||
} from "../types/notification.types";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import Mail from "nodemailer/lib/mailer";
|
||||
import i18next, { defaultLng, supportedLngs } from "../i18n";
|
||||
import { settingsService } from "./settings.service";
|
||||
import { formatInTimeZone } from "date-fns-tz";
|
||||
|
||||
const testSubjectKey = "testNotification.subject";
|
||||
const testEmailBodyKey = "testNotification.email.body";
|
||||
const testEmailBodyHtmlKey = "testNotification.email.bodyHtml";
|
||||
const testWebhookDetailsKey = "testNotification.webhook.detailsMessage";
|
||||
const testTelegramDetailsKey = "testNotification.telegram.detailsMessage";
|
||||
const testTelegramBodyTemplateKey = "testNotification.telegram.bodyTemplate";
|
||||
|
||||
export class NotificationService {
|
||||
private repository: NotificationSettingsRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new NotificationSettingsRepository();
|
||||
}
|
||||
|
||||
async getAllSettings(): Promise<NotificationSetting[]> {
|
||||
return this.repository.getAll();
|
||||
}
|
||||
|
||||
async getSettingById(id: number): Promise<NotificationSetting | null> {
|
||||
return this.repository.getById(id);
|
||||
}
|
||||
|
||||
async createSetting(
|
||||
settingData: Omit<NotificationSetting, "id" | "created_at" | "updated_at">
|
||||
): Promise<number> {
|
||||
return this.repository.create(settingData);
|
||||
}
|
||||
|
||||
async updateSetting(
|
||||
id: number,
|
||||
settingData: Partial<
|
||||
Omit<NotificationSetting, "id" | "created_at" | "updated_at">
|
||||
>
|
||||
): Promise<boolean> {
|
||||
return this.repository.update(id, settingData);
|
||||
}
|
||||
|
||||
async deleteSetting(id: number): Promise<boolean> {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
|
||||
async testSetting(
|
||||
channelType: NotificationChannelType,
|
||||
config: NotificationChannelConfig
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
switch (channelType) {
|
||||
case "email":
|
||||
return this._testEmailSetting(config as EmailConfig);
|
||||
case "webhook":
|
||||
return this._testWebhookSetting(config as WebhookConfig);
|
||||
case "telegram":
|
||||
return this._testTelegramSetting(config as TelegramConfig);
|
||||
default:
|
||||
console.warn(`[通知测试] 不支持的测试渠道类型: ${channelType}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `不支持测试此渠道类型 (${channelType})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async _testEmailSetting(
|
||||
config: EmailConfig
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
console.log("[通知测试 - 邮件] 开始测试...");
|
||||
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
|
||||
console.error("[通知测试 - 邮件] 缺少必要的配置。");
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 主机, 端口, 发件人)。",
|
||||
};
|
||||
}
|
||||
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
const langSetting = await settingsService.getSetting("language");
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
console.log(`[通知测试 - 邮件] 使用语言: ${userLang}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[通知测试 - 邮件] 获取语言设置时出错,使用默认 (${defaultLng}):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const transporterOptions = {
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure ?? true,
|
||||
auth:
|
||||
config.smtpUser || config.smtpPass
|
||||
? {
|
||||
user: config.smtpUser,
|
||||
pass: config.smtpPass,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
const eventDisplayName = i18next.t(`event.SETTINGS_UPDATED`, {
|
||||
lng: userLang,
|
||||
defaultValue: "SETTINGS_UPDATED",
|
||||
});
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
from: config.from,
|
||||
to: config.to,
|
||||
subject: i18next.t(testSubjectKey, {
|
||||
lng: userLang,
|
||||
defaultValue: "Nexus Terminal Test Notification ({event})",
|
||||
eventDisplay: eventDisplayName,
|
||||
}),
|
||||
text: i18next.t(testEmailBodyKey, {
|
||||
lng: userLang,
|
||||
timestamp: new Date().toISOString(),
|
||||
defaultValue: `This is a test email from Nexus Terminal for event '{{event}}'.\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 '{{event}}'.</p><p>If you received this, your SMTP configuration is working.</p><p>Timestamp: {{timestamp}}</p>`,
|
||||
eventDisplay: eventDisplayName,
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[通知测试 - 邮件] 尝试通过 ${config.smtpHost}:${config.smtpPort} 发送测试邮件至 ${config.to}`
|
||||
);
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log(`[通知测试 - 邮件] 测试邮件发送成功: ${info.messageId}`);
|
||||
return { success: true, message: "测试邮件发送成功!请检查收件箱。" };
|
||||
} catch (error: any) {
|
||||
console.error(`[通知测试 - 邮件] 发送测试邮件时出错:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: `测试邮件发送失败: ${error.message || "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async _testWebhookSetting(
|
||||
config: WebhookConfig
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
console.log("[通知测试 - Webhook] 开始测试...");
|
||||
if (!config.url) {
|
||||
console.error("[通知测试 - Webhook] 缺少 URL。");
|
||||
return { success: false, message: "测试 Webhook 失败:缺少 URL。" };
|
||||
}
|
||||
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
const langSetting = await settingsService.getSetting("language");
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
console.log(`[通知测试 - Webhook] 使用语言: ${userLang}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[通知测试 - Webhook] 获取语言设置时出错,使用默认 (${defaultLng}):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const testPayload: NotificationPayload = {
|
||||
event: "SETTINGS_UPDATED",
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
message: i18next.t(testWebhookDetailsKey, {
|
||||
lng: userLang,
|
||||
defaultValue:
|
||||
"This is a test notification from Nexus Terminal (Webhook).",
|
||||
}),
|
||||
},
|
||||
};
|
||||
const translatedWebhookMessage =
|
||||
typeof testPayload.details === "object" && testPayload.details?.message
|
||||
? testPayload.details.message
|
||||
: "Details 不是带有 message 属性的对象";
|
||||
console.log(
|
||||
`[通知测试 - Webhook] 测试负载已创建。翻译后的 details.message:`,
|
||||
translatedWebhookMessage
|
||||
);
|
||||
|
||||
const eventDisplayName = i18next.t(`event.${testPayload.event}`, {
|
||||
lng: userLang,
|
||||
defaultValue: testPayload.event,
|
||||
});
|
||||
const defaultBody = JSON.stringify(testPayload, null, 2);
|
||||
const defaultBodyTemplate = `Default: JSON payload. Use {event}, {timestamp}, {details}.`;
|
||||
|
||||
const templateDataWebhookTest: Record<string, string> = {
|
||||
event: testPayload.event,
|
||||
eventDisplay: eventDisplayName,
|
||||
timestamp: new Date(testPayload.timestamp).toISOString(),
|
||||
|
||||
details:
|
||||
typeof testPayload.details === "object" && testPayload.details?.message
|
||||
? testPayload.details.message
|
||||
: typeof testPayload.details === "string"
|
||||
? testPayload.details
|
||||
: JSON.stringify(testPayload.details || {}, null, 2),
|
||||
};
|
||||
const requestBody = this._renderTemplate(
|
||||
config.bodyTemplate || defaultBodyTemplate,
|
||||
templateDataWebhookTest,
|
||||
defaultBody
|
||||
);
|
||||
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
method: config.method || "POST",
|
||||
url: config.url,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(config.headers || {}),
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`[通知测试 - Webhook] 发送测试 Webhook 到 ${config.url}`);
|
||||
const response = await axios(requestConfig);
|
||||
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(
|
||||
`[通知测试 - Webhook] 发送测试 Webhook 到 ${config.url} 时出错:`,
|
||||
errorMessage
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `测试 Webhook 发送失败: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async _testTelegramSetting(
|
||||
config: TelegramConfig
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
console.log("[通知测试 - Telegram] 开始测试...");
|
||||
if (!config.botToken || !config.chatId) {
|
||||
console.error("[通知测试 - Telegram] 缺少 botToken 或 chatId。");
|
||||
return {
|
||||
success: false,
|
||||
message: "测试 Telegram 失败:缺少机器人 Token 或聊天 ID。",
|
||||
};
|
||||
}
|
||||
|
||||
let userLang = defaultLng;
|
||||
try {
|
||||
const langSetting = await settingsService.getSetting("language");
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
console.log(`[通知测试 - Telegram] 使用语言: ${userLang}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[通知测试 - Telegram] 获取语言设置时出错,使用默认 (${defaultLng}):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const testPayload: NotificationPayload = {
|
||||
event: "SETTINGS_UPDATED",
|
||||
timestamp: Date.now(),
|
||||
details: undefined,
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
testPayload.details = { message: translatedDetailsMessage };
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
const templateKeyWithNamespace = `notifications:${testTelegramBodyTemplateKey}`;
|
||||
const defaultMessageTemplateFromI18n = i18next.t(templateKeyWithNamespace, {
|
||||
lng: userLang,
|
||||
defaultValue: `Fallback Template: *Nexus Terminal Test Notification*\nEvent: \`{event}\`\nTimestamp: {timestamp}\nDetails:\n\`\`\`\n{details}\n\`\`\``,
|
||||
});
|
||||
console.log(
|
||||
`[通知测试 - Telegram] 来自 i18n 的默认模板 (使用语言 '${userLang}', 键 '${templateKeyWithNamespace}'):`,
|
||||
defaultMessageTemplateFromI18n
|
||||
);
|
||||
|
||||
const templateToUse =
|
||||
config.messageTemplate || defaultMessageTemplateFromI18n;
|
||||
console.log(`[通知测试 - Telegram] 要渲染的模板:`, templateToUse);
|
||||
|
||||
const eventDisplayName = i18next.t(`event.${testPayload.event}`, {
|
||||
lng: userLang,
|
||||
defaultValue: testPayload.event,
|
||||
});
|
||||
|
||||
const templateDataTelegramTest: Record<string, string> = {
|
||||
event: this._escapeBasicMarkdown(testPayload.event),
|
||||
eventDisplay: this._escapeBasicMarkdown(eventDisplayName),
|
||||
timestamp: new Date(testPayload.timestamp).toISOString(),
|
||||
|
||||
details: this._escapeBasicMarkdown(messageFromPayload),
|
||||
};
|
||||
|
||||
const messageText = this._renderTemplate(
|
||||
templateToUse,
|
||||
templateDataTelegramTest,
|
||||
defaultMessageTemplateFromI18n
|
||||
);
|
||||
console.log(`[通知测试 - Telegram] 渲染的消息文本:`, messageText);
|
||||
|
||||
let baseApiUrl = "https://api.telegram.org";
|
||||
if (config.customDomain) {
|
||||
try {
|
||||
const url = new URL(config.customDomain);
|
||||
baseApiUrl = `${url.protocol}//${url.host}`;
|
||||
console.log(`[通知测试 - Telegram] 使用自定义域名: ${baseApiUrl}`);
|
||||
} catch (e) {
|
||||
console.warn(`[通知测试 - Telegram] 无效的自定义域名 URL: ${config.customDomain}。将回退到默认 Telegram API。`);
|
||||
}
|
||||
}
|
||||
const telegramApiUrl = `${baseApiUrl}/bot${config.botToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[通知测试 - Telegram] 发送测试 Telegram 消息到聊天 ID ${config.chatId}`
|
||||
);
|
||||
const response = await axios.post(
|
||||
telegramApiUrl,
|
||||
{
|
||||
chat_id: config.chatId,
|
||||
text: messageText,
|
||||
parse_mode: "Markdown",
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
|
||||
if (response.data?.ok) {
|
||||
console.log(`[通知测试 - Telegram] 测试 Telegram 消息发送成功。`);
|
||||
return { success: true, message: "测试 Telegram 消息发送成功!" };
|
||||
} else {
|
||||
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(
|
||||
`[通知测试 - Telegram] 发送测试 Telegram 消息时出错:`,
|
||||
errorMessage
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `测试 Telegram 发送失败: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendNotification(
|
||||
event: NotificationEvent,
|
||||
details?: Record<string, any> | string
|
||||
): Promise<void> {
|
||||
// console.log(`[通知] 事件触发: ${event}`, details || "");
|
||||
|
||||
let userLang = defaultLng;
|
||||
let userTimezone = "UTC";
|
||||
try {
|
||||
const [langSetting, timezoneSetting] = await Promise.all([
|
||||
settingsService.getSetting("language"),
|
||||
settingsService.getSetting("timezone"),
|
||||
]);
|
||||
if (langSetting && supportedLngs.includes(langSetting)) {
|
||||
userLang = langSetting;
|
||||
}
|
||||
|
||||
if (timezoneSetting) {
|
||||
userTimezone = timezoneSetting;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[通知] 获取事件 ${event} 的语言或时区设置时出错:`, error);
|
||||
}
|
||||
console.log(
|
||||
`[通知] 事件 ${event} 使用语言 '${userLang}', 时区 '${userTimezone}'`
|
||||
);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
event,
|
||||
timestamp: Date.now(),
|
||||
details: details || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const applicableSettings = await this.repository.getEnabledByEvent(event);
|
||||
console.log(
|
||||
`[通知] 找到 ${applicableSettings.length} 个适用于事件 ${event} 的设置`
|
||||
);
|
||||
|
||||
if (applicableSettings.length === 0) {
|
||||
return; // 此事件没有启用的设置
|
||||
}
|
||||
|
||||
const sendPromises = applicableSettings.map((setting) => {
|
||||
switch (setting.channel_type) {
|
||||
case "webhook":
|
||||
return this._sendWebhook(setting, payload, userLang, userTimezone);
|
||||
case "email":
|
||||
return this._sendEmail(setting, payload, userLang, userTimezone);
|
||||
case "telegram":
|
||||
return this._sendTelegram(setting, payload, userLang, userTimezone);
|
||||
default:
|
||||
console.warn(
|
||||
`[通知] 未知渠道类型: ${setting.channel_type} (设置 ID: ${setting.id})`
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(sendPromises);
|
||||
console.log(`[通知] 完成尝试发送事件 ${event} 的通知`);
|
||||
} catch (error) {
|
||||
console.error(`[通知] 获取或处理事件 ${event} 的设置时出错:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private _escapeBasicMarkdown(text: string): string {
|
||||
if (typeof text !== "string") return "";
|
||||
|
||||
return text.replace(/([*_`\[])/g, "\\$1");
|
||||
}
|
||||
|
||||
private _renderTemplate(
|
||||
template: string | undefined,
|
||||
data: Record<string, string>,
|
||||
defaultText: string
|
||||
): string {
|
||||
if (!template) return defaultText;
|
||||
let rendered = template;
|
||||
for (const key in data) {
|
||||
rendered = rendered.replace(new RegExp(`\\{${key}\\}`, "g"), data[key]);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private async _sendWebhook(
|
||||
setting: NotificationSetting,
|
||||
payload: NotificationPayload,
|
||||
userLang: string,
|
||||
userTimezone: string
|
||||
): Promise<void> {
|
||||
const config = setting.config as WebhookConfig;
|
||||
if (!config.url) {
|
||||
console.error(`[通知] Webhook 设置 ID ${setting.id} 缺少 URL。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDisplayName = i18next.t(`event.${payload.event}`, {
|
||||
lng: userLang,
|
||||
defaultValue: payload.event,
|
||||
});
|
||||
|
||||
const translatedDetails = this._translatePayloadDetails(
|
||||
payload.details,
|
||||
userLang
|
||||
);
|
||||
const translatedPayload = { ...payload, details: translatedDetails };
|
||||
|
||||
const defaultBody = JSON.stringify(translatedPayload, null, 2);
|
||||
const defaultBodyTemplate = `Default: JSON payload. Use {event}, {timestamp}, {details}.`;
|
||||
|
||||
const templateDataWebhook: Record<string, string> = {
|
||||
event: translatedPayload.event,
|
||||
eventDisplay: eventDisplayName,
|
||||
|
||||
timestamp: formatInTimeZone(
|
||||
new Date(translatedPayload.timestamp),
|
||||
userTimezone,
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
|
||||
),
|
||||
|
||||
details:
|
||||
typeof translatedPayload.details === "object" &&
|
||||
translatedPayload.details?.message
|
||||
? translatedPayload.details.message
|
||||
: typeof translatedPayload.details === "string"
|
||||
? translatedPayload.details
|
||||
: JSON.stringify(translatedPayload.details || {}, null, 2),
|
||||
};
|
||||
let templateToRender = config.bodyTemplate || defaultBodyTemplate;
|
||||
let isCustomTemplate = !!config.bodyTemplate;
|
||||
|
||||
if (isCustomTemplate) {
|
||||
console.log(
|
||||
`[_sendWebhook] Original custom body template for event ${payload.event}:`,
|
||||
templateToRender
|
||||
);
|
||||
|
||||
templateToRender = templateToRender.replace(
|
||||
/\{event\}/g,
|
||||
"{eventDisplay}"
|
||||
);
|
||||
console.log(
|
||||
`[_sendWebhook] Pre-processed body template (replaced {event} with {eventDisplay}):`,
|
||||
templateToRender
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[_sendWebhook] No custom body template found. Using default template for event ${payload.event}`
|
||||
);
|
||||
}
|
||||
|
||||
const requestBody = this._renderTemplate(
|
||||
templateToRender,
|
||||
templateDataWebhook,
|
||||
defaultBody
|
||||
);
|
||||
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
method: config.method || "POST",
|
||||
url: config.url,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(config.headers || {}),
|
||||
},
|
||||
data: requestBody,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[通知] 发送 Webhook 到 ${config.url} (事件: ${payload.event})`
|
||||
);
|
||||
const response = await axios(requestConfig);
|
||||
console.log(
|
||||
`[通知] Webhook 成功发送到 ${config.url}。状态: ${response.status}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message || error.response?.data || error.message;
|
||||
console.error(
|
||||
`[通知] 发送 Webhook 到 ${config.url} (设置 ID: ${setting.id}) 时出错:`,
|
||||
errorMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _sendEmail(
|
||||
setting: NotificationSetting,
|
||||
payload: NotificationPayload,
|
||||
userLang: string,
|
||||
userTimezone: string
|
||||
): Promise<void> {
|
||||
const config = setting.config as EmailConfig;
|
||||
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
|
||||
console.error(
|
||||
`[通知] 邮件设置 ID ${setting.id} 缺少必要的 SMTP 配置 (to, smtpHost, smtpPort, from)。`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const transporterOptions = {
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure ?? true,
|
||||
auth:
|
||||
config.smtpUser || config.smtpPass
|
||||
? {
|
||||
user: config.smtpUser,
|
||||
pass: config.smtpPass,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
const i18nOptions: Record<string, any> = { lng: userLang };
|
||||
if (payload.details && typeof payload.details === "object") {
|
||||
Object.assign(i18nOptions, payload.details);
|
||||
} else if (payload.details !== undefined) {
|
||||
i18nOptions.details = payload.details;
|
||||
}
|
||||
|
||||
const eventDisplayName = i18next.t(`event.${payload.event}`, {
|
||||
lng: userLang,
|
||||
defaultValue: payload.event,
|
||||
});
|
||||
|
||||
const subject = eventDisplayName;
|
||||
console.log(
|
||||
`[_sendEmail] Using fixed subject for event ${payload.event}: ${subject}`
|
||||
);
|
||||
|
||||
const formattedTimestampForEmail = formatInTimeZone(
|
||||
new Date(payload.timestamp),
|
||||
userTimezone,
|
||||
"yyyy-MM-dd HH:mm:ss zzz"
|
||||
);
|
||||
const detailsString =
|
||||
typeof payload.details === "string"
|
||||
? payload.details
|
||||
: JSON.stringify(payload.details || {}, null, 2);
|
||||
|
||||
const templateDataEmailBody: Record<string, string> = {
|
||||
event: payload.event,
|
||||
eventDisplay: eventDisplayName,
|
||||
timestamp: formattedTimestampForEmail,
|
||||
details: detailsString,
|
||||
|
||||
...Object.entries(i18nOptions).reduce((acc, [key, value]) => {
|
||||
if (key !== "lng" && typeof value !== "object") {
|
||||
acc[key] = String(value);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
};
|
||||
console.log(
|
||||
`[_sendEmail] Prepared templateDataEmailBody for event ${payload.event}:`,
|
||||
templateDataEmailBody
|
||||
);
|
||||
|
||||
let body = "";
|
||||
const defaultBodyText = `Event: ${eventDisplayName}\nTimestamp: ${formattedTimestampForEmail}\nDetails:\n${detailsString}`;
|
||||
|
||||
if (config.bodyTemplate) {
|
||||
let templateToRender = config.bodyTemplate;
|
||||
console.log(
|
||||
`[_sendEmail] Original custom body template for event ${payload.event}:`,
|
||||
templateToRender
|
||||
);
|
||||
|
||||
templateToRender = templateToRender.replace(
|
||||
/\{event\}/g,
|
||||
"{eventDisplay}"
|
||||
);
|
||||
console.log(
|
||||
`[_sendEmail] Pre-processed body template (replaced {event} with {eventDisplay}):`,
|
||||
templateToRender
|
||||
);
|
||||
|
||||
body = this._renderTemplate(
|
||||
templateToRender,
|
||||
templateDataEmailBody,
|
||||
defaultBodyText
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[_sendEmail] No custom body template found. Using default constructed body text for event ${payload.event}`
|
||||
);
|
||||
body = defaultBodyText;
|
||||
}
|
||||
console.log(
|
||||
`[_sendEmail] Final email body for event ${payload.event}:\n${body}`
|
||||
);
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
from: config.from,
|
||||
to: config.to,
|
||||
subject: subject,
|
||||
text: body,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[通知] 通过 ${config.smtpHost}:${config.smtpPort} 发送邮件至 ${config.to} (事件: ${payload.event}, 主题: ${subject})`
|
||||
);
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log(
|
||||
`[通知] 邮件成功发送至 ${config.to} (设置 ID: ${setting.id})。消息 ID: ${info.messageId}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[通知] 通过 ${config.smtpHost} 发送邮件 (设置 ID: ${setting.id}) 时出错:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _sendTelegram(
|
||||
setting: NotificationSetting,
|
||||
payload: NotificationPayload,
|
||||
userLang: string,
|
||||
userTimezone: string
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`[_sendTelegram] Initiating for event: ${payload.event}, Setting ID: ${setting.id}, Lang: ${userLang}, Timezone: ${userTimezone}`
|
||||
);
|
||||
console.log(
|
||||
`[_sendTelegram] Received payload:`,
|
||||
JSON.stringify(payload, null, 2)
|
||||
);
|
||||
const config = setting.config as TelegramConfig;
|
||||
if (!config.botToken || !config.chatId) {
|
||||
console.error(
|
||||
`[通知] Telegram 设置 ID ${setting.id} 缺少 botToken 或 chatId。`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let detailsText = "";
|
||||
if (payload.details) {
|
||||
if (
|
||||
payload.event === "SETTINGS_UPDATED" &&
|
||||
typeof payload.details === "object" &&
|
||||
Array.isArray(payload.details.updatedKeys)
|
||||
) {
|
||||
detailsText = payload.details.updatedKeys.join(", ");
|
||||
} else if (typeof payload.details === "string") {
|
||||
detailsText = payload.details;
|
||||
} else {
|
||||
detailsText = JSON.stringify(payload.details);
|
||||
}
|
||||
}
|
||||
console.log(`[_sendTelegram] Formatted detailsText:`, detailsText);
|
||||
|
||||
const translatedEventName = i18next.t(`event.${payload.event}`, {
|
||||
lng: userLang,
|
||||
defaultValue: payload.event,
|
||||
});
|
||||
|
||||
const templateData: Record<string, string> = {
|
||||
event: translatedEventName,
|
||||
timestamp: formatInTimeZone(
|
||||
new Date(payload.timestamp),
|
||||
userTimezone,
|
||||
"yyyy-MM-dd HH:mm:ss zzz"
|
||||
),
|
||||
details: detailsText,
|
||||
};
|
||||
console.log(
|
||||
`[_sendTelegram] Prepared templateData (NO escaping):`,
|
||||
JSON.stringify(templateData, null, 2)
|
||||
);
|
||||
|
||||
let messageText = "";
|
||||
if (config.messageTemplate) {
|
||||
console.log(
|
||||
`[_sendTelegram] Using custom template:`,
|
||||
config.messageTemplate
|
||||
);
|
||||
const fallbackForCustom = `Event: ${templateData.event}, Details: ${templateData.details}`;
|
||||
messageText = this._renderTemplate(
|
||||
config.messageTemplate,
|
||||
templateData,
|
||||
fallbackForCustom
|
||||
);
|
||||
} else {
|
||||
const i18nKey = `eventBody.${payload.event}`;
|
||||
console.log(`[_sendTelegram] Using i18n template key:`, i18nKey);
|
||||
const fallbackBody = `*Fallback Notification*\nEvent: ${templateData.event}\nTime: \`${templateData.timestamp}\`\nDetails: ${templateData.details}`;
|
||||
messageText = i18next.t(i18nKey, {
|
||||
lng: userLang,
|
||||
...templateData,
|
||||
defaultValue: fallbackBody,
|
||||
});
|
||||
}
|
||||
console.log(`[_sendTelegram] Final message text to send:`, messageText);
|
||||
|
||||
let baseApiUrlSend = "https://api.telegram.org";
|
||||
if (config.customDomain) {
|
||||
try {
|
||||
const url = new URL(config.customDomain);
|
||||
baseApiUrlSend = `${url.protocol}//${url.host}`;
|
||||
console.log(`[_sendTelegram] 使用自定义域名: ${baseApiUrlSend} (事件: ${payload.event})`);
|
||||
} catch (e) {
|
||||
console.warn(`[_sendTelegram] 无效的自定义域名 URL: ${config.customDomain} (事件: ${payload.event})。将回退到默认 Telegram API。`);
|
||||
}
|
||||
}
|
||||
const telegramApiUrl = `${baseApiUrlSend}/bot${config.botToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[通知] 发送 Telegram 消息到聊天 ID ${config.chatId} (事件: ${payload.event})`
|
||||
);
|
||||
const requestBody = {
|
||||
chat_id: config.chatId,
|
||||
text: messageText,
|
||||
parse_mode: "Markdown",
|
||||
};
|
||||
console.log(
|
||||
`[_sendTelegram] Sending request to Telegram API:`,
|
||||
JSON.stringify(requestBody, null, 2)
|
||||
);
|
||||
const response = await axios.post(telegramApiUrl, requestBody, {
|
||||
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(
|
||||
`[通知] 发送 Telegram 消息 (设置 ID: ${setting.id}) 时出错:`,
|
||||
errorMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _translatePayloadDetails(details: any, lng: string): any {
|
||||
if (!details || typeof details !== "object") {
|
||||
return details;
|
||||
}
|
||||
|
||||
if (details.testResult === "success" && details.connectionName) {
|
||||
return {
|
||||
...details,
|
||||
message: i18next.t("connection.testSuccess", {
|
||||
lng,
|
||||
name: details.connectionName,
|
||||
defaultValue: `Connection test successful for '${details.connectionName}'!`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
details.testResult === "failed" &&
|
||||
details.connectionName &&
|
||||
details.error
|
||||
) {
|
||||
return {
|
||||
...details,
|
||||
message: i18next.t("connection.testFailed", {
|
||||
lng,
|
||||
name: details.connectionName,
|
||||
error: details.error,
|
||||
defaultValue: `Connection test failed for '${details.connectionName}': ${details.error}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...details,
|
||||
message: i18next.t("settings.updated", {
|
||||
lng,
|
||||
defaultValue: "Settings updated successfully.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import {
|
||||
generateRegistrationOptions as generateRegOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions as generateAuthOptions,
|
||||
verifyAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifiedAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
AuthenticatorTransportFuture,
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
// The actual type for verification.registrationInfo is RegistrationInfo within @simplewebauthn/server
|
||||
// and for verification.authenticationInfo is AuthenticationInfo.
|
||||
// We will rely on TypeScript's inference from the VerifiedRegistrationResponse/VerifiedAuthenticationResponse types.
|
||||
} from '@simplewebauthn/server';
|
||||
import { passkeyRepository, Passkey, NewPasskey } from '../passkey/passkey.repository';
|
||||
import { userRepository, User } from '../user/user.repository';
|
||||
import { config } from '../config/app.config';
|
||||
|
||||
const RP_ID = config.rpId;
|
||||
const RP_ORIGIN = config.rpOrigin;
|
||||
const RP_NAME = config.appName;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function base64UrlToUint8Array(base64urlString: string): Uint8Array {
|
||||
const base64 = base64urlString.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Buffer.from will handle padding correctly for base64
|
||||
try {
|
||||
return Buffer.from(base64, 'base64');
|
||||
} catch (e) {
|
||||
console.error("Failed to decode base64url string to Buffer:", base64urlString, e);
|
||||
throw new Error("Invalid base64url string for Buffer conversion");
|
||||
}
|
||||
}
|
||||
|
||||
export class PasskeyService {
|
||||
constructor(
|
||||
private passkeyRepo: typeof passkeyRepository,
|
||||
private userRepo: typeof userRepository
|
||||
) {}
|
||||
|
||||
async generateRegistrationOptions(username: string, userId: number) {
|
||||
const user = await this.userRepo.findUserById(userId);
|
||||
if (!user || user.username !== username) {
|
||||
throw new Error('User not found or username mismatch');
|
||||
}
|
||||
|
||||
const existingPasskeys = await this.passkeyRepo.getPasskeysByUserId(userId);
|
||||
|
||||
const excludeCredentials: {id: string, type: 'public-key', transports?: AuthenticatorTransportFuture[]}[] = existingPasskeys.map(pk => ({
|
||||
id: pk.credential_id,
|
||||
type: 'public-key',
|
||||
transports: pk.transports ? JSON.parse(pk.transports) as AuthenticatorTransportFuture[] : undefined,
|
||||
}));
|
||||
|
||||
const options: GenerateRegistrationOptionsOpts = {
|
||||
rpName: RP_NAME,
|
||||
rpID: RP_ID,
|
||||
userID: textEncoder.encode(userId.toString()),
|
||||
userName: username,
|
||||
userDisplayName: username,
|
||||
timeout: 60000,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257],
|
||||
};
|
||||
|
||||
const generatedOptions = await generateRegOptions(options);
|
||||
return generatedOptions;
|
||||
}
|
||||
|
||||
async verifyRegistration(
|
||||
registrationResponseJSON: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
userHandleFromClient: string
|
||||
): Promise<VerifiedRegistrationResponse & { newPasskeyToSave?: NewPasskey }> {
|
||||
const userId = parseInt(userHandleFromClient, 10);
|
||||
if (isNaN(userId)) {
|
||||
throw new Error('Invalid user handle provided.');
|
||||
}
|
||||
const user = await this.userRepo.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found for the provided handle.');
|
||||
}
|
||||
|
||||
// The actual WebAuthn response is nested within the received object
|
||||
const actualRegistrationResponse = (registrationResponseJSON as any).registrationResponse;
|
||||
|
||||
// Add a check for the presence of credential ID before calling the library
|
||||
if (!actualRegistrationResponse || !actualRegistrationResponse.id) {
|
||||
console.error('Missing credential ID in actualRegistrationResponse from client:', registrationResponseJSON);
|
||||
throw new Error('Registration failed: Missing or malformed credential ID from client.');
|
||||
}
|
||||
|
||||
const verifyOpts: VerifyRegistrationResponseOpts = {
|
||||
response: actualRegistrationResponse, // Use the nested object
|
||||
expectedChallenge,
|
||||
expectedOrigin: RP_ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
requireUserVerification: true,
|
||||
};
|
||||
|
||||
const verification = await verifyRegistrationResponse(verifyOpts);
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const regInfo = verification.registrationInfo;
|
||||
|
||||
// Based on the logs, credentialPublicKey, credentialID, counter, and transports
|
||||
// are nested within regInfo.credential.
|
||||
// credentialBackedUp is at the top level of regInfo.
|
||||
const credentialDetails = (regInfo as any).credential;
|
||||
const credentialBackedUp = (regInfo as any).credentialBackedUp; // This seems to be at the top level
|
||||
|
||||
if (!credentialDetails || typeof credentialDetails.publicKey !== 'object' || typeof credentialDetails.id !== 'string' || typeof credentialDetails.counter !== 'number') {
|
||||
console.error('Verification successful, but registrationInfo.credential structure is unexpected or missing:', regInfo);
|
||||
throw new Error('Failed to process registration info due to unexpected credential structure.');
|
||||
}
|
||||
|
||||
const credentialPublicKey = credentialDetails.publicKey;
|
||||
const credentialID = credentialDetails.id;
|
||||
const counter = credentialDetails.counter;
|
||||
const transports = credentialDetails.transports; // This might be undefined, handle appropriately
|
||||
|
||||
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64');
|
||||
|
||||
const newPasskeyEntry: NewPasskey = {
|
||||
user_id: user.id,
|
||||
credential_id: credentialID,
|
||||
public_key: publicKeyBase64,
|
||||
counter: counter,
|
||||
transports: transports ? JSON.stringify(transports) : null,
|
||||
backed_up: !!credentialBackedUp,
|
||||
};
|
||||
return { ...verification, newPasskeyToSave: newPasskeyEntry };
|
||||
}
|
||||
return verification;
|
||||
}
|
||||
|
||||
async generateAuthenticationOptions(username?: string) {
|
||||
let allowCredentials: {id: string, type: 'public-key', transports?: AuthenticatorTransportFuture[]}[] | undefined = undefined;
|
||||
|
||||
if (username) {
|
||||
const user = await this.userRepo.findUserByUsername(username);
|
||||
if (user) {
|
||||
const userPasskeys = await this.passkeyRepo.getPasskeysByUserId(user.id);
|
||||
allowCredentials = userPasskeys.map(pk => ({
|
||||
id: pk.credential_id,
|
||||
type: 'public-key',
|
||||
transports: pk.transports ? JSON.parse(pk.transports) as AuthenticatorTransportFuture[] : undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const options: GenerateAuthenticationOptionsOpts = {
|
||||
rpID: RP_ID,
|
||||
timeout: 60000,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
};
|
||||
|
||||
const generatedOptions = await generateAuthOptions(options);
|
||||
return generatedOptions;
|
||||
}
|
||||
|
||||
async verifyAuthentication(
|
||||
authenticationResponseJSON: AuthenticationResponseJSON,
|
||||
expectedChallenge: string
|
||||
): Promise<VerifiedAuthenticationResponse & { passkey?: Passkey, userId?: number }> {
|
||||
|
||||
// Decode and check authenticatorData length
|
||||
if (authenticationResponseJSON.response && authenticationResponseJSON.response.authenticatorData) {
|
||||
try {
|
||||
const authenticatorDataBytes = base64UrlToUint8Array(authenticationResponseJSON.response.authenticatorData);
|
||||
if (authenticatorDataBytes.length < 37) {
|
||||
// console.warn(`[PasskeyService] WARNING: Decoded authenticatorData length (${authenticatorDataBytes.length} bytes) is less than the expected minimum of 37 bytes. This may lead to CBOR parsing errors and subsequent failures (e.g., 'cannot read counter').`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error decoding authenticatorData from client response:', e.message);
|
||||
// Potentially re-throw or handle as a critical error, as this is unexpected.
|
||||
}
|
||||
} else {
|
||||
console.warn('[PasskeyService] authenticatorData is missing in the client response.');
|
||||
}
|
||||
|
||||
const credentialIdFromResponse = authenticationResponseJSON.id;
|
||||
if (!credentialIdFromResponse) {
|
||||
console.error('[PasskeyService] Credential ID missing from authentication response.');
|
||||
throw new Error('Credential ID missing from authentication response.');
|
||||
}
|
||||
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialIdFromResponse);
|
||||
if (!passkey) {
|
||||
console.error('[PasskeyService] Passkey not found for credential ID:', credentialIdFromResponse);
|
||||
throw new Error('Authentication failed. Passkey not found.');
|
||||
}
|
||||
|
||||
let authenticatorCredentialID: Uint8Array;
|
||||
try {
|
||||
authenticatorCredentialID = base64UrlToUint8Array(passkey.credential_id);
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error decoding credential_id to Uint8Array:', passkey.credential_id, e.message);
|
||||
throw new Error('Failed to decode credential_id.');
|
||||
}
|
||||
|
||||
let authenticatorPublicKey: Uint8Array; // Changed type from Buffer to Uint8Array
|
||||
try {
|
||||
const pkBuffer = Buffer.from(passkey.public_key, 'base64');
|
||||
// Ensure it's a plain Uint8Array instance
|
||||
authenticatorPublicKey = new Uint8Array(pkBuffer.buffer, pkBuffer.byteOffset, pkBuffer.byteLength);
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error decoding public_key to Uint8Array:', passkey.public_key, e.message);
|
||||
throw new Error('Failed to decode public_key.');
|
||||
}
|
||||
|
||||
let authenticatorTransports: AuthenticatorTransportFuture[] | undefined;
|
||||
try {
|
||||
authenticatorTransports = passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransportFuture[] : undefined;
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error parsing transports JSON:', passkey.transports, e.message);
|
||||
authenticatorTransports = undefined;
|
||||
}
|
||||
|
||||
// This object structure should match what @simplewebauthn/server expects for its `credential` option parameter.
|
||||
// Specifically, it expects `id`, `publicKey`, and `counter`.
|
||||
const credentialObjectForLibrary = {
|
||||
id: authenticatorCredentialID, // Renamed from credentialID
|
||||
publicKey: authenticatorPublicKey, // Renamed from credentialPublicKey
|
||||
counter: passkey.counter,
|
||||
transports: authenticatorTransports,
|
||||
credentialBackedUp: !!passkey.backed_up,
|
||||
credentialDeviceType: (passkey.backed_up ? 'multiDevice' : 'singleDevice') as 'multiDevice' | 'singleDevice',
|
||||
};
|
||||
|
||||
// Reverting to 'any' for verifyOpts due to issues with the library's
|
||||
// type definitions for VerifyAuthenticationResponseOpts not recognizing 'authenticator' key.
|
||||
// This aligns with the original code's approach and TODO comment.
|
||||
const verifyOpts: any = {
|
||||
response: authenticationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: RP_ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
credential: credentialObjectForLibrary, // Renamed from authenticator to credential
|
||||
requireUserVerification: true,
|
||||
};
|
||||
|
||||
// Call without 'as VerifyAuthenticationResponseOpts' since verifyOpts is 'any'
|
||||
const verification = await verifyAuthenticationResponse(verifyOpts);
|
||||
|
||||
if (verification.verified && verification.authenticationInfo) {
|
||||
const authInfo = verification.authenticationInfo;
|
||||
await this.passkeyRepo.updatePasskeyCounter(passkey.credential_id, authInfo.newCounter);
|
||||
await this.passkeyRepo.updatePasskeyLastUsedAt(passkey.credential_id);
|
||||
return { ...verification, passkey, userId: passkey.user_id };
|
||||
}
|
||||
throw new Error('Authentication failed.');
|
||||
}
|
||||
|
||||
async listPasskeysByUserId(userId: number): Promise<Partial<Passkey>[]> {
|
||||
const passkeys = await this.passkeyRepo.getPasskeysByUserId(userId);
|
||||
// 只返回部分信息以避免泄露敏感数据
|
||||
return passkeys.map(pk => ({
|
||||
credential_id: pk.credential_id,
|
||||
created_at: pk.created_at,
|
||||
last_used_at: pk.last_used_at,
|
||||
transports: pk.transports ? JSON.parse(pk.transports) : undefined,
|
||||
name: pk.name, // <-- 添加 name 字段
|
||||
}));
|
||||
}
|
||||
|
||||
async deletePasskey(userId: number, credentialID: string): Promise<boolean> {
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialID);
|
||||
if (!passkey) {
|
||||
throw new Error('Passkey not found.');
|
||||
}
|
||||
if (passkey.user_id !== userId) {
|
||||
// 安全措施:用户只能删除自己的 Passkey
|
||||
throw new Error('Unauthorized to delete this passkey.');
|
||||
}
|
||||
const wasDeleted = await this.passkeyRepo.deletePasskey(credentialID);
|
||||
return wasDeleted;
|
||||
}
|
||||
|
||||
async updatePasskeyName(userId: number, credentialID: string, newName: string): Promise<void> {
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialID);
|
||||
if (!passkey) {
|
||||
throw new Error('Passkey not found.');
|
||||
}
|
||||
if (passkey.user_id !== userId) {
|
||||
// Security measure: User can only update their own passkey names
|
||||
throw new Error('Unauthorized to update this passkey name.');
|
||||
}
|
||||
await this.passkeyRepo.updatePasskeyName(credentialID, newName);
|
||||
}
|
||||
|
||||
async hasPasskeysConfigured(username?: string): Promise<boolean> {
|
||||
if (username) {
|
||||
const user = await this.userRepo.findUserByUsername(username);
|
||||
if (!user) {
|
||||
return false; // 如果提供了用户名但用户不存在,则认为没有配置 passkey
|
||||
}
|
||||
const passkeys = await this.passkeyRepo.getPasskeysByUserId(user.id);
|
||||
return passkeys.length > 0;
|
||||
} else {
|
||||
// 如果没有提供用户名,检查整个系统中是否存在任何 passkey
|
||||
// 这对于“可发现凭证”场景可能有用,或者简单地检查系统是否启用了 passkey 功能
|
||||
const anyPasskey = await this.passkeyRepo.getFirstPasskey();
|
||||
return !!anyPasskey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const passkeyService = new PasskeyService(passkeyRepository, userRepository);
|
||||
@@ -1,166 +0,0 @@
|
||||
import * as ProxyRepository from '../proxies/proxy.repository';
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
|
||||
export interface ProxyData extends ProxyRepository.ProxyData {}
|
||||
|
||||
export interface CreateProxyInput {
|
||||
name: string;
|
||||
type: 'SOCKS5' | 'HTTP';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | null;
|
||||
auth_method?: 'none' | 'password' | 'key';
|
||||
password?: string | null;
|
||||
private_key?: string | null;
|
||||
passphrase?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateProxyInput {
|
||||
name?: string;
|
||||
type?: 'SOCKS5' | 'HTTP';
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string | null;
|
||||
auth_method?: 'none' | 'password' | 'key';
|
||||
password?: string | null;
|
||||
private_key?: string | null;
|
||||
passphrase?: string | null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有代理
|
||||
*/
|
||||
export const getAllProxies = async (): Promise<ProxyData[]> => {
|
||||
return ProxyRepository.findAllProxies();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个代理
|
||||
*/
|
||||
export const getProxyById = async (id: number): Promise<ProxyData | null> => {
|
||||
return ProxyRepository.findProxyById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新代理
|
||||
*/
|
||||
export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> => {
|
||||
// 1. 验证输入
|
||||
if (!input.name || !input.type || !input.host || !input.port) {
|
||||
throw new Error('缺少必要的代理信息 (name, type, host, port)。');
|
||||
}
|
||||
if (input.auth_method === 'password' && !input.password) {
|
||||
throw new Error('代理密码认证方式需要提供 password。');
|
||||
}
|
||||
if (input.auth_method === 'key' && !input.private_key) {
|
||||
throw new Error('代理密钥认证方式需要提供 private_key。');
|
||||
}
|
||||
|
||||
// 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. 准备仓库数据
|
||||
const proxyData: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'> = {
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
username: input.username || null,
|
||||
auth_method: input.auth_method || 'none',
|
||||
encrypted_password: encryptedPassword,
|
||||
encrypted_private_key: encryptedPrivateKey,
|
||||
encrypted_passphrase: encryptedPassphrase,
|
||||
};
|
||||
|
||||
// 4. 创建代理记录
|
||||
const newProxyId = await ProxyRepository.createProxy(proxyData);
|
||||
|
||||
// 5. 获取并返回新创建的代理
|
||||
const newProxy = await getProxyById(newProxyId);
|
||||
if (!newProxy) {
|
||||
throw new Error('创建代理后无法检索到该代理。');
|
||||
}
|
||||
return newProxy;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新代理信息
|
||||
*/
|
||||
export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<ProxyData | null> => {
|
||||
// 1. 获取当前代理数据以进行比较(例如,用于认证方法更改逻辑)
|
||||
const currentProxy = await ProxyRepository.findProxyById(id);
|
||||
if (!currentProxy) {
|
||||
return null; // 未找到代理
|
||||
}
|
||||
|
||||
// 2. 准备更新数据
|
||||
const dataToUpdate: Partial<Omit<ProxyData, 'id' | 'created_at'>> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
const newAuthMethod = input.auth_method || currentProxy.auth_method;
|
||||
|
||||
// 更新标准字段
|
||||
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; // 允许清除
|
||||
|
||||
// 处理认证方法更改或凭证更新
|
||||
if (input.auth_method && input.auth_method !== currentProxy.auth_method) {
|
||||
dataToUpdate.auth_method = input.auth_method;
|
||||
needsCredentialUpdate = true;
|
||||
// 根据 *新* 认证方法加密新凭证
|
||||
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; // 清除旧密钥信息
|
||||
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; // 清除旧密码信息
|
||||
} else { // '无'
|
||||
dataToUpdate.encrypted_password = null;
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
}
|
||||
} else {
|
||||
// 认证方法未更改,如果为当前方法提供了凭证,则更新凭证
|
||||
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; // 一起更新密码短语
|
||||
needsCredentialUpdate = true;
|
||||
} else if (input.passphrase !== undefined) { // 仅更新密码短语
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果有更改,则更新代理记录
|
||||
const hasChanges = Object.keys(dataToUpdate).length > 0;
|
||||
if (hasChanges) {
|
||||
const updated = await ProxyRepository.updateProxy(id, dataToUpdate);
|
||||
if (!updated) {
|
||||
throw new Error('更新代理记录失败。');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 获取并返回更新后的代理
|
||||
return getProxyById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除代理
|
||||
*/
|
||||
export const deleteProxy = async (id: number): Promise<boolean> => {
|
||||
return ProxyRepository.deleteProxy(id);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository';
|
||||
import { QuickCommandTag } from '../quick-command-tags/quick-command-tag.repository';
|
||||
|
||||
/**
|
||||
* 获取所有快捷指令标签
|
||||
*/
|
||||
export const getAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => {
|
||||
return QuickCommandTagRepository.findAllQuickCommandTags();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个快捷指令标签
|
||||
*/
|
||||
export const getQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => {
|
||||
return QuickCommandTagRepository.findQuickCommandTagById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加新的快捷指令标签
|
||||
* @param name 标签名称
|
||||
* @returns 返回新标签的 ID
|
||||
*/
|
||||
export const addQuickCommandTag = async (name: string): Promise<number> => {
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error('标签名称不能为空');
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
// 可以在这里添加更多验证逻辑,例如检查名称格式等
|
||||
try {
|
||||
const newId = await QuickCommandTagRepository.createQuickCommandTag(trimmedName);
|
||||
return newId;
|
||||
} catch (error: any) {
|
||||
// Service 层可以重新抛出或处理 Repository 抛出的错误
|
||||
console.error(`[Service] 添加快捷指令标签 "${trimmedName}" 失败:`, error.message);
|
||||
throw error; // 重新抛出,让 Controller 处理 HTTP 响应
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新快捷指令标签
|
||||
* @param id 标签 ID
|
||||
* @param name 新的标签名称
|
||||
* @returns 返回是否成功更新
|
||||
*/
|
||||
export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => {
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error('标签名称不能为空');
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
// 可以在这里添加更多验证逻辑
|
||||
try {
|
||||
const success = await QuickCommandTagRepository.updateQuickCommandTag(id, trimmedName);
|
||||
if (!success) {
|
||||
// 可能需要检查标签是否存在,或者让 Repository 处理
|
||||
console.warn(`[Service] 尝试更新不存在的快捷指令标签 ID: ${id}`);
|
||||
}
|
||||
return success;
|
||||
} catch (error: any) {
|
||||
console.error(`[Service] 更新快捷指令标签 ${id} 失败:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除快捷指令标签
|
||||
* @param id 标签 ID
|
||||
* @returns 返回是否成功删除
|
||||
*/
|
||||
export const deleteQuickCommandTag = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
const success = await QuickCommandTagRepository.deleteQuickCommandTag(id);
|
||||
if (!success) {
|
||||
console.warn(`[Service] 尝试删除不存在的快捷指令标签 ID: ${id}`);
|
||||
}
|
||||
return success;
|
||||
} catch (error: any) {
|
||||
console.error(`[Service] 删除快捷指令标签 ${id} 失败:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置指定快捷指令的标签关联
|
||||
* @param commandId 快捷指令 ID
|
||||
* @param tagIds 新的快捷指令标签 ID 数组
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const setCommandTags = async (commandId: number, tagIds: number[]): Promise<void> => {
|
||||
// 验证 tagIds 是否为数字数组 (基本验证)
|
||||
if (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number')) {
|
||||
throw new Error('标签 ID 列表必须是一个数字数组');
|
||||
}
|
||||
// 可以在这里添加更复杂的验证,例如检查 tagIds 是否都存在于 quick_command_tags 表中
|
||||
// 但 Repository 中的 setCommandTagAssociations 已包含基本的检查和错误处理
|
||||
|
||||
try {
|
||||
// 直接调用 Repository 处理关联更新 (Repository 函数现在返回 void)
|
||||
await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
|
||||
// Service 函数也返回 void,所以不需要 return
|
||||
} catch (error: any) {
|
||||
console.error(`[Service] 设置快捷指令 ${commandId} 的标签失败:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定快捷指令的所有标签
|
||||
* @param commandId 快捷指令 ID
|
||||
* @returns 标签对象数组
|
||||
*/
|
||||
export const getTagsForCommand = async (commandId: number): Promise<QuickCommandTag[]> => {
|
||||
try {
|
||||
return await QuickCommandTagRepository.findTagsByCommandId(commandId);
|
||||
} catch (error: any) {
|
||||
console.error(`[Service] 获取快捷指令 ${commandId} 的标签失败:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import * as QuickCommandsRepository from '../quick-commands/quick-commands.repository';
|
||||
import { QuickCommandWithTags } from '../quick-commands/quick-commands.repository';
|
||||
import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository';
|
||||
|
||||
// 定义排序类型
|
||||
export type QuickCommandSortBy = 'name' | 'usage_count';
|
||||
|
||||
/**
|
||||
* 添加快捷指令
|
||||
* @param name - 指令名称 (可选)
|
||||
* @param command - 指令内容
|
||||
* @param tagIds - 关联的快捷指令标签 ID 数组 (可选)
|
||||
* @param variables - 变量对象 (可选)
|
||||
* @returns 返回添加记录的 ID
|
||||
*/
|
||||
export const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<number> => {
|
||||
if (!command || command.trim().length === 0) {
|
||||
throw new Error('指令内容不能为空');
|
||||
}
|
||||
// 如果 name 是空字符串,则视为 null
|
||||
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
||||
const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim(), variables);
|
||||
|
||||
// 添加成功后,设置标签关联
|
||||
if (commandId > 0 && tagIds && Array.isArray(tagIds)) {
|
||||
try {
|
||||
await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
|
||||
} catch (tagError: any) {
|
||||
// 如果标签关联失败,可以选择记录警告或回滚(但通常不回滚主记录)
|
||||
console.warn(`[Service] 添加快捷指令 ${commandId} 成功,但设置标签关联失败:`, tagError.message);
|
||||
// 可以考虑是否需要通知用户部分操作失败
|
||||
}
|
||||
}
|
||||
return commandId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新快捷指令
|
||||
* @param id - 要更新的记录 ID
|
||||
* @param name - 新的指令名称 (可选)
|
||||
* @param command - 新的指令内容
|
||||
* @param tagIds - 新的关联标签 ID 数组 (可选, undefined 表示不更新标签)
|
||||
* @param variables - 新的变量对象 (可选)
|
||||
* @returns 返回是否成功更新 (更新行数 > 0)
|
||||
*/
|
||||
export const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<boolean> => {
|
||||
if (!command || command.trim().length === 0) {
|
||||
throw new Error('指令内容不能为空');
|
||||
}
|
||||
const finalName = name && name.trim().length > 0 ? name.trim() : null;
|
||||
const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim(), variables);
|
||||
|
||||
// 如果指令更新成功,并且提供了 tagIds (即使是空数组也表示要更新),则更新标签关联
|
||||
if (commandUpdated && typeof tagIds !== 'undefined') {
|
||||
try {
|
||||
await QuickCommandTagRepository.setCommandTagAssociations(id, tagIds);
|
||||
} catch (tagError: any) {
|
||||
console.warn(`[Service] 更新快捷指令 ${id} 成功,但更新标签关联失败:`, tagError.message);
|
||||
// 即使标签更新失败,主记录已更新,通常返回 true
|
||||
}
|
||||
}
|
||||
// 返回主记录是否更新成功
|
||||
return commandUpdated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除快捷指令
|
||||
* @param id - 要删除的记录 ID
|
||||
* @returns 返回是否成功删除 (删除行数 > 0)
|
||||
*/
|
||||
export const deleteQuickCommand = async (id: number): Promise<boolean> => {
|
||||
const changes = await QuickCommandsRepository.deleteQuickCommand(id);
|
||||
return changes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有快捷指令,并按指定方式排序
|
||||
* @param sortBy - 排序字段 ('name' 或 'usage_count')
|
||||
* @returns 返回排序后的快捷指令数组 (包含 tagIds)
|
||||
*/
|
||||
export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise<QuickCommandWithTags[]> => {
|
||||
// Repository 已返回带 tagIds 的数据
|
||||
return QuickCommandsRepository.getAllQuickCommands(sortBy);
|
||||
};
|
||||
|
||||
/**
|
||||
* 增加快捷指令的使用次数
|
||||
* @param id - 记录 ID
|
||||
* @returns 返回是否成功更新 (更新行数 > 0)
|
||||
*/
|
||||
export const incrementUsageCount = async (id: number): Promise<boolean> => {
|
||||
const changes = await QuickCommandsRepository.incrementUsageCount(id);
|
||||
return changes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个快捷指令 (可能用于编辑)
|
||||
* @param id - 记录 ID
|
||||
* @returns 返回找到的快捷指令 (包含 tagIds),或 undefined
|
||||
*/
|
||||
export const getQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => {
|
||||
// Repository 已返回带 tagIds 的数据
|
||||
return QuickCommandsRepository.findQuickCommandById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将单个标签批量关联到多个快捷指令
|
||||
* @param commandIds - 需要添加标签的快捷指令 ID 数组
|
||||
* @param tagId - 要添加的标签 ID
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const assignTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => {
|
||||
try {
|
||||
// 基本验证
|
||||
if (!Array.isArray(commandIds) || commandIds.some(id => typeof id !== 'number' || isNaN(id))) {
|
||||
throw new Error('无效的指令 ID 列表');
|
||||
}
|
||||
if (typeof tagId !== 'number' || isNaN(tagId)) {
|
||||
throw new Error('无效的标签 ID');
|
||||
}
|
||||
|
||||
// 调用 Repository 函数执行批量关联
|
||||
// 注意:这里需要导入 QuickCommandTagRepository
|
||||
console.log(`[Service] assignTagToCommands: Calling repo with commandIds: ${JSON.stringify(commandIds)}, tagId: ${tagId}`);
|
||||
await QuickCommandTagRepository.addTagToCommands(commandIds, tagId);
|
||||
console.log(`[Service] assignTagToCommands: Repo call finished for tag ${tagId}.`); // +++ 修改日志 +++
|
||||
// 可以在这里添加额外的业务逻辑,例如发送事件通知等
|
||||
} catch (error: any) {
|
||||
console.error(`[Service] assignTagToCommands: 批量关联标签 ${tagId} 到指令时出错:`, error.message);
|
||||
// 向上抛出错误,让 Controller 处理 HTTP 响应
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import Mail from "nodemailer/lib/mailer";
|
||||
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { INotificationSender } from "../notification.dispatcher.service";
|
||||
import { ProcessedNotification } from "../notification.processor.service";
|
||||
import { EmailConfig } from "../../types/notification.types";
|
||||
import { settingsService } from "../settings.service";
|
||||
|
||||
class EmailSenderService implements INotificationSender {
|
||||
async send(notification: ProcessedNotification): Promise<void> {
|
||||
const config = notification.config as EmailConfig;
|
||||
const { to, smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass, from } =
|
||||
config;
|
||||
const subject = notification.subject || "Notification";
|
||||
const body = notification.body;
|
||||
|
||||
if (!to) {
|
||||
console.error(
|
||||
"[EmailSender] Missing recipient address (to) in configuration."
|
||||
);
|
||||
throw new Error(
|
||||
"Email configuration is incomplete (missing recipient address)."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSmtpHost = await settingsService.getSetting("smtpHost");
|
||||
const globalSmtpPortStr = await settingsService.getSetting("smtpPort");
|
||||
const globalSmtpSecureStr = await settingsService.getSetting(
|
||||
"smtpSecure"
|
||||
);
|
||||
const globalSmtpUser = await settingsService.getSetting("smtpUser");
|
||||
const globalSmtpPass = await settingsService.getSetting("smtpPass");
|
||||
const globalSmtpFrom = await settingsService.getSetting("smtpFrom");
|
||||
|
||||
const finalSmtpHost = smtpHost || globalSmtpHost;
|
||||
const finalSmtpPort =
|
||||
smtpPort ?? (globalSmtpPortStr ? parseInt(globalSmtpPortStr, 10) : 587);
|
||||
const finalSmtpSecure =
|
||||
smtpSecure ?? globalSmtpSecureStr === "true" ?? false;
|
||||
const finalSmtpUser = smtpUser || globalSmtpUser;
|
||||
const finalSmtpPass = smtpPass || globalSmtpPass;
|
||||
const finalFrom =
|
||||
from || globalSmtpFrom || "noreply@nexus-terminal.local";
|
||||
|
||||
if (!finalSmtpHost) {
|
||||
console.error(
|
||||
"[EmailSender] SMTP host is not configured (neither channel-specific nor global)."
|
||||
);
|
||||
throw new Error("SMTP host configuration is missing.");
|
||||
}
|
||||
|
||||
if (isNaN(finalSmtpPort) || finalSmtpPort <= 0) {
|
||||
console.error(
|
||||
`[EmailSender] Invalid SMTP port configured: ${finalSmtpPort}. Using default 587.`
|
||||
);
|
||||
|
||||
throw new Error(`Invalid SMTP port configured: ${finalSmtpPort}`);
|
||||
}
|
||||
|
||||
const transporterOptions: SMTPTransport.Options = {
|
||||
host: finalSmtpHost,
|
||||
port: finalSmtpPort,
|
||||
secure: finalSmtpSecure,
|
||||
auth:
|
||||
finalSmtpUser && finalSmtpPass
|
||||
? {
|
||||
user: finalSmtpUser,
|
||||
pass: finalSmtpPass,
|
||||
}
|
||||
: undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: finalSmtpSecure,
|
||||
|
||||
minVersion: "TLSv1.2",
|
||||
},
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterOptions);
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
from: `"${finalFrom.split("@")[0]}" <${finalFrom}>`,
|
||||
to: to,
|
||||
subject: subject,
|
||||
|
||||
html: body,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[EmailSender] Sending email notification to: ${to} with subject: "${subject}"`
|
||||
);
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log(
|
||||
`[EmailSender] Email sent successfully. Message ID: ${info.messageId}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[EmailSender] Error sending email notification to ${to}:`,
|
||||
error
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to send email notification: ${error.message || error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailSenderService = new EmailSenderService();
|
||||
export default emailSenderService;
|
||||
@@ -1,90 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { INotificationSender } from "../notification.dispatcher.service";
|
||||
import { ProcessedNotification } from "../notification.processor.service";
|
||||
import { TelegramConfig } from "../../types/notification.types";
|
||||
|
||||
class TelegramSenderService implements INotificationSender {
|
||||
async send(notification: ProcessedNotification): Promise<void> {
|
||||
const config = notification.config as TelegramConfig;
|
||||
const { botToken, chatId, customDomain } = config; // Destructure customDomain
|
||||
const messageBody = notification.body;
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
console.error(
|
||||
"[TelegramSender] Missing botToken or chatId in configuration."
|
||||
);
|
||||
throw new Error(
|
||||
"Telegram configuration is incomplete (missing botToken or chatId)."
|
||||
);
|
||||
}
|
||||
|
||||
let baseApiUrl = "https://api.telegram.org";
|
||||
if (customDomain) {
|
||||
try {
|
||||
const url = new URL(customDomain); // Validate and parse the custom domain
|
||||
baseApiUrl = `${url.protocol}//${url.host}`; // Use protocol and host from customDomain
|
||||
console.log(`[TelegramSender] Using custom domain: ${baseApiUrl}`);
|
||||
} catch (e) {
|
||||
console.warn(`[TelegramSender] Invalid customDomain URL: ${customDomain}. Falling back to default Telegram API.`);
|
||||
// Optionally, you could throw an error here or decide to proceed with the default
|
||||
}
|
||||
}
|
||||
|
||||
const apiUrl = `${baseApiUrl}/bot${botToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[TelegramSender] Sending notification to chat ID: ${chatId}`
|
||||
);
|
||||
const response = await axios.post(
|
||||
apiUrl,
|
||||
{
|
||||
chat_id: chatId,
|
||||
text: messageBody,
|
||||
parse_mode: "Markdown",
|
||||
disable_web_page_preview: true,
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data && response.data.ok) {
|
||||
console.log(
|
||||
`[TelegramSender] Successfully sent notification to chat ID: ${chatId}`
|
||||
);
|
||||
} else {
|
||||
const errorDescription =
|
||||
response.data?.description || "Unknown error from Telegram API";
|
||||
console.error(
|
||||
`[TelegramSender] Failed to send notification. Telegram API response: ${errorDescription}`,
|
||||
response.data
|
||||
);
|
||||
throw new Error(`Telegram API error: ${errorDescription}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(
|
||||
`[TelegramSender] Axios error sending notification: ${error.message}`,
|
||||
error.response?.data
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to send Telegram notification (Axios Error): ${error.message}`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[TelegramSender] Unexpected error sending notification:`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to send Telegram notification (Unexpected Error): ${
|
||||
error.message || error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const telegramSenderService = new TelegramSenderService();
|
||||
export default telegramSenderService;
|
||||
@@ -1,126 +0,0 @@
|
||||
import axios, { Method } from "axios";
|
||||
import { INotificationSender } from "../notification.dispatcher.service";
|
||||
import { ProcessedNotification } from "../notification.processor.service";
|
||||
import { WebhookConfig } from "../../types/notification.types";
|
||||
|
||||
class WebhookSenderService implements INotificationSender {
|
||||
async send(notification: ProcessedNotification): Promise<void> {
|
||||
const config = notification.config as WebhookConfig;
|
||||
const { url, method = "POST", headers = {} } = config;
|
||||
const requestBody = notification.body;
|
||||
|
||||
if (!url) {
|
||||
console.error("[WebhookSender] Missing webhook URL in configuration.");
|
||||
throw new Error("Webhook configuration is incomplete (missing URL).");
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (e) {
|
||||
console.error(`[WebhookSender] Invalid webhook URL format: ${url}`);
|
||||
throw new Error(`Invalid webhook URL format: ${url}`);
|
||||
}
|
||||
|
||||
const finalHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
|
||||
const requestMethod: Method = method.toUpperCase() as Method;
|
||||
const validMethods: Method[] = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
];
|
||||
if (!validMethods.includes(requestMethod)) {
|
||||
console.error(
|
||||
`[WebhookSender] Invalid HTTP method specified: ${method}. Defaulting to POST.`
|
||||
);
|
||||
|
||||
throw new Error(`Invalid HTTP method specified: ${method}`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[WebhookSender] Sending ${requestMethod} notification to webhook URL: ${url}`
|
||||
);
|
||||
|
||||
let requestData: any = undefined;
|
||||
let requestParams: any = undefined;
|
||||
|
||||
if (["POST", "PUT", "PATCH"].includes(requestMethod)) {
|
||||
if (
|
||||
finalHeaders["Content-Type"]
|
||||
?.toLowerCase()
|
||||
.includes("application/json")
|
||||
) {
|
||||
try {
|
||||
requestData = JSON.parse(requestBody);
|
||||
} catch (parseError) {
|
||||
console.warn(
|
||||
`[WebhookSender] Failed to parse request body as JSON for Content-Type application/json. Sending as raw string. Body: ${requestBody.substring(
|
||||
0,
|
||||
100
|
||||
)}...`
|
||||
);
|
||||
requestData = requestBody;
|
||||
}
|
||||
} else {
|
||||
requestData = requestBody;
|
||||
}
|
||||
} else if (requestMethod === "GET") {
|
||||
console.warn(
|
||||
`[WebhookSender] Sending data in body for GET request might not be standard. URL: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method: requestMethod,
|
||||
url: url,
|
||||
headers: finalHeaders,
|
||||
data: requestData,
|
||||
params: requestParams,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
console.log(
|
||||
`[WebhookSender] Successfully sent notification to webhook. Status: ${response.status}`
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[WebhookSender] Webhook endpoint responded with status: ${response.status}`,
|
||||
response.data
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(
|
||||
`[WebhookSender] Axios error sending notification to ${url}: ${error.message}`,
|
||||
error.response?.status,
|
||||
error.response?.data
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to send webhook notification (Axios Error): ${error.message}`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[WebhookSender] Unexpected error sending notification to ${url}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to send webhook notification (Unexpected Error): ${
|
||||
error.message || error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const webhookSenderService = new WebhookSenderService();
|
||||
export default webhookSenderService;
|
||||
@@ -1,564 +0,0 @@
|
||||
import {
|
||||
settingsRepository,
|
||||
Setting,
|
||||
getSidebarConfig as getSidebarConfigFromRepo,
|
||||
setSidebarConfig as setSidebarConfigInRepo,
|
||||
getCaptchaConfig as getCaptchaConfigFromRepo,
|
||||
setCaptchaConfig as setCaptchaConfigInRepo,
|
||||
} from '../settings/settings.repository';
|
||||
import {
|
||||
SidebarConfig,
|
||||
PaneName,
|
||||
UpdateSidebarConfigDto,
|
||||
CaptchaSettings,
|
||||
UpdateCaptchaSettingsDto,
|
||||
CaptchaProvider,
|
||||
} from '../types/settings.types';
|
||||
|
||||
// +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++
|
||||
interface FocusItemConfig { // 单个项目的配置
|
||||
shortcut?: string;
|
||||
}
|
||||
interface FocusSwitcherFullConfig { // 完整配置结构
|
||||
sequence: string[];
|
||||
shortcuts: Record<string, FocusItemConfig>;
|
||||
}
|
||||
|
||||
const FOCUS_SEQUENCE_KEY = 'focusSwitcherSequence'; // 设置键保持不变
|
||||
const NAV_BAR_VISIBLE_KEY = 'navBarVisible'; // 导航栏可见性设置键
|
||||
const LAYOUT_TREE_KEY = 'layoutTree'; // 布局树设置键
|
||||
const AUTO_COPY_ON_SELECT_KEY = 'autoCopyOnSelect'; // 终端选中自动复制设置键
|
||||
const STATUS_MONITOR_INTERVAL_SECONDS_KEY = 'statusMonitorIntervalSeconds'; // 状态监控间隔设置键
|
||||
const DEFAULT_STATUS_MONITOR_INTERVAL_SECONDS = 3; // 默认状态监控间隔
|
||||
const IP_BLACKLIST_ENABLED_KEY = 'ipBlacklistEnabled'; // IP 黑名单启用设置键
|
||||
const SHOW_CONNECTION_TAGS_KEY = 'showConnectionTags'; // 连接标签显示设置键
|
||||
const SHOW_QUICK_COMMAND_TAGS_KEY = 'showQuickCommandTags'; // 快捷指令标签显示设置键
|
||||
const SHOW_STATUS_MONITOR_IP_ADDRESS_KEY = 'showStatusMonitorIpAddress'; // 状态监视器IP显示设置键
|
||||
|
||||
export const settingsService = {
|
||||
/**
|
||||
* 获取所有设置项
|
||||
* @returns 返回包含所有设置项的键值对记录
|
||||
*/
|
||||
async getAllSettings(): Promise<Record<string, string>> {
|
||||
// console.log('[Service] Calling repository.getAllSettings...');
|
||||
const settingsArray = await settingsRepository.getAllSettings();
|
||||
// console.log('[Service] Got settings array from repository:', JSON.stringify(settingsArray));
|
||||
const settingsRecord: Record<string, string> = {};
|
||||
settingsArray.forEach(setting => {
|
||||
settingsRecord[setting.key] = setting.value;
|
||||
});
|
||||
return settingsRecord;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个设置项的值
|
||||
* @param key 设置项的键
|
||||
* @returns 返回设置项的值,如果不存在则返回 null
|
||||
*/
|
||||
async getSetting(key: string): Promise<string | null> {
|
||||
return settingsRepository.getSetting(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置单个设置项的值 (如果键已存在则更新)
|
||||
* @param key 设置项的键
|
||||
* @param value 设置项的值
|
||||
*/
|
||||
async setSetting(key: string, value: string): Promise<void> {
|
||||
await settingsRepository.setSetting(key, value);
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量设置多个设置项的值
|
||||
* @param settings 包含多个设置项键值对的对象
|
||||
*/
|
||||
async setMultipleSettings(settings: Record<string, string>): Promise<void> {
|
||||
console.log('[Service] Calling repository.setMultipleSettings with:', JSON.stringify(settings));
|
||||
await settingsRepository.setMultipleSettings(settings);
|
||||
console.log('[Service] Finished repository.setMultipleSettings.');
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除单个设置项
|
||||
* @param key 要删除的设置项的键
|
||||
*/
|
||||
async deleteSetting(key: string): Promise<void> {
|
||||
await settingsRepository.deleteSetting(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 IP 白名单设置
|
||||
* @returns 返回包含启用状态和白名单列表的对象
|
||||
*/
|
||||
async getIpWhitelistSettings(): Promise<{ enabled: boolean; whitelist: string }> {
|
||||
const enabledStr = await settingsRepository.getSetting('ipWhitelistEnabled');
|
||||
const whitelist = await settingsRepository.getSetting('ipWhitelist');
|
||||
return {
|
||||
enabled: enabledStr === 'true',
|
||||
whitelist: whitelist ?? '',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新 IP 白名单设置
|
||||
* @param enabled 是否启用 IP 白名单
|
||||
* @param whitelist 允许的 IP 地址/CIDR 列表 (字符串形式)
|
||||
*/
|
||||
async updateIpWhitelistSettings(enabled: boolean, whitelist: string): Promise<void> {
|
||||
await Promise.all([
|
||||
settingsRepository.setSetting('ipWhitelistEnabled', String(enabled)),
|
||||
settingsRepository.setSetting('ipWhitelist', whitelist),
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查 IP 黑名单功能是否已启用
|
||||
* @returns 返回是否启用 (boolean),如果未设置则默认为 true
|
||||
*/
|
||||
async isIpBlacklistEnabled(): Promise<boolean> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${IP_BLACKLIST_ENABLED_KEY}`);
|
||||
try {
|
||||
const enabledStr = await settingsRepository.getSetting(IP_BLACKLIST_ENABLED_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${IP_BLACKLIST_ENABLED_KEY}:`, enabledStr);
|
||||
// 如果设置存在且值为 'false',则返回 false,否则都返回 true (包括未设置的情况)
|
||||
return enabledStr !== 'false';
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting IP blacklist enabled setting (key: ${IP_BLACKLIST_ENABLED_KEY}):`, error);
|
||||
// 出错时返回默认值 true (安全起见,默认启用)
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取焦点切换顺序
|
||||
* @returns 返回存储的完整焦点切换配置对象,如果未设置或无效则返回默认空配置
|
||||
*/
|
||||
async getFocusSwitcherSequence(): Promise<FocusSwitcherFullConfig> { // +++ 更新返回类型 +++
|
||||
console.log(`[Service] Attempting to get setting for key: ${FOCUS_SEQUENCE_KEY}`);
|
||||
const defaultConfig: FocusSwitcherFullConfig = { sequence: [], shortcuts: {} }; // 默认值
|
||||
try {
|
||||
const configJson = await settingsRepository.getSetting(FOCUS_SEQUENCE_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${FOCUS_SEQUENCE_KEY}:`, configJson);
|
||||
if (configJson) {
|
||||
const config = JSON.parse(configJson);
|
||||
// +++ 验证 FocusSwitcherFullConfig 结构 +++
|
||||
if (
|
||||
typeof config === 'object' && config !== null &&
|
||||
Array.isArray(config.sequence) && config.sequence.every((item: any) => typeof item === 'string') &&
|
||||
typeof config.shortcuts === 'object' && config.shortcuts !== null &&
|
||||
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));
|
||||
return config as FocusSwitcherFullConfig;
|
||||
} else {
|
||||
console.warn('[Service] Invalid full focus switcher config format found in settings. Returning default.');
|
||||
}
|
||||
} else {
|
||||
console.log('[Service] No focus switcher config found in settings. Returning default.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error parsing full focus switcher config from settings (key: ${FOCUS_SEQUENCE_KEY}):`, error);
|
||||
}
|
||||
console.log('[Service] Returning default focus config:', JSON.stringify(defaultConfig));
|
||||
return defaultConfig;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置完整的焦点切换配置
|
||||
* @param fullConfig 包含 sequence 和 shortcuts 的完整配置对象
|
||||
*/
|
||||
async setFocusSwitcherSequence(fullConfig: FocusSwitcherFullConfig): Promise<void> { // +++ 更新参数类型 +++
|
||||
console.log('[Service] setFocusSwitcherSequence called with full config:', JSON.stringify(fullConfig));
|
||||
// +++ 验证 FocusSwitcherFullConfig 结构 (控制器层已做基本验证) +++
|
||||
if (
|
||||
!(typeof fullConfig === 'object' && fullConfig !== null &&
|
||||
Array.isArray(fullConfig.sequence) && fullConfig.sequence.every((item: any) => typeof item === 'string') &&
|
||||
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.error('[Service] Attempted to save invalid full focus switcher config format:', fullConfig);
|
||||
throw new Error('Invalid full config format provided.');
|
||||
}
|
||||
// TODO: 可能需要进一步验证 sequence 中的 id 和 shortcuts 中的 key 是否有效
|
||||
|
||||
try {
|
||||
const configJson = JSON.stringify(fullConfig); // +++ 序列化完整结构 +++
|
||||
console.log(`[Service] Attempting to save setting. Key: ${FOCUS_SEQUENCE_KEY}, Value: ${configJson}`);
|
||||
await settingsRepository.setSetting(FOCUS_SEQUENCE_KEY, configJson);
|
||||
console.log(`[Service] Successfully saved setting for key: ${FOCUS_SEQUENCE_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${FOCUS_SEQUENCE_KEY}:`, error);
|
||||
throw new Error('Failed to save focus switcher sequence.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取导航栏可见性设置
|
||||
* @returns 返回导航栏是否可见 (boolean),如果未设置则默认为 true
|
||||
*/
|
||||
async getNavBarVisibility(): Promise<boolean> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${NAV_BAR_VISIBLE_KEY}`);
|
||||
try {
|
||||
const visibleStr = await settingsRepository.getSetting(NAV_BAR_VISIBLE_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${NAV_BAR_VISIBLE_KEY}:`, visibleStr);
|
||||
// 如果设置存在且值为 'false',则返回 false,否则都返回 true (包括未设置的情况)
|
||||
return visibleStr !== 'false';
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting nav bar visibility setting (key: ${NAV_BAR_VISIBLE_KEY}):`, error);
|
||||
// 出错时返回默认值 true
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置导航栏可见性
|
||||
* @param visible 是否可见 (boolean)
|
||||
*/
|
||||
async setNavBarVisibility(visible: boolean): Promise<void> {
|
||||
console.log(`[Service] setNavBarVisibility called with: ${visible}`);
|
||||
try {
|
||||
const visibleStr = String(visible); // 将布尔值转换为 'true' 或 'false'
|
||||
console.log(`[Service] Attempting to save setting. Key: ${NAV_BAR_VISIBLE_KEY}, Value: ${visibleStr}`);
|
||||
await settingsRepository.setSetting(NAV_BAR_VISIBLE_KEY, visibleStr);
|
||||
console.log(`[Service] Successfully saved setting for key: ${NAV_BAR_VISIBLE_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${NAV_BAR_VISIBLE_KEY}:`, error);
|
||||
throw new Error('Failed to save nav bar visibility setting.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取布局树设置
|
||||
* @returns 返回存储的布局树 JSON 字符串,如果未设置则返回 null
|
||||
*/
|
||||
async getLayoutTree(): Promise<string | null> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${LAYOUT_TREE_KEY}`);
|
||||
try {
|
||||
const layoutJson = await settingsRepository.getSetting(LAYOUT_TREE_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${LAYOUT_TREE_KEY}:`, layoutJson ? layoutJson.substring(0, 100) + '...' : null); // 只打印部分内容
|
||||
return layoutJson; // 直接返回 JSON 字符串或 null
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting layout tree setting (key: ${LAYOUT_TREE_KEY}):`, error);
|
||||
return null; // 出错时返回 null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置布局树
|
||||
* @param layoutJson 布局树的 JSON 字符串
|
||||
*/
|
||||
async setLayoutTree(layoutJson: string): Promise<void> {
|
||||
console.log(`[Service] setLayoutTree called with JSON (first 100 chars): ${layoutJson.substring(0, 100)}...`);
|
||||
// 可选:在这里添加 JSON 格式验证
|
||||
try {
|
||||
JSON.parse(layoutJson); // 尝试解析以验证格式
|
||||
} catch (e) {
|
||||
console.error('[Service] Invalid JSON format provided for layout tree:', e);
|
||||
throw new Error('Invalid layout tree JSON format.');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Service] Attempting to save setting. Key: ${LAYOUT_TREE_KEY}`);
|
||||
await settingsRepository.setSetting(LAYOUT_TREE_KEY, layoutJson);
|
||||
console.log(`[Service] Successfully saved setting for key: ${LAYOUT_TREE_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${LAYOUT_TREE_KEY}:`, error);
|
||||
throw new Error('Failed to save layout tree setting.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取终端选中自动复制设置
|
||||
* @returns 返回是否启用该功能 (boolean),如果未设置则默认为 false
|
||||
*/
|
||||
async getAutoCopyOnSelect(): Promise<boolean> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${AUTO_COPY_ON_SELECT_KEY}`);
|
||||
try {
|
||||
const enabledStr = await settingsRepository.getSetting(AUTO_COPY_ON_SELECT_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${AUTO_COPY_ON_SELECT_KEY}:`, enabledStr);
|
||||
// 如果设置存在且值为 'true',则返回 true,否则都返回 false (包括未设置或值为 'false' 的情况)
|
||||
return enabledStr === 'true';
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting auto copy on select setting (key: ${AUTO_COPY_ON_SELECT_KEY}):`, error);
|
||||
// 出错时返回默认值 false
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置终端选中自动复制
|
||||
* @param enabled 是否启用 (boolean)
|
||||
*/
|
||||
async setAutoCopyOnSelect(enabled: boolean): Promise<void> {
|
||||
console.log(`[Service] setAutoCopyOnSelect called with: ${enabled}`);
|
||||
try {
|
||||
const enabledStr = String(enabled); // 将布尔值转换为 'true' 或 'false'
|
||||
console.log(`[Service] Attempting to save setting. Key: ${AUTO_COPY_ON_SELECT_KEY}, Value: ${enabledStr}`);
|
||||
await settingsRepository.setSetting(AUTO_COPY_ON_SELECT_KEY, enabledStr);
|
||||
console.log(`[Service] Successfully saved setting for key: ${AUTO_COPY_ON_SELECT_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${AUTO_COPY_ON_SELECT_KEY}:`, error);
|
||||
throw new Error('Failed to save auto copy on select setting.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取状态监控轮询间隔 (秒)
|
||||
* @returns 返回间隔秒数 (number),如果未设置或无效则返回默认值
|
||||
*/
|
||||
async getStatusMonitorIntervalSeconds(): Promise<number> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${STATUS_MONITOR_INTERVAL_SECONDS_KEY}`);
|
||||
try {
|
||||
const intervalStr = await settingsRepository.getSetting(STATUS_MONITOR_INTERVAL_SECONDS_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${STATUS_MONITOR_INTERVAL_SECONDS_KEY}:`, intervalStr);
|
||||
if (intervalStr) {
|
||||
const intervalNum = parseInt(intervalStr, 10);
|
||||
// 验证是否为正整数
|
||||
if (!isNaN(intervalNum) && intervalNum > 0) {
|
||||
return intervalNum;
|
||||
} else {
|
||||
console.warn(`[Service] Invalid status monitor interval value found ('${intervalStr}'). Returning default.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Service] No status monitor interval found in settings. Returning default.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting status monitor interval setting (key: ${STATUS_MONITOR_INTERVAL_SECONDS_KEY}):`, error);
|
||||
}
|
||||
// 返回默认值
|
||||
return DEFAULT_STATUS_MONITOR_INTERVAL_SECONDS;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置状态监控轮询间隔 (秒)
|
||||
* @param interval 间隔秒数 (number)
|
||||
*/
|
||||
async setStatusMonitorIntervalSeconds(interval: number): Promise<void> {
|
||||
console.log(`[Service] setStatusMonitorIntervalSeconds called with: ${interval}`);
|
||||
// 验证输入是否为正整数
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
console.error(`[Service] Attempted to save invalid status monitor interval: ${interval}`);
|
||||
throw new Error('Invalid interval value provided. Must be a positive integer.');
|
||||
}
|
||||
try {
|
||||
const intervalStr = String(interval);
|
||||
console.log(`[Service] Attempting to save setting. Key: ${STATUS_MONITOR_INTERVAL_SECONDS_KEY}, Value: ${intervalStr}`);
|
||||
await settingsRepository.setSetting(STATUS_MONITOR_INTERVAL_SECONDS_KEY, intervalStr);
|
||||
console.log(`[Service] Successfully saved setting for key: ${STATUS_MONITOR_INTERVAL_SECONDS_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${STATUS_MONITOR_INTERVAL_SECONDS_KEY}:`, error);
|
||||
throw new Error('Failed to save status monitor interval setting.');
|
||||
}
|
||||
},
|
||||
|
||||
// --- Sidebar Config Specific Functions ---
|
||||
|
||||
/**
|
||||
* 获取侧栏配置
|
||||
* @returns Promise<SidebarConfig>
|
||||
*/
|
||||
async getSidebarConfig(): Promise<SidebarConfig> {
|
||||
console.log('[SettingsService] Getting sidebar config...');
|
||||
// Directly call the specific repository function
|
||||
const config = await getSidebarConfigFromRepo();
|
||||
console.log('[SettingsService] Returning sidebar config:', config);
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置侧栏配置
|
||||
* @param configDto - The sidebar configuration object from DTO
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async setSidebarConfig(configDto: UpdateSidebarConfigDto): Promise<void> {
|
||||
console.log('[SettingsService] Setting sidebar config:', configDto);
|
||||
|
||||
// --- Validation ---
|
||||
if (!configDto || typeof configDto !== 'object' || !Array.isArray(configDto.left) || !Array.isArray(configDto.right)) {
|
||||
throw new Error('无效的侧栏配置格式。必须包含 left 和 right 数组。');
|
||||
}
|
||||
|
||||
// Validate PaneName (using the type imported)
|
||||
const validPaneNames: Set<PaneName> = new Set([
|
||||
'connections', 'terminal', 'commandBar', 'fileManager',
|
||||
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
|
||||
'dockerManager', 'suspendedSshSessions' // 添加 "suspendedSshSessions"
|
||||
]);
|
||||
|
||||
const validatePaneArray = (arr: any[], side: string) => {
|
||||
if (!arr.every(item => typeof item === 'string' && validPaneNames.has(item as PaneName))) {
|
||||
const invalidItems = arr.filter(item => typeof item !== 'string' || !validPaneNames.has(item as PaneName));
|
||||
throw new Error(`侧栏配置 (${side}) 包含无效的面板名称: ${invalidItems.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
validatePaneArray(configDto.left, 'left');
|
||||
validatePaneArray(configDto.right, 'right');
|
||||
|
||||
// Prevent duplicates (optional, uncomment if needed)
|
||||
// const allPanes = [...configDto.left, ...configDto.right];
|
||||
// const uniquePanes = new Set(allPanes);
|
||||
// if (allPanes.length !== uniquePanes.size) {
|
||||
// throw new Error('侧栏配置中不允许包含重复的面板。');
|
||||
// }
|
||||
|
||||
// Prepare the data in the exact SidebarConfig format expected by the repo
|
||||
const configToSave: SidebarConfig = {
|
||||
left: configDto.left,
|
||||
right: configDto.right,
|
||||
};
|
||||
|
||||
// Directly call the specific repository function
|
||||
await setSidebarConfigInRepo(configToSave);
|
||||
console.log('[SettingsService] Sidebar config successfully set.');
|
||||
}, // <-- Add comma here
|
||||
|
||||
// --- CAPTCHA Settings Specific Functions ---
|
||||
|
||||
/**
|
||||
* 获取 CAPTCHA 配置
|
||||
* @returns Promise<CaptchaSettings>
|
||||
*/
|
||||
async getCaptchaConfig(): Promise<CaptchaSettings> {
|
||||
console.log('[SettingsService] Getting CAPTCHA config...');
|
||||
// Directly call the specific repository function
|
||||
const config = await getCaptchaConfigFromRepo();
|
||||
// Mask secret keys before logging
|
||||
const maskedConfig = { ...config, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' };
|
||||
console.log('[SettingsService] Returning CAPTCHA config:', maskedConfig);
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置 CAPTCHA 配置
|
||||
* @param configDto - The CAPTCHA configuration object from DTO
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async setCaptchaConfig(configDto: UpdateCaptchaSettingsDto): Promise<void> {
|
||||
console.log('[SettingsService] Setting CAPTCHA config (DTO):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets in log
|
||||
|
||||
// --- Validation ---
|
||||
if (!configDto || typeof configDto !== 'object') {
|
||||
throw new Error('无效的 CAPTCHA 配置格式。');
|
||||
}
|
||||
|
||||
// Fetch the current settings to merge with the DTO
|
||||
const currentConfig = await getCaptchaConfigFromRepo();
|
||||
const configToSave: CaptchaSettings = { ...currentConfig };
|
||||
|
||||
// Validate and update individual fields from DTO
|
||||
if (configDto.enabled !== undefined) {
|
||||
if (typeof configDto.enabled !== 'boolean') throw new Error('captcha.enabled 必须是布尔值。');
|
||||
configToSave.enabled = configDto.enabled;
|
||||
}
|
||||
if (configDto.provider !== undefined) {
|
||||
const validProviders: CaptchaProvider[] = ['hcaptcha', 'recaptcha', 'none'];
|
||||
if (!validProviders.includes(configDto.provider)) throw new Error(`无效的 CAPTCHA 提供商: ${configDto.provider}`);
|
||||
configToSave.provider = configDto.provider;
|
||||
}
|
||||
if (configDto.hcaptchaSiteKey !== undefined) {
|
||||
if (typeof configDto.hcaptchaSiteKey !== 'string') throw new Error('hcaptchaSiteKey 必须是字符串。');
|
||||
configToSave.hcaptchaSiteKey = configDto.hcaptchaSiteKey;
|
||||
}
|
||||
if (configDto.hcaptchaSecretKey !== undefined) {
|
||||
if (typeof configDto.hcaptchaSecretKey !== 'string') throw new Error('hcaptchaSecretKey 必须是字符串。');
|
||||
configToSave.hcaptchaSecretKey = configDto.hcaptchaSecretKey;
|
||||
}
|
||||
if (configDto.recaptchaSiteKey !== undefined) {
|
||||
if (typeof configDto.recaptchaSiteKey !== 'string') throw new Error('recaptchaSiteKey 必须是字符串。');
|
||||
configToSave.recaptchaSiteKey = configDto.recaptchaSiteKey;
|
||||
}
|
||||
if (configDto.recaptchaSecretKey !== undefined) {
|
||||
if (typeof configDto.recaptchaSecretKey !== 'string') throw new Error('recaptchaSecretKey 必须是字符串。');
|
||||
configToSave.recaptchaSecretKey = configDto.recaptchaSecretKey;
|
||||
}
|
||||
|
||||
// Ensure consistency: if disabled, provider should ideally be 'none' (optional enforcement)
|
||||
// if (!configToSave.enabled) {
|
||||
// configToSave.provider = 'none';
|
||||
// }
|
||||
|
||||
// Directly call the specific repository function with the full, validated config
|
||||
await setCaptchaConfigInRepo(configToSave);
|
||||
console.log('[SettingsService] CAPTCHA config successfully set.');
|
||||
}, // <-- Add comma here
|
||||
|
||||
// --- Show Connection Tags ---
|
||||
async getShowConnectionTags(): Promise<boolean> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${SHOW_CONNECTION_TAGS_KEY}`);
|
||||
try {
|
||||
const valueStr = await settingsRepository.getSetting(SHOW_CONNECTION_TAGS_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${SHOW_CONNECTION_TAGS_KEY}:`, valueStr);
|
||||
// 默认显示,所以只有当值为 'false' 时才返回 false
|
||||
return valueStr !== 'false';
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting show connection tags setting (key: ${SHOW_CONNECTION_TAGS_KEY}):`, error);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async setShowConnectionTags(enabled: boolean): Promise<void> {
|
||||
console.log(`[Service] setShowConnectionTags called with: ${enabled}`);
|
||||
try {
|
||||
const valueStr = String(enabled);
|
||||
console.log(`[Service] Attempting to save setting. Key: ${SHOW_CONNECTION_TAGS_KEY}, Value: ${valueStr}`);
|
||||
await settingsRepository.setSetting(SHOW_CONNECTION_TAGS_KEY, valueStr);
|
||||
console.log(`[Service] Successfully saved setting for key: ${SHOW_CONNECTION_TAGS_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${SHOW_CONNECTION_TAGS_KEY}:`, error);
|
||||
throw new Error('Failed to save show connection tags setting.');
|
||||
}
|
||||
},
|
||||
|
||||
// --- Show Quick Command Tags ---
|
||||
async getShowQuickCommandTags(): Promise<boolean> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${SHOW_QUICK_COMMAND_TAGS_KEY}`);
|
||||
try {
|
||||
const valueStr = await settingsRepository.getSetting(SHOW_QUICK_COMMAND_TAGS_KEY);
|
||||
console.log(`[Service] Raw value from repository for ${SHOW_QUICK_COMMAND_TAGS_KEY}:`, valueStr);
|
||||
// 默认显示,所以只有当值为 'false' 时才返回 false
|
||||
return valueStr !== 'false';
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting show quick command tags setting (key: ${SHOW_QUICK_COMMAND_TAGS_KEY}):`, error);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async setShowQuickCommandTags(enabled: boolean): Promise<void> {
|
||||
console.log(`[Service] setShowQuickCommandTags called with: ${enabled}`);
|
||||
try {
|
||||
const valueStr = String(enabled);
|
||||
console.log(`[Service] Attempting to save setting. Key: ${SHOW_QUICK_COMMAND_TAGS_KEY}, Value: ${valueStr}`);
|
||||
await settingsRepository.setSetting(SHOW_QUICK_COMMAND_TAGS_KEY, valueStr);
|
||||
console.log(`[Service] Successfully saved setting for key: ${SHOW_QUICK_COMMAND_TAGS_KEY}`);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${SHOW_QUICK_COMMAND_TAGS_KEY}:`, error);
|
||||
throw new Error('Failed to save show quick command tags setting.');
|
||||
}
|
||||
},
|
||||
|
||||
// --- Show Status Monitor IP Address ---
|
||||
async getShowStatusMonitorIpAddress(): Promise<boolean> {
|
||||
console.log(`[Service] Attempting to get setting for key: ${SHOW_STATUS_MONITOR_IP_ADDRESS_KEY}`);
|
||||
try {
|
||||
const valueStr = await settingsRepository.getSetting(SHOW_STATUS_MONITOR_IP_ADDRESS_KEY);
|
||||
// 默认显示 (true),所以只有当值为 'false' 时才返回 false
|
||||
return valueStr !== 'false';
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error getting show status monitor IP address setting (key: ${SHOW_STATUS_MONITOR_IP_ADDRESS_KEY}):`, error);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async setShowStatusMonitorIpAddress(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const valueStr = String(enabled);
|
||||
await settingsRepository.setSetting(SHOW_STATUS_MONITOR_IP_ADDRESS_KEY, valueStr);
|
||||
} catch (error) {
|
||||
console.error(`[Service] Error calling settingsRepository.setSetting for key ${SHOW_STATUS_MONITOR_IP_ADDRESS_KEY}:`, error);
|
||||
throw new Error('Failed to save show status monitor IP address setting.');
|
||||
}
|
||||
}
|
||||
|
||||
}; // <-- End of settingsService object definition
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,453 +0,0 @@
|
||||
import { Client, Channel, ClientChannel } from 'ssh2';
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
SuspendSessionDetails,
|
||||
SuspendedSessionsMap,
|
||||
BackendSshStatus,
|
||||
SuspendedSessionInfo,
|
||||
} from '../types/ssh-suspend.types';
|
||||
import { temporaryLogStorageService, TemporaryLogStorageService } from './temporary-log-storage.service';
|
||||
import { ClientState } from '../websocket/types';
|
||||
// clientStates 的直接访问已移除,因为takeOverMarkedSession现在从调用者接收所需信息
|
||||
|
||||
/**
|
||||
* SshSuspendService 负责管理所有用户的挂起 SSH 会话的生命周期。
|
||||
*/
|
||||
export class SshSuspendService extends EventEmitter {
|
||||
private suspendedSessions: SuspendedSessionsMap = new Map();
|
||||
private readonly logStorageService: TemporaryLogStorageService;
|
||||
|
||||
constructor(logStorage?: TemporaryLogStorageService) {
|
||||
super(); // 调用 EventEmitter 的构造函数
|
||||
this.logStorageService = logStorage || temporaryLogStorageService;
|
||||
// TODO: 考虑在服务启动时从日志目录加载持久化的 'disconnected_by_backend' 会话信息。
|
||||
// 这需要日志文件本身包含可解析的元数据。
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户特定的会话映射,如果不存在则创建。
|
||||
* @param userId 用户ID。
|
||||
* @returns 该用户的 Map<suspendSessionId, SuspendSessionDetails>。
|
||||
*/
|
||||
private getUserSessions(userId: number): Map<string, SuspendSessionDetails> { // userId: string -> number
|
||||
if (!this.suspendedSessions.has(userId)) {
|
||||
this.suspendedSessions.set(userId, new Map<string, SuspendSessionDetails>());
|
||||
}
|
||||
return this.suspendedSessions.get(userId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当一个被标记为待挂起的会话的 WebSocket 连接断开时,由此方法接管 SSH 资源。
|
||||
* @param details 包含接管所需的所有会话详细信息。
|
||||
* @returns Promise<string | null> 返回新生成的 suspendSessionId,如果无法接管则返回 null。
|
||||
*/
|
||||
async takeOverMarkedSession(details: {
|
||||
userId: number;
|
||||
originalSessionId: string;
|
||||
sshClient: Client;
|
||||
channel: ClientChannel;
|
||||
connectionName: string;
|
||||
connectionId: string;
|
||||
logIdentifier: string;
|
||||
customSuspendName?: string;
|
||||
}): Promise<string | null> {
|
||||
const {
|
||||
userId,
|
||||
originalSessionId,
|
||||
sshClient,
|
||||
channel,
|
||||
connectionName,
|
||||
connectionId,
|
||||
logIdentifier,
|
||||
customSuspendName,
|
||||
} = details;
|
||||
console.log(`[SshSuspendService DEBUG] takeOverMarkedSession: Called for userId=${userId}, originalSessionId=${originalSessionId}`);
|
||||
|
||||
// 检查 SSH client 和 channel 是否仍然可用
|
||||
// ClientChannel 有 readable 和 writable, Client 本身没有直接的此类属性
|
||||
// 如果 channel 不可读写,通常意味着底层连接有问题。
|
||||
console.log(`[SshSuspendService DEBUG] takeOverMarkedSession: Checking channel for originalSessionId=${originalSessionId}. Readable: ${channel?.readable}, Writable: ${channel?.writable}`);
|
||||
if (!channel || !channel.readable || !channel.writable) {
|
||||
console.warn(`[SshSuspendService WARN] takeOverMarkedSession: userId=${userId}, originalSessionId=${originalSessionId}. SSH channel is not usable. readable=${channel?.readable}, writable=${channel?.writable}. Cannot take over.`);
|
||||
// 确保如果 SSH 连接已经关闭,日志文件仍然保留,但不创建挂起条目。
|
||||
// SshSuspendService 不会管理这个“已经断开”的会话,但日志保留供用户清理。
|
||||
try { channel?.end(); } catch (e) { /* ignore */ }
|
||||
try { sshClient?.end(); } catch (e) { /* ignore */ }
|
||||
return null; // 无法接管
|
||||
}
|
||||
|
||||
const suspendSessionId = uuidv4();
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
|
||||
channel.removeAllListeners('data');
|
||||
channel.removeAllListeners('close');
|
||||
channel.removeAllListeners('error');
|
||||
channel.removeAllListeners('end');
|
||||
channel.removeAllListeners('exit');
|
||||
|
||||
sshClient.removeAllListeners('error');
|
||||
sshClient.removeAllListeners('end');
|
||||
|
||||
const sessionDetails: SuspendSessionDetails = {
|
||||
sshClient,
|
||||
channel,
|
||||
tempLogPath: logIdentifier, // 使用传入的日志标识符 (基于 originalSessionId)
|
||||
connectionName,
|
||||
connectionId,
|
||||
suspendStartTime: new Date().toISOString(),
|
||||
customSuspendName,
|
||||
backendSshStatus: 'hanging',
|
||||
originalSessionId,
|
||||
userId,
|
||||
};
|
||||
|
||||
userSessions.set(suspendSessionId, sessionDetails);
|
||||
console.log(`[SshSuspendService INFO] takeOverMarkedSession: userId=${userId}, originalSessionId=${originalSessionId} taken over. New suspendSessionId=${suspendSessionId}, initial status=${sessionDetails.backendSshStatus}. Log identifier=${logIdentifier}`);
|
||||
|
||||
await this.logStorageService.ensureLogDirectoryExists();
|
||||
|
||||
console.log(`[SshSuspendService DEBUG] takeOverMarkedSession: Setting up channel 'data' listener for suspendSessionId=${suspendSessionId}`);
|
||||
channel.on('data', (data: Buffer) => {
|
||||
const currentDetails = userSessions.get(suspendSessionId);
|
||||
if (currentDetails?.backendSshStatus === 'hanging') {
|
||||
// console.log(`[SshSuspendService DEBUG] channel.on('data') for suspendSessionId=${suspendSessionId}: Writing to log ${logIdentifier}`);
|
||||
this.logStorageService.writeToLog(logIdentifier, data.toString('utf-8')).catch(err => {
|
||||
console.error(`[SshSuspendService ERROR] channel.on('data') for suspendSessionId=${suspendSessionId}, log=${logIdentifier}: Failed to write to log:`, err);
|
||||
});
|
||||
} else {
|
||||
// console.log(`[SshSuspendService DEBUG] channel.on('data') for suspendSessionId=${suspendSessionId}: Backend status is ${currentDetails?.backendSshStatus}, not writing to log.`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSessionTermination = (reasonSuffix: string) => {
|
||||
const currentSession = userSessions.get(suspendSessionId);
|
||||
console.log(`[SshSuspendService DEBUG] handleSessionTermination: Called for suspendSessionId=${suspendSessionId}, reasonSuffix='${reasonSuffix}'. Session found: ${!!currentSession}. Current status: ${currentSession?.backendSshStatus}`);
|
||||
if (currentSession && currentSession.backendSshStatus === 'hanging') {
|
||||
const reason = `SSH connection ${reasonSuffix}.`;
|
||||
console.warn(`[SshSuspendService WARN] handleSessionTermination: userId=${currentSession.userId}, suspendSessionId=${suspendSessionId}. SSH connection terminated during suspension. Reason: ${reason}`);
|
||||
currentSession.backendSshStatus = 'disconnected_by_backend';
|
||||
currentSession.disconnectionTimestamp = new Date().toISOString();
|
||||
|
||||
this.removeChannelListeners(channel, sshClient);
|
||||
console.log(`[SshSuspendService DEBUG] handleSessionTermination: Listeners removed for suspendSessionId=${suspendSessionId}.`);
|
||||
|
||||
this.emit('sessionAutoTerminated', {
|
||||
userId: currentSession.userId,
|
||||
suspendSessionId,
|
||||
reason
|
||||
});
|
||||
console.log(`[SshSuspendService INFO] handleSessionTermination: Emitted 'sessionAutoTerminated' for suspendSessionId=${suspendSessionId}, userId=${currentSession.userId}.`);
|
||||
} else if (currentSession) {
|
||||
console.log(`[SshSuspendService DEBUG] handleSessionTermination: Condition not met for suspendSessionId=${suspendSessionId}. Status was '${currentSession.backendSshStatus}', not 'hanging'. No action taken.`);
|
||||
} else {
|
||||
console.warn(`[SshSuspendService WARN] handleSessionTermination: Session not found for suspendSessionId=${suspendSessionId} when event '${reasonSuffix}' occurred.`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[SshSuspendService DEBUG] takeOverMarkedSession: Setting up channel/client event listeners for suspendSessionId=${suspendSessionId}`);
|
||||
channel.on('close', () => {
|
||||
console.log(`[SshSuspendService DEBUG] channel.on('close') triggered for suspendSessionId=${suspendSessionId}`);
|
||||
handleSessionTermination('channel closed');
|
||||
});
|
||||
channel.on('error', (err: Error) => {
|
||||
console.error(`[SshSuspendService ERROR] channel.on('error') for suspendSessionId=${suspendSessionId}:`, err);
|
||||
handleSessionTermination('channel errored');
|
||||
});
|
||||
channel.on('end', () => {
|
||||
console.log(`[SshSuspendService DEBUG] channel.on('end') triggered for suspendSessionId=${suspendSessionId}`);
|
||||
handleSessionTermination('channel ended');
|
||||
});
|
||||
channel.on('exit', (code: number | null, signalName: string | null) => {
|
||||
console.log(`[SshSuspendService DEBUG] channel.on('exit') triggered for suspendSessionId=${suspendSessionId}. Code: ${code}, Signal: ${signalName}`);
|
||||
handleSessionTermination(`channel exited with code ${code}, signal ${signalName}`);
|
||||
});
|
||||
|
||||
sshClient.on('error', (err: Error) => {
|
||||
console.error(`[SshSuspendService ERROR] sshClient.on('error') for suspendSessionId=${suspendSessionId}:`, err);
|
||||
handleSessionTermination('client errored');
|
||||
});
|
||||
sshClient.on('end', () => {
|
||||
console.log(`[SshSuspendService DEBUG] sshClient.on('end') triggered for suspendSessionId=${suspendSessionId}`);
|
||||
handleSessionTermination('client ended');
|
||||
});
|
||||
|
||||
return suspendSessionId;
|
||||
}
|
||||
|
||||
private removeChannelListeners(channel: Channel, sshClient: Client): void {
|
||||
channel.removeAllListeners('data');
|
||||
channel.removeAllListeners('close');
|
||||
channel.removeAllListeners('error');
|
||||
channel.removeAllListeners('end');
|
||||
channel.removeAllListeners('exit');
|
||||
sshClient.removeAllListeners('error');
|
||||
sshClient.removeAllListeners('end');
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出指定用户的所有挂起会话(包括活跃和已断开的)。
|
||||
* 目前主要从内存中获取信息。
|
||||
* @param userId 用户ID。
|
||||
* @returns Promise<SuspendedSessionInfo[]> 挂起会话信息的数组。
|
||||
*/
|
||||
async listSuspendedSessions(userId: number): Promise<SuspendedSessionInfo[]> {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const sessionsInfo: SuspendedSessionInfo[] = [];
|
||||
|
||||
for (const [suspendSessionId, details] of userSessions.entries()) {
|
||||
sessionsInfo.push({
|
||||
suspendSessionId,
|
||||
connectionName: details.connectionName,
|
||||
connectionId: details.connectionId,
|
||||
suspendStartTime: details.suspendStartTime,
|
||||
customSuspendName: details.customSuspendName,
|
||||
backendSshStatus: details.backendSshStatus,
|
||||
disconnectionTimestamp: details.disconnectionTimestamp,
|
||||
});
|
||||
}
|
||||
// TODO: 增强此方法以从日志目录恢复 'disconnected_by_backend' 的会话状态,
|
||||
// 这需要日志文件包含元数据。
|
||||
return sessionsInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复指定的挂起会话。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要恢复的挂起会话ID。
|
||||
* @returns Promise<{ sshClient: Client; channel: ClientChannel; logData: string; connectionName: string; originalConnectionId: string; } | null> 恢复成功则返回客户端、通道、日志数据、连接名和原始连接ID,否则返回null。
|
||||
*/
|
||||
async resumeSession(userId: number, suspendSessionId: string): Promise<{ sshClient: Client; channel: ClientChannel; logData: string; connectionName: string; originalConnectionId: string; } | null> {
|
||||
// console.log(`[SshSuspendService][用户: ${userId}] resumeSession 调用,suspendSessionId: ${suspendSessionId}`);
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session) {
|
||||
// console.warn(`[SshSuspendService][用户: ${userId}] resumeSession: 未找到挂起的会话 ${suspendSessionId}。`);
|
||||
return null;
|
||||
}
|
||||
// console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 找到会话 ${suspendSessionId},状态: ${session.backendSshStatus}`);
|
||||
|
||||
if (session.backendSshStatus !== 'hanging') {
|
||||
// console.warn(`[SshSuspendService][用户: ${userId}] resumeSession: 会话 ${suspendSessionId} 状态不为 'hanging' (当前: ${session.backendSshStatus}),无法恢复。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 停止监听旧通道事件
|
||||
this.removeChannelListeners(session.channel, session.sshClient);
|
||||
// console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已移除会话 ${suspendSessionId} 的旧监听器。`);
|
||||
|
||||
let logData = '';
|
||||
try {
|
||||
// 使用 session.tempLogPath (即 logIdentifier, 基于 originalSessionId) 来读取日志
|
||||
logData = await this.logStorageService.readLog(session.tempLogPath);
|
||||
console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 的数据,长度: ${logData.length}`);
|
||||
} catch (error) {
|
||||
// console.error(`[SshSuspendService][用户: ${userId}] resumeSession: 读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 失败:`, error);
|
||||
// 根据策略,读取日志失败可能也应该导致恢复失败
|
||||
return null;
|
||||
}
|
||||
|
||||
// 在从 userSessions 删除会话之前,保存需要返回的会话详细信息
|
||||
const { sshClient, channel, connectionName, connectionId: originalConnectionId } = session;
|
||||
|
||||
userSessions.delete(suspendSessionId);
|
||||
// console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已从内存中删除挂起会话 ${suspendSessionId} 的记录。`);
|
||||
try {
|
||||
// 删除以 session.tempLogPath (logIdentifier) 命名的日志文件
|
||||
await this.logStorageService.deleteLog(session.tempLogPath);
|
||||
// console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 已删除挂起会话 ${suspendSessionId} 的日志文件 (路径: ${session.tempLogPath})。`);
|
||||
} catch (error) {
|
||||
// console.warn(`[SshSuspendService][用户: ${userId}] resumeSession: 删除挂起会话 ${suspendSessionId} 的日志文件 (路径: ${session.tempLogPath}) 失败:`, error);
|
||||
// 日志删除失败不应阻止恢复流程继续
|
||||
}
|
||||
|
||||
// console.log(`[SshSuspendService][用户: ${userId}] resumeSession: 挂起会话 ${suspendSessionId} 准备返回恢复数据。`);
|
||||
return {
|
||||
sshClient,
|
||||
channel,
|
||||
logData,
|
||||
connectionName,
|
||||
originalConnectionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 终止一个活跃的挂起会话。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要终止的挂起会话ID。
|
||||
* @returns Promise<boolean> 操作是否成功。
|
||||
*/
|
||||
async terminateSuspendedSession(userId: number, suspendSessionId: string): Promise<boolean> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session || session.backendSshStatus !== 'hanging') {
|
||||
console.warn(`[用户: ${userId}] 尝试终止的会话 ${suspendSessionId} 不存在或不是活跃状态 (${session?.backendSshStatus})。`);
|
||||
// 如果会话已断开,但记录还在,也应该能被“终止”(即移除)
|
||||
if(session && session.backendSshStatus === 'disconnected_by_backend'){
|
||||
const logPathToDelete = session.tempLogPath; // 获取正确的日志路径
|
||||
userSessions.delete(suspendSessionId);
|
||||
await this.logStorageService.deleteLog(logPathToDelete);
|
||||
console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} (日志: ${logPathToDelete}) 已通过终止操作移除。`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeChannelListeners(session.channel, session.sshClient);
|
||||
|
||||
try {
|
||||
session.channel.close(); // 尝试优雅关闭
|
||||
} catch (e) {
|
||||
console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭channel时出错:`, e);
|
||||
}
|
||||
try {
|
||||
session.sshClient.end(); // 尝试优雅关闭
|
||||
} catch (e) {
|
||||
console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭sshClient时出错:`, e);
|
||||
}
|
||||
|
||||
const logPathToFinallyDelete = session.tempLogPath; // 获取正确的日志路径
|
||||
userSessions.delete(suspendSessionId);
|
||||
await this.logStorageService.deleteLog(logPathToFinallyDelete);
|
||||
|
||||
console.log(`[用户: ${userId}] 活跃的挂起会话 ${suspendSessionId} (日志: ${logPathToFinallyDelete}) 已成功终止并移除。`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个已断开的挂起会话条目。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要移除的挂起会话ID。
|
||||
* @returns Promise<boolean> 操作是否成功。
|
||||
*/
|
||||
async removeDisconnectedSessionEntry(userId: number, suspendSessionId: string): Promise<boolean> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (session && session.backendSshStatus === 'hanging') {
|
||||
console.warn(`[用户: ${userId}] 尝试移除的会话 ${suspendSessionId} 仍处于活跃状态,请先终止。`);
|
||||
return false; // 不允许直接移除活跃会话,应先终止
|
||||
}
|
||||
|
||||
// 如果会话在内存中(不论状态),则删除
|
||||
if (session) {
|
||||
userSessions.delete(suspendSessionId);
|
||||
}
|
||||
|
||||
// 总是尝试删除日志文件,因为它可能对应一个已不在内存中的断开会话
|
||||
try {
|
||||
// suspendSessionId 在这里是用户从UI上选择的,可能在内存中,也可能不在 (只剩日志文件)
|
||||
// 如果在内存中,session.tempLogPath 是正确的日志标识符
|
||||
// 如果不在内存中,suspendSessionId 本身可能就是日志文件名 (如果之前设计是这样的话,但现在统一用 originalSessionId 作为日志名基础)
|
||||
// 假设 remove 请求中的 suspendSessionId 就是我们存储的那个挂起ID
|
||||
const logPathToRemove = session ? session.tempLogPath : suspendSessionId; // 如果 session 不在内存,尝试直接用 suspendSessionId 作为日志文件名部分
|
||||
await this.logStorageService.deleteLog(logPathToRemove);
|
||||
console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 的日志 (标识: ${logPathToRemove}) 已删除 (内存中状态: ${session ? session.backendSshStatus : '不在内存'})。`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[用户: ${userId}] 删除会话 ${suspendSessionId} 的日志文件失败:`, error);
|
||||
// 即便日志删除失败,如果内存条目已删,也算部分成功。但严格来说应返回false。
|
||||
// 如果 session 不在内存中,但日志删除成功,也算成功。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑挂起会话的自定义名称。
|
||||
* 目前仅更新内存中的名称。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 挂起会话ID。
|
||||
* @param newCustomName 新的自定义名称。
|
||||
* @returns Promise<boolean> 操作是否成功。
|
||||
*/
|
||||
async editSuspendedSessionName(userId: number, suspendSessionId: string, newCustomName: string): Promise<boolean> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.warn(`[用户: ${userId}] 尝试编辑名称的会话 ${suspendSessionId} 不存在。`);
|
||||
return false;
|
||||
}
|
||||
|
||||
session.customSuspendName = newCustomName;
|
||||
console.log(`[用户: ${userId}] 挂起会话 ${suspendSessionId} 的自定义名称已更新为: ${newCustomName}`);
|
||||
// TODO: 如果设计要求将自定义名称持久化到日志文件的元数据部分,
|
||||
// 此处需要添加更新日志文件的逻辑。这可能涉及读取、修改元数据、然后重写文件。
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理特定会话的 SSH 连接意外断开。
|
||||
* 此方法主要由内部事件监听器调用。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 发生断开的会话ID。
|
||||
*/
|
||||
public handleUnexpectedDisconnection(userId: number, suspendSessionId: string): void { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (session && session.backendSshStatus === 'hanging') {
|
||||
const reason = 'Unexpected disconnection handled by SshSuspendService.';
|
||||
session.backendSshStatus = 'disconnected_by_backend';
|
||||
session.disconnectionTimestamp = new Date().toISOString();
|
||||
this.removeChannelListeners(session.channel, session.sshClient); // 移除监听器
|
||||
console.log(`[用户: ${userId}] 会话 ${suspendSessionId} 状态更新为 'disconnected_by_backend'。原因: ${reason}`);
|
||||
|
||||
this.emit('sessionAutoTerminated', {
|
||||
userId: session.userId,
|
||||
suspendSessionId,
|
||||
reason
|
||||
});
|
||||
// 确保所有已缓冲的日志已尝试写入 (通常由 'data' 事件处理,这里是最终状态确认)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定挂起会话的日志内容。
|
||||
* 允许导出 'disconnected_by_backend' 和 'hanging' 状态的会话日志。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要导出日志的挂起会话ID。
|
||||
* @returns Promise<{ content: string, filename: string } | null> 日志内容和建议的文件名,如果会话不符合条件或读取失败则返回null。
|
||||
*/
|
||||
async getSessionLogContent(userId: number, suspendSessionId: string): Promise<{ content: string, filename: string } | null> {
|
||||
console.log(`[SshSuspendService][用户: ${userId}] getSessionLogContent 调用,suspendSessionId: ${suspendSessionId}`);
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.warn(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 未找到挂起的会话 ${suspendSessionId}。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.backendSshStatus !== 'disconnected_by_backend' && session.backendSshStatus !== 'hanging') {
|
||||
console.warn(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 会话 ${suspendSessionId} 状态为 ${session.backendSshStatus},不符合导出条件 (需要 'disconnected_by_backend' 或 'hanging')。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session.tempLogPath) {
|
||||
console.error(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 会话 ${suspendSessionId} 缺少 tempLogPath。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const logContent = await this.logStorageService.readLog(session.tempLogPath);
|
||||
console.log(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 已读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 的数据,长度: ${logContent.length}`);
|
||||
|
||||
const baseName = session.customSuspendName || session.connectionName || suspendSessionId.substring(0,8);
|
||||
const safeBaseName = baseName.replace(/[^\w.-]/g, '_'); // 替换掉不安全字符为空格或下划线
|
||||
const timestamp = new Date(session.suspendStartTime).toISOString().replace(/[:.]/g, '-');
|
||||
// tempLogPath 通常是 originalSessionId
|
||||
const filename = `ssh_log_${safeBaseName}_${session.tempLogPath}_${timestamp}.log`;
|
||||
|
||||
return { content: logContent, filename };
|
||||
} catch (error) {
|
||||
console.error(`[SshSuspendService][用户: ${userId}] getSessionLogContent: 读取挂起会话 ${suspendSessionId} (日志: ${session.tempLogPath}) 失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式导出
|
||||
export const sshSuspendService = new SshSuspendService();
|
||||
@@ -1,210 +0,0 @@
|
||||
import * as SshKeyRepository from '../ssh_keys/ssh_key.repository';
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
import { SshKeyDbRow, CreateSshKeyData, UpdateSshKeyData } from '../ssh_keys/ssh_key.repository';
|
||||
|
||||
// 定义 Service 层返回给 Controller 的基本密钥信息 (不含加密内容)
|
||||
export interface SshKeyBasicInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 定义 Service 层创建密钥时的输入类型
|
||||
export interface CreateSshKeyInput {
|
||||
name: string;
|
||||
private_key: string; // 明文私钥
|
||||
passphrase?: string; // 明文密码短语 (可选)
|
||||
}
|
||||
|
||||
// 定义 Service 层更新密钥时的输入类型 (名称必选,凭证可选)
|
||||
export interface UpdateSshKeyInput {
|
||||
name?: string; // 名称可选,但通常会提供
|
||||
private_key?: string; // 明文私钥 (可选,表示要更新)
|
||||
passphrase?: string; // 明文密码短语 (可选,如果提供了私钥,则此项也可能需要更新)
|
||||
}
|
||||
|
||||
// 定义包含解密后凭证的密钥详情
|
||||
export interface DecryptedSshKeyDetails extends SshKeyBasicInfo {
|
||||
privateKey: string; // 解密后的私钥
|
||||
passphrase?: string; // 解密后的密码短语
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建新的 SSH 密钥
|
||||
* @param input 包含名称和明文凭证的对象
|
||||
* @returns Promise<SshKeyBasicInfo> 新创建密钥的基本信息
|
||||
*/
|
||||
export const createSshKey = async (input: CreateSshKeyInput): Promise<SshKeyBasicInfo> => {
|
||||
// 1. 验证输入
|
||||
if (!input.name || !input.private_key) {
|
||||
throw new Error('必须提供密钥名称和私钥内容。');
|
||||
}
|
||||
// 可选:添加更严格的私钥格式验证
|
||||
|
||||
// 2. 加密凭证
|
||||
const encrypted_private_key = encrypt(input.private_key);
|
||||
const encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
|
||||
// 3. 准备仓库数据
|
||||
const dataToSave: CreateSshKeyData = {
|
||||
name: input.name,
|
||||
encrypted_private_key,
|
||||
encrypted_passphrase,
|
||||
};
|
||||
|
||||
// 4. 调用仓库创建记录
|
||||
try {
|
||||
const newId = await SshKeyRepository.createSshKey(dataToSave);
|
||||
return { id: newId, name: input.name };
|
||||
} catch (error: any) {
|
||||
// 处理可能的 UNIQUE constraint 错误
|
||||
if (error.message && error.message.includes('UNIQUE constraint failed: ssh_keys.name')) {
|
||||
throw new Error(`SSH 密钥名称 "${input.name}" 已存在。`);
|
||||
}
|
||||
throw error; // 重新抛出其他错误
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有 SSH 密钥的基本信息 (ID 和 Name)
|
||||
* @returns Promise<SshKeyBasicInfo[]> 密钥列表
|
||||
*/
|
||||
export const getAllSshKeyNames = async (): Promise<SshKeyBasicInfo[]> => {
|
||||
return SshKeyRepository.findAllSshKeyNames();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取 SSH 密钥的完整数据库行 (包含加密凭证)
|
||||
* 供内部服务使用,例如需要解密的场景
|
||||
* @param id 密钥 ID
|
||||
* @returns Promise<SshKeyDbRow | null> 密钥数据库行或 null
|
||||
*/
|
||||
export const getSshKeyDbRowById = async (id: number): Promise<SshKeyDbRow | null> => {
|
||||
return SshKeyRepository.findSshKeyById(id);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 根据 ID 获取解密后的 SSH 密钥详情
|
||||
* @param id 密钥 ID
|
||||
* @returns Promise<DecryptedSshKeyDetails | null> 解密后的密钥详情或 null
|
||||
*/
|
||||
export const getDecryptedSshKeyById = async (id: number): Promise<DecryptedSshKeyDetails | null> => {
|
||||
const dbRow = await SshKeyRepository.findSshKeyById(id);
|
||||
if (!dbRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKey = decrypt(dbRow.encrypted_private_key);
|
||||
const passphrase = dbRow.encrypted_passphrase ? decrypt(dbRow.encrypted_passphrase) : undefined;
|
||||
return {
|
||||
id: dbRow.id,
|
||||
name: dbRow.name,
|
||||
privateKey,
|
||||
passphrase,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`Service: 解密 SSH 密钥 ${id} 失败:`, error);
|
||||
// 根据策略决定是抛出错误还是返回 null/部分信息
|
||||
throw new Error(`解密 SSH 密钥 ${id} 失败。`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 更新 SSH 密钥
|
||||
* @param id 要更新的密钥 ID
|
||||
* @param input 包含要更新字段的对象 (明文凭证)
|
||||
* @returns Promise<SshKeyBasicInfo | null> 更新后的密钥基本信息或 null (如果未找到)
|
||||
*/
|
||||
export const updateSshKey = async (id: number, input: UpdateSshKeyInput): Promise<SshKeyBasicInfo | null> => {
|
||||
// 1. 检查密钥是否存在
|
||||
const existingKey = await SshKeyRepository.findSshKeyById(id);
|
||||
if (!existingKey) {
|
||||
return null; // 未找到
|
||||
}
|
||||
|
||||
// 2. 准备要更新的数据
|
||||
const dataToUpdate: UpdateSshKeyData = {};
|
||||
let finalName = existingKey.name; // 保留现有名称,除非输入中提供了新名称
|
||||
|
||||
if (input.name !== undefined) {
|
||||
if (!input.name) {
|
||||
throw new Error('密钥名称不能为空。');
|
||||
}
|
||||
dataToUpdate.name = input.name;
|
||||
finalName = input.name; // 更新最终名称
|
||||
}
|
||||
|
||||
// 只有当提供了新的私钥时,才更新私钥和密码短语
|
||||
if (input.private_key !== undefined) {
|
||||
if (!input.private_key) {
|
||||
throw new Error('私钥内容不能为空。');
|
||||
}
|
||||
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
|
||||
// 如果更新了私钥,则密码短语也需要更新(即使是设为 null)
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
} else if (input.passphrase !== undefined && existingKey.encrypted_private_key) {
|
||||
// 如果只提供了密码短语,且当前存在私钥,则只更新密码短语
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
}
|
||||
|
||||
|
||||
// 3. 如果有数据需要更新,则调用仓库
|
||||
if (Object.keys(dataToUpdate).length > 0) {
|
||||
try {
|
||||
const updated = await SshKeyRepository.updateSshKey(id, dataToUpdate);
|
||||
if (!updated) {
|
||||
// 理论上不应发生,因为我们已经检查过存在性
|
||||
throw new Error('更新 SSH 密钥记录失败。');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 处理可能的 UNIQUE constraint 错误
|
||||
if (error.message && error.message.includes('UNIQUE constraint failed: ssh_keys.name')) {
|
||||
throw new Error(`SSH 密钥名称 "${input.name}" 已存在。`);
|
||||
}
|
||||
throw error; // 重新抛出其他错误
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 返回更新后的基本信息
|
||||
return { id: id, name: finalName };
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除 SSH 密钥
|
||||
* @param id 要删除的密钥 ID
|
||||
* @returns Promise<boolean> 是否删除成功
|
||||
*/
|
||||
export const deleteSshKey = async (id: number): Promise<boolean> => {
|
||||
// 注意:删除密钥前,相关的 connections 表中的 ssh_key_id 会被设为 NULL (ON DELETE SET NULL)
|
||||
return SshKeyRepository.deleteSshKey(id);
|
||||
};
|
||||
/**
|
||||
* 获取所有解密后的 SSH 密钥详情
|
||||
* @returns Promise<DecryptedSshKeyDetails[]> 解密后的密钥详情列表
|
||||
*/
|
||||
export const getAllDecryptedSshKeys = async (): Promise<DecryptedSshKeyDetails[]> => {
|
||||
const dbRows = await SshKeyRepository.findAllSshKeys();
|
||||
const decryptedKeys: DecryptedSshKeyDetails[] = [];
|
||||
|
||||
for (const dbRow of dbRows) {
|
||||
try {
|
||||
const privateKey = decrypt(dbRow.encrypted_private_key);
|
||||
const passphrase = dbRow.encrypted_passphrase ? decrypt(dbRow.encrypted_passphrase) : undefined;
|
||||
decryptedKeys.push({
|
||||
id: dbRow.id,
|
||||
name: dbRow.name,
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Service: 解密 SSH 密钥 ${dbRow.id} 失败:`, error);
|
||||
// 继续处理其他密钥,不因单个密钥解密失败而中断整个过程
|
||||
// 可以选择记录错误或通知管理员,但这里我们只记录日志
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedKeys;
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as TagRepository from '../tags/tag.repository';
|
||||
|
||||
// Re-export or define types
|
||||
export interface TagData extends TagRepository.TagData {}
|
||||
|
||||
/**
|
||||
* 获取所有标签
|
||||
*/
|
||||
export const getAllTags = async (): Promise<TagData[]> => {
|
||||
return TagRepository.findAllTags();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个标签
|
||||
*/
|
||||
export const getTagById = async (id: number): Promise<TagData | null> => {
|
||||
return TagRepository.findTagById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新标签
|
||||
*/
|
||||
export const createTag = async (name: string): Promise<TagData> => {
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error('标签名称不能为空。');
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
|
||||
|
||||
try {
|
||||
const newTagId = await TagRepository.createTag(trimmedName);
|
||||
const newTag = await getTagById(newTagId);
|
||||
if (!newTag) {
|
||||
throw new Error('创建标签后无法检索到该标签。');
|
||||
}
|
||||
return newTag;
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`创建标签失败:标签名称 "${trimmedName}" 已存在。`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新标签名称
|
||||
*/
|
||||
export const updateTag = async (id: number, name: string): Promise<TagData | null> => {
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error('标签名称不能为空。');
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
|
||||
|
||||
try {
|
||||
const updated = await TagRepository.updateTag(id, trimmedName);
|
||||
if (!updated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getTagById(id);
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`更新标签失败:标签名称 "${trimmedName}" 已存在。`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除标签
|
||||
*/
|
||||
export const deleteTag = async (id: number): Promise<boolean> => {
|
||||
return TagRepository.deleteTag(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新标签与连接的关联关系
|
||||
*/
|
||||
export const updateTagConnections = async (tagId: number, connectionIds: number[]): Promise<void> => {
|
||||
// 在服务层可以添加额外的业务逻辑,例如验证 tagId 和 connectionIds 的有效性
|
||||
// 例如,检查标签是否存在,连接 ID 是否都存在于数据库中等。
|
||||
// 此处为简化,直接调用 repository 方法。
|
||||
|
||||
// 确保 connectionIds 是一个数组,即使是空数组
|
||||
const idsToUpdate = Array.isArray(connectionIds) ? connectionIds : [];
|
||||
|
||||
try {
|
||||
await TagRepository.updateTagConnections(tagId, idsToUpdate);
|
||||
} catch (error: any) {
|
||||
// 服务层可以进一步处理或包装错误
|
||||
console.error(`Service: 更新标签 ${tagId} 的连接关联时发生错误:`, error.message);
|
||||
throw new Error(`服务层更新标签连接关联失败: ${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const MAX_LOG_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
const LOG_DIRECTORY = './data/temp_suspended_ssh_logs/';
|
||||
|
||||
/**
|
||||
* TemporaryLogStorageService负责管理临时日志文件的原子化读、写、删除及轮替操作。
|
||||
*/
|
||||
export class TemporaryLogStorageService {
|
||||
constructor() {
|
||||
this.ensureLogDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保日志目录存在,如果不存在则创建它。
|
||||
*/
|
||||
async ensureLogDirectoryExists(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(LOG_DIRECTORY, { recursive: true });
|
||||
// console.log(`日志目录 '${LOG_DIRECTORY}' 已确保存在。`);
|
||||
} catch (error) {
|
||||
console.error(`创建日志目录 '${LOG_DIRECTORY}' 失败:`, error);
|
||||
// 在实际应用中,这里可能需要更健壮的错误处理
|
||||
}
|
||||
}
|
||||
|
||||
private getLogFilePath(suspendSessionId: string): string {
|
||||
return path.join(LOG_DIRECTORY, `${suspendSessionId}.log`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据写入指定挂起会话的日志文件。
|
||||
* 如果文件大小超过MAX_LOG_SIZE_BYTES,将采取轮替策略(清空并从头开始写)。
|
||||
* @param suspendSessionId - 挂起会话的ID。
|
||||
* @param data - 要写入的数据。
|
||||
*/
|
||||
async writeToLog(suspendSessionId: string, data: string): Promise<void> {
|
||||
const filePath = this.getLogFilePath(suspendSessionId);
|
||||
try {
|
||||
await this.ensureLogDirectoryExists(); // 确保目录存在
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(filePath);
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
}
|
||||
// 文件不存在,是正常情况,后续会创建
|
||||
}
|
||||
|
||||
if (stat && stat.size >= MAX_LOG_SIZE_BYTES) {
|
||||
// 文件过大,执行轮替策略:清空文件
|
||||
console.log(`日志文件 '${filePath}' 大小达到 ${MAX_LOG_SIZE_BYTES / (1024 * 1024)}MB,执行轮替(清空)。`);
|
||||
await fs.writeFile(filePath, data, 'utf8'); // 清空并写入新数据
|
||||
} else {
|
||||
await fs.appendFile(filePath, data, 'utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`写入日志文件 '${filePath}' 失败:`, error);
|
||||
throw error; // 重新抛出错误,让调用者处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定挂起会话的日志文件内容。
|
||||
* @param suspendSessionId - 挂起会话的ID。
|
||||
* @returns 返回日志文件的内容。如果文件不存在,则返回空字符串。
|
||||
*/
|
||||
async readLog(suspendSessionId: string): Promise<string> {
|
||||
const filePath = this.getLogFilePath(suspendSessionId);
|
||||
try {
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// console.log(`日志文件 '${filePath}' 不存在,返回空内容。`);
|
||||
return ''; // 文件不存在,通常意味着没有日志
|
||||
}
|
||||
console.error(`读取日志文件 '${filePath}' 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定挂起会话的日志文件。
|
||||
* @param suspendSessionId - 挂起会话的ID。
|
||||
*/
|
||||
async deleteLog(suspendSessionId: string): Promise<void> {
|
||||
const filePath = this.getLogFilePath(suspendSessionId);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
// console.log(`日志文件 '${filePath}' 已成功删除。`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// console.warn(`尝试删除日志文件 '${filePath}' 时发现文件已不存在,操作忽略。`);
|
||||
return; // 文件不存在,无需操作
|
||||
}
|
||||
console.error(`删除日志文件 '${filePath}' 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出日志目录中的所有日志文件名(不含扩展名,即suspendSessionId)。
|
||||
* 这可以用于 `SshSuspendService` 初始化时加载已断开的会话。
|
||||
* @returns 返回包含所有 suspendSessionId 的数组。
|
||||
*/
|
||||
async listLogFiles(): Promise<string[]> {
|
||||
try {
|
||||
await this.ensureLogDirectoryExists();
|
||||
const files = await fs.readdir(LOG_DIRECTORY);
|
||||
return files
|
||||
.filter(file => file.endsWith('.log'))
|
||||
.map(file => file.replace(/\.log$/, ''));
|
||||
} catch (error) {
|
||||
console.error(`列出日志目录 '${LOG_DIRECTORY}' 中的文件失败:`, error);
|
||||
return []; // 发生错误时返回空数组
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式导出
|
||||
export const temporaryLogStorageService = new TemporaryLogStorageService();
|
||||
@@ -1,86 +0,0 @@
|
||||
import * as terminalThemeRepository from '../terminal-themes/terminal-theme.repository';
|
||||
import { TerminalTheme, CreateTerminalThemeDto, UpdateTerminalThemeDto } from '../types/terminal-theme.types';
|
||||
import type { ITheme } from 'xterm';
|
||||
|
||||
/**
|
||||
* 获取所有终端主题
|
||||
* @returns Promise<TerminalTheme[]>
|
||||
*/
|
||||
export const getAllThemes = async (): Promise<TerminalTheme[]> => {
|
||||
return terminalThemeRepository.findAllThemes();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单个终端主题
|
||||
* @param id 主题 ID (SQLite 数字 ID)
|
||||
* @returns Promise<TerminalTheme | null>
|
||||
*/
|
||||
export const getThemeById = async (id: number): Promise<TerminalTheme | null> => {
|
||||
if (isNaN(id)) {
|
||||
throw new Error('无效的主题 ID');
|
||||
}
|
||||
return terminalThemeRepository.findThemeById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新终端主题
|
||||
* @param themeDto 创建数据
|
||||
* @returns Promise<TerminalTheme>
|
||||
*/
|
||||
export const createNewTheme = async (themeDto: CreateTerminalThemeDto): Promise<TerminalTheme> => {
|
||||
// 移除验证相关的注释
|
||||
// 简单验证 themeData 结构 (确保基本字段存在)
|
||||
if (!themeDto.themeData || typeof themeDto.themeData.background !== 'string' || typeof themeDto.themeData.foreground !== 'string') {
|
||||
throw new Error('无效的主题数据格式');
|
||||
}
|
||||
|
||||
return terminalThemeRepository.createTheme(themeDto);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新终端主题
|
||||
* @param id 主题 ID (SQLite 数字 ID)
|
||||
* @param themeDto 更新数据
|
||||
* @returns Promise<boolean> 是否成功更新
|
||||
*/
|
||||
export const updateExistingTheme = async (id: number, themeDto: UpdateTerminalThemeDto): Promise<boolean> => {
|
||||
if (isNaN(id)) {
|
||||
throw new Error('无效的主题 ID');
|
||||
}
|
||||
// 可选:验证 themeDto
|
||||
if (!themeDto.name || !themeDto.themeData || typeof themeDto.themeData.background !== 'string' || typeof themeDto.themeData.foreground !== 'string') {
|
||||
throw new Error('无效的主题更新数据');
|
||||
}
|
||||
return terminalThemeRepository.updateTheme(id, themeDto);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除终端主题
|
||||
* @param id 主题 ID (SQLite 数字 ID)
|
||||
* @returns Promise<boolean> 是否成功删除
|
||||
*/
|
||||
export const deleteExistingTheme = async (id: number): Promise<boolean> => {
|
||||
if (isNaN(id)) {
|
||||
throw new Error('无效的主题 ID');
|
||||
}
|
||||
return terminalThemeRepository.deleteTheme(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入终端主题
|
||||
* @param themeData 主题数据对象 (ITheme)
|
||||
* @param name 主题名称
|
||||
* @returns Promise<TerminalTheme>
|
||||
*/
|
||||
export const importTheme = async (themeData: ITheme, name: string): Promise<TerminalTheme> => {
|
||||
if (!name) {
|
||||
throw new Error('导入主题时必须提供名称');
|
||||
}
|
||||
// 验证导入的数据结构是否符合 ITheme (简化验证)
|
||||
if (typeof themeData.background !== 'string' || typeof themeData.foreground !== 'string') {
|
||||
throw new Error('导入的主题数据格式无效');
|
||||
}
|
||||
const dto: CreateTerminalThemeDto = { name, themeData };
|
||||
return createNewTheme(dto);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user