diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 9ec5468..5603ae7 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -62,7 +62,7 @@ export const login = async (req: Request, res: Response): Promise => { const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; ipBlacklistService.recordFailedAttempt(clientIp); auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); - notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); + // notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); // 保留原有调用,因为这里已经有了 res.status(401).json({ message: 'CAPTCHA 验证失败。' }); return; } @@ -89,8 +89,8 @@ export const login = async (req: Request, res: Response): Promise => { ipBlacklistService.recordFailedAttempt(clientIp); // 记录审计日志 (添加 IP) auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp }); - // 发送登录失败通知 - notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp }); + // 发送登录失败通知 (保留原有调用) + // notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp }); res.status(401).json({ message: '无效的凭据。' }); return; } @@ -104,8 +104,8 @@ export const login = async (req: Request, res: Response): Promise => { ipBlacklistService.recordFailedAttempt(clientIp); // 记录审计日志 (添加 IP) auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); - // 发送登录失败通知 - notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); + // 发送登录失败通知 (保留原有调用) + // notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); res.status(401).json({ message: '无效的凭据。' }); return; } @@ -126,6 +126,7 @@ export const login = async (req: Request, res: Response): Promise => { ipBlacklistService.resetAttempts(clientIp); // 记录审计日志 (添加 IP) auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp }); + notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp }); // 添加通知调用 req.session.userId = user.id; req.session.username = user.username; req.session.requiresTwoFactor = false; // 明确标记不需要 2FA @@ -235,6 +236,7 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise ipBlacklistService.resetAttempts(clientIp); // 记录审计日志 (2FA 成功也算登录成功) (添加 IP) auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true }); + notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true }); // 添加通知调用 // 验证成功,建立完整会话 req.session.username = user.username; req.session.requiresTwoFactor = false; // 标记 2FA 已完成 @@ -261,6 +263,7 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise ipBlacklistService.recordFailedAttempt(clientIp); // 记录审计日志 (添加 IP) auditLogService.logAction('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp }); + notificationService.sendNotification('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp }); // 添加通知调用 res.status(401).json({ message: '验证码无效。' }); } @@ -336,6 +339,7 @@ export const changePassword = async (req: Request, res: Response): Promise const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP // 记录审计日志 (添加 IP) auditLogService.logAction('PASSWORD_CHANGED', { userId, ip: clientIp }); + notificationService.sendNotification('PASSWORD_CHANGED', { userId, ip: clientIp }); // 添加通知调用 res.status(200).json({ message: '密码已成功修改。' }); @@ -470,6 +474,7 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr // 记录审计日志 (添加 IP) const regInfo: any = verification.registrationInfo; auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp }); + notificationService.sendNotification('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp }); // 添加通知调用 res.status(201).json({ message: 'Passkey 注册成功!', verified: true }); } else { console.error(`用户 ${userId} Passkey 注册验证失败:`, verification); @@ -531,6 +536,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 记录审计日志 (添加 IP) auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp }); + notificationService.sendNotification('2FA_ENABLED', { userId, ip: clientIp }); // 添加通知调用 // 清除 session 中的临时密钥 delete req.session.tempTwoFactorSecret; @@ -594,6 +600,7 @@ export const disable2FA = async (req: Request, res: Response): Promise => const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 记录审计日志 (添加 IP) auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp }); + notificationService.sendNotification('2FA_DISABLED', { userId, ip: clientIp }); // 添加通知调用 res.status(200).json({ message: '两步验证已成功禁用。' }); @@ -679,6 +686,7 @@ export const setupAdmin = async (req: Request, res: Response): Promise => const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 记录审计日志 (添加 IP) auditLogService.logAction('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp }); + notificationService.sendNotification('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp }); // 添加通知调用 res.status(201).json({ message: '初始管理员账号创建成功!' }); @@ -708,6 +716,7 @@ export const logout = (req: Request, res: Response): void => { if (userId) { // 仅在能获取到 userId 时记录 const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; auditLogService.logAction('LOGOUT', { userId, username, ip: clientIp }); + notificationService.sendNotification('LOGOUT', { userId, username, ip: clientIp }); // 添加通知调用 } res.status(200).json({ message: '已成功登出。' }); } diff --git a/packages/backend/src/locales/en-US/notifications.json b/packages/backend/src/locales/en-US/notifications.json index e1218e3..0b53627 100644 --- a/packages/backend/src/locales/en-US/notifications.json +++ b/packages/backend/src/locales/en-US/notifications.json @@ -1,19 +1,19 @@ { "testNotification": { - "subject": "Nexus Terminal Test Notification ({eventDisplay})", + "subject": "Nexus Terminal Test Notification ({event})", "email": { - "body": "This is a test email from Nexus Terminal for event '{{eventDisplay}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}", - "bodyHtml": "

This is a test email from Nexus Terminal for event '{{eventDisplay}}'.

If you received this, your SMTP configuration is working.

Timestamp: {{timestamp}}

" + "body": "This is a test email from Nexus Terminal for event '{{event}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}", + "bodyHtml": "

This is a test email from Nexus Terminal for event '{{event}}'.

If you received this, your SMTP configuration is working.

Timestamp: {{timestamp}}

" }, "webhook": { - "detailsMessage": "This is a test notification from Nexus Terminal (Webhook - i18n) for event '{{eventDisplay}}'." + "detailsMessage": "This is a test notification from Nexus Terminal (Webhook - i18n) for event '{{event}}'." }, "telegram": { - "detailsMessage": "This is a test notification from Nexus Terminal (Telegram - i18n) for event '{{eventDisplay}}'.", - "bodyTemplate": "*Nexus Terminal Test Notification*\nEvent: `{eventDisplay}`\nTimestamp: {timestamp}\nDetails:\n```\n{details}\n```" + "detailsMessage": "This is a test notification from Nexus Terminal (Telegram - i18n) for event '{{event}}'.", + "bodyTemplate": "*Nexus Terminal Test Notification*\nEvent: `{event}`\nTimestamp: {timestamp}\nDetails:\n```\n{details}\n```" } }, - "eventDisplay": { + "event": { "LOGIN_SUCCESS": "Login Success", "LOGIN_FAILURE": "Login Failure", "LOGOUT": "Logout", @@ -51,7 +51,7 @@ }, "eventBody": { - "SETTINGS_UPDATED": "Event: {{eventDisplay}}\nTimestamp: {{timestamp}}\nDetails:\n{{details}}" + "SETTINGS_UPDATED": "Event: {{event}}\nTimestamp: {{timestamp}}\nDetails:\n{{details}}" }, "connection": { "testSuccess": "Connection test successful for '{{name}}'!", diff --git a/packages/backend/src/locales/ja-JP/notifications.json b/packages/backend/src/locales/ja-JP/notifications.json index 7c5e4f4..1055d5f 100644 --- a/packages/backend/src/locales/ja-JP/notifications.json +++ b/packages/backend/src/locales/ja-JP/notifications.json @@ -1,19 +1,19 @@ { "testNotification": { - "subject": "星枢ターミナル テスト通知 ({eventDisplay})", + "subject": "星枢ターミナル テスト通知 ({event})", "email": { - "body": "これは、星枢ターミナルからのイベント'{{eventDisplay}}'に関するテストメールです。\n\nこのメールを受信した場合、SMTP 設定は正常に機能しています。\n\nタイムスタンプ: {{timestamp}}", - "bodyHtml": "

これは、星枢ターミナルからのイベント'{{eventDisplay}}'に関するテストメールです。

このメールを受信した場合、SMTP 設定は正常に機能しています。

タイムスタンプ: {{timestamp}}

" + "body": "これは、星枢ターミナルからのイベント'{{event}}'に関するテストメールです。\n\nこのメールを受信した場合、SMTP 設定は正常に機能しています。\n\nタイムスタンプ: {{timestamp}}", + "bodyHtml": "

これは、星枢ターミナルからのイベント'{{event}}'に関するテストメールです。

このメールを受信した場合、SMTP 設定は正常に機能しています。

タイムスタンプ: {{timestamp}}

" }, "webhook": { - "detailsMessage": "これは星枢ターミナルからのテスト通知 (Webhook - i18n) です。 イベント:'{{eventDisplay}}'。" + "detailsMessage": "これは星枢ターミナルからのテスト通知 (Webhook - i18n) です。 イベント:'{{event}}'。" }, "telegram": { - "detailsMessage": "これは星枢ターミナルからのテスト通知 (Telegram - i18n) です。 イベント:'{{eventDisplay}}'。", - "bodyTemplate": "*星枢ターミナル テスト通知*\nイベント: `{eventDisplay}`\nタイムスタンプ: {timestamp}\n詳細:\n```\n{details}\n```" + "detailsMessage": "これは星枢ターミナルからのテスト通知 (Telegram - i18n) です。 イベント:'{{event}}'。", + "bodyTemplate": "*星枢ターミナル テスト通知*\nイベント: `{event}`\nタイムスタンプ: {timestamp}\n詳細:\n```\n{details}\n```" } }, - "eventDisplay": { + "event": { "LOGIN_SUCCESS": "ログイン成功", "LOGIN_FAILURE": "ログイン失敗", "LOGOUT": "ログアウト", @@ -49,7 +49,7 @@ "ADMIN_SETUP_COMPLETE": "初期管理者設定完了" }, "eventBody": { - "SETTINGS_UPDATED": "イベント: {{eventDisplay}}\nタイムスタンプ: {{timestamp}}\n詳細:\n{{details}}" + "SETTINGS_UPDATED": "イベント: {{event}}\nタイムスタンプ: {{timestamp}}\n詳細:\n{{details}}" }, "connection": { "testSuccess": "接続 '{{name}}' のテストに成功しました!", diff --git a/packages/backend/src/locales/zh-CN/notifications.json b/packages/backend/src/locales/zh-CN/notifications.json index b9c71d9..3ffc2d6 100644 --- a/packages/backend/src/locales/zh-CN/notifications.json +++ b/packages/backend/src/locales/zh-CN/notifications.json @@ -1,19 +1,19 @@ { "testNotification": { - "subject": "星枢终端测试通知 ({eventDisplay})", + "subject": "星枢终端测试通知 ({event})", "email": { - "body": "这是一封来自星枢终端关于事件 '{{eventDisplay}}' 的测试邮件。\n\n如果您收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间戳: {{timestamp}}", - "bodyHtml": "

这是一封来自 星枢终端 关于事件 '{{eventDisplay}}' 的测试邮件。

如果您收到此邮件,表示您的 SMTP 配置工作正常。

时间戳: {{timestamp}}

" + "body": "这是一封来自星枢终端关于事件 '{{event}}' 的测试邮件。\n\n如果您收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间戳: {{timestamp}}", + "bodyHtml": "

这是一封来自 星枢终端 关于事件 '{{event}}' 的测试邮件。

如果您收到此邮件,表示您的 SMTP 配置工作正常。

时间戳: {{timestamp}}

" }, "webhook": { - "detailsMessage": "这是一条来自星枢终端的测试通知 (Webhook - i18n),事件:'{{eventDisplay}}'。" + "detailsMessage": "这是一条来自星枢终端的测试通知 (Webhook - i18n),事件:'{{event}}'。" }, "telegram": { - "detailsMessage": "这是一条来自星枢终端的测试通知 (Telegram - i18n),事件:'{{eventDisplay}}'。", - "bodyTemplate": "*星枢终端测试通知*\n事件: `{eventDisplay}`\n时间戳: {timestamp}\n详情:\n```\n{details}\n```" + "detailsMessage": "这是一条来自星枢终端的测试通知 (Telegram - i18n),事件:'{{event}}'。", + "bodyTemplate": "*星枢终端测试通知*\n事件: `{event}`\n时间戳: {timestamp}\n详情:\n```\n{details}\n```" } }, - "eventDisplay": { + "event": { "LOGIN_SUCCESS": "登录成功", "LOGIN_FAILURE": "登录失败", "LOGOUT": "登出", @@ -51,7 +51,7 @@ }, "eventBody": { - "SETTINGS_UPDATED": "事件: {{eventDisplay}}\n时间戳: {{timestamp}}\n详情:\n{{details}}" + "SETTINGS_UPDATED": "事件: {{event}}\n时间戳: {{timestamp}}\n详情:\n{{details}}" }, "connection": { "testSuccess": "连接 '{{name}}' 测试成功!", diff --git a/packages/backend/src/services/notification.service.ts b/packages/backend/src/services/notification.service.ts index 29e8ac7..1bb2db8 100644 --- a/packages/backend/src/services/notification.service.ts +++ b/packages/backend/src/services/notification.service.ts @@ -95,14 +95,14 @@ export class NotificationService { const transporter = nodemailer.createTransport(transporterOptions); - const eventDisplayName = i18next.t(`eventDisplay.SETTINGS_UPDATED`, { lng: userLang, defaultValue: 'SETTINGS_UPDATED' }); + 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 ({eventDisplay})', eventDisplay: eventDisplayName }), - text: i18next.t(testEmailBodyKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `This is a test email from Nexus Terminal for event '{{eventDisplay}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}`, eventDisplay: eventDisplayName }), - html: i18next.t(testEmailBodyHtmlKey, { lng: userLang, timestamp: new Date().toISOString(), defaultValue: `

This is a test email from Nexus Terminal for event '{{eventDisplay}}'.

If you received this, your SMTP configuration is working.

Timestamp: {{timestamp}}

`, eventDisplay: eventDisplayName }), + 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: `

This is a test email from Nexus Terminal for event '{{event}}'.

If you received this, your SMTP configuration is working.

Timestamp: {{timestamp}}

`, eventDisplay: eventDisplayName }), }; try { @@ -142,10 +142,22 @@ export class NotificationService { const translatedWebhookMessage = (typeof testPayload.details === 'object' && testPayload.details?.message) ? testPayload.details.message : 'Details 不是带有 message 属性的对象'; console.log(`[通知测试 - Webhook] 测试负载已创建。翻译后的 details.message:`, translatedWebhookMessage); - const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event }); + 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 {eventDisplay}, {timestamp}, {details}.`; - const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, testPayload, defaultBody, eventDisplayName); + const defaultBodyTemplate = `Default: JSON payload. Use {event}, {timestamp}, {details}.`; + // Prepare data object for _renderTemplate + const templateDataWebhookTest: Record = { + event: testPayload.event, + eventDisplay: eventDisplayName, // Assuming no markdown needed for webhook test + timestamp: new Date(testPayload.timestamp).toISOString(), + // Use the message from details if available + 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); // Use new signature (3 args) const requestConfig: AxiosRequestConfig = { method: config.method || 'POST', @@ -214,9 +226,19 @@ export class NotificationService { const templateToUse = config.messageTemplate || defaultMessageTemplateFromI18n; console.log(`[通知测试 - Telegram] 要渲染的模板:`, templateToUse); - const eventDisplayName = i18next.t(`eventDisplay.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event }); - const messageText = this._renderTemplate(templateToUse, testPayload, '', eventDisplayName); - console.log(`[通知测试 - Telegram] 渲染的消息文本:`, messageText); + const eventDisplayName = i18next.t(`event.${testPayload.event}`, { lng: userLang, defaultValue: testPayload.event }); + // Prepare data object for _renderTemplate + const templateDataTelegramTest: Record = { + // Use 'event' key with escaped raw event name + event: this._escapeBasicMarkdown(testPayload.event), + eventDisplay: this._escapeBasicMarkdown(eventDisplayName), // Keep escaped display name too + timestamp: new Date(testPayload.timestamp).toISOString(), + // Use 'details' key with escaped formatted details message + details: this._escapeBasicMarkdown(messageFromPayload) + }; + // Render using the chosen template and prepared data + const messageText = this._renderTemplate(templateToUse, templateDataTelegramTest, defaultMessageTemplateFromI18n); // Use new signature (3 args) + console.log(`[通知测试 - Telegram] 渲染的消息文本:`, messageText); // Keep original log message const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; @@ -294,14 +316,23 @@ export class NotificationService { } - private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string, eventDisplayName?: string): string { + // Helper function to escape basic Markdown characters (`*`, `_`, `` ` ``, `[`) + private _escapeBasicMarkdown(text: string): string { + if (typeof text !== 'string') return ''; + // Escape *, _, `, [ + // Note: Telegram's Markdown parser might have quirks. + // If issues persist, consider escaping more characters or using MarkdownV2 with its stricter rules. + return text.replace(/([*_`\[])/g, '\\$1'); + } + + private _renderTemplate(template: string | undefined, data: Record, defaultText: string): string { if (!template) return defaultText; let rendered = template; - rendered = rendered.replace(/\{event\}/g, payload.event); - rendered = rendered.replace(/\{eventDisplay\}/g, eventDisplayName || payload.event); - rendered = rendered.replace(/\{timestamp\}/g, new Date(payload.timestamp).toISOString()); - const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); - rendered = rendered.replace(/\{details\}/g, detailsString); + for (const key in data) { + // Replace placeholders like {key} with data[key] + // Assumes data values are already properly escaped if needed + rendered = rendered.replace(new RegExp(`\\{${key}\\}`, 'g'), data[key]); + } return rendered; } @@ -312,14 +343,26 @@ export class NotificationService { return; } - const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event }); + 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 {eventDisplay}, {timestamp}, {details}.`; - const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, translatedPayload, defaultBody, eventDisplayName); + const defaultBodyTemplate = `Default: JSON payload. Use {event}, {timestamp}, {details}.`; + // Prepare data object for _renderTemplate + const templateDataWebhook: Record = { + event: translatedPayload.event, + eventDisplay: eventDisplayName, // Assuming no markdown needed for webhook + timestamp: new Date(translatedPayload.timestamp).toISOString(), + // Use the translated message if available, otherwise stringify + details: (typeof translatedPayload.details === 'object' && translatedPayload.details?.message) + ? translatedPayload.details.message + : (typeof translatedPayload.details === 'string' + ? translatedPayload.details + : JSON.stringify(translatedPayload.details || {}, null, 2)) + }; + const requestBody = this._renderTemplate(config.bodyTemplate || defaultBodyTemplate, templateDataWebhook, defaultBody); // Use new signature (3 args) const requestConfig: AxiosRequestConfig = { method: config.method || 'POST', @@ -368,15 +411,29 @@ export class NotificationService { i18nOptions.details = payload.details; } - const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event }); + const eventDisplayName = i18next.t(`event.${payload.event}`, { lng: userLang, defaultValue: payload.event }); const defaultSubjectKey = `event.${payload.event}`; - const defaultSubjectFallback = `Nexus Terminal Notification: {eventDisplay}`; + const defaultSubjectFallback = `Nexus Terminal Notification: {event}`; const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName }); const defaultSubjectTemplateKey = 'testNotification.subject'; const defaultSubjectTemplate = i18next.t(defaultSubjectTemplateKey, { lng: userLang, defaultValue: defaultSubjectFallback, eventDisplay: eventDisplayName }); - const subject = this._renderTemplate(config.subjectTemplate || defaultSubjectTemplate, payload, subjectText, eventDisplayName); + // Prepare data object for _renderTemplate (for subject) + const templateDataEmailSubject: Record = { + event: payload.event, + eventDisplay: eventDisplayName, // Assuming subject doesn't need markdown + timestamp: new Date(payload.timestamp).toISOString(), + details: typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2), + // Add other relevant fields from i18nOptions if needed by subject template + ...Object.entries(i18nOptions).reduce((acc, [key, value]) => { + if (key !== 'lng') { // Exclude 'lng' itself + acc[key] = String(value); + } + return acc; + }, {} as Record) + }; + const subject = this._renderTemplate(config.subjectTemplate || defaultSubjectTemplate, templateDataEmailSubject, subjectText); // Use new signature (3 args) const bodyKey = `eventBody.${payload.event}`; @@ -401,41 +458,82 @@ export class NotificationService { } private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { + console.log(`[_sendTelegram] Initiating for event: ${payload.event}, Setting ID: ${setting.id}, Lang: ${userLang}`); + 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; } - const i18nOptions: Record = { lng: userLang }; - if (payload.details && typeof payload.details === 'object') { - Object.assign(i18nOptions, payload.details); - } else if (payload.details !== undefined) { - i18nOptions.details = payload.details; + 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 { + // Generic fallback for unhandled object details + detailsText = JSON.stringify(payload.details); + } } - const eventDisplayName = i18next.t(`eventDisplay.${payload.event}`, { lng: userLang, defaultValue: payload.event }); + console.log(`[_sendTelegram] Formatted detailsText:`, detailsText); - const messageKey = `eventBody.${payload.event}`; - const detailsStr = payload.details ? `\nDetails: \`\`\`\n${typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details, null, 2)}\n\`\`\`` : ''; - const defaultMessageTemplateFallback = `*Nexus Terminal Notification*\n\nEvent: \`{eventDisplay}\`\nTimestamp: {timestamp}${detailsStr}`; - const translatedBody = i18next.t(messageKey, { ...i18nOptions, defaultValue: defaultMessageTemplateFallback, eventDisplay: eventDisplayName }); + // 3. Prepare data for template placeholders AND i18n interpolation (NO escaping here) + // Get the translated event name using the correct key 'event.' + const translatedEventName = i18next.t(`event.${payload.event}`, { lng: userLang, defaultValue: payload.event }); - const defaultTemplateKey = `notifications:${testTelegramBodyTemplateKey}`; - const defaultMessageTemplateFromI18n = i18next.t(defaultTemplateKey, { lng: userLang, defaultValue: translatedBody, eventDisplay: eventDisplayName }); + const templateData: Record = { + // Assign the *translated* event name to the 'event' key (NO escaping) + event: translatedEventName, + // ISO timestamp (usually safe) + timestamp: new Date(payload.timestamp).toISOString(), + // Formatted details string (NO escaping) + details: detailsText + // Note: We no longer create eventDisplay key + }; + console.log(`[_sendTelegram] Prepared templateData (NO escaping):`, JSON.stringify(templateData, null, 2)); - const messageText = this._renderTemplate(config.messageTemplate || defaultMessageTemplateFromI18n, payload, translatedBody, eventDisplayName); + // 4. Handle template + let messageText = ''; + if (config.messageTemplate) { + // User has a custom template, render it using prepared data + console.log(`[_sendTelegram] Using custom template:`, config.messageTemplate); + // Provide a fallback text in case rendering fails + const fallbackForCustom = `Event: ${templateData.event}, Details: ${templateData.details}`; // Use 'event' key now + messageText = this._renderTemplate(config.messageTemplate, templateData, fallbackForCustom); + } else { + // No custom template, use i18n to generate the full body + const i18nKey = `eventBody.${payload.event}`; + console.log(`[_sendTelegram] Using i18n template key:`, i18nKey); + // Define a fallback structure using the prepared data (NO escaping) + const fallbackBody = `*Fallback Notification*\nEvent: ${templateData.event}\nTime: \`${templateData.timestamp}\`\nDetails: ${templateData.details}`; // Use 'event' key now + // Pass the prepared data for interpolation within the i18n translation + messageText = i18next.t(i18nKey, { + lng: userLang, + ...templateData, // Pass all prepared data for interpolation (event, timestamp, details) + // If specific i18n keys need raw data (like the keys array), add them here: + // updatedKeys: (payload.event === 'SETTINGS_UPDATED' && Array.isArray(payload.details?.updatedKeys)) ? payload.details.updatedKeys.join(', ') : '', + defaultValue: fallbackBody + }); + } + console.log(`[_sendTelegram] Final message text to send:`, messageText); + + // 6. Send to Telegram (Moved step number) const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; - try { - console.log(`[通知] 发送 Telegram 消息到聊天 ID ${config.chatId} (事件: ${payload.event})`); - const response = await axios.post(telegramApiUrl, { + console.log(`[通知] 发送 Telegram 消息到聊天 ID ${config.chatId} (事件: ${payload.event})`); + const requestBody = { chat_id: config.chatId, text: messageText, - parse_mode: 'Markdown', - }, { timeout: 10000 }); - console.log(`[通知] Telegram 消息发送成功。响应 OK:`, response.data?.ok); + parse_mode: 'Markdown', // Use standard 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; + const errorMessage = error.response?.data?.description || error.response?.data || error.message; console.error(`[通知] 发送 Telegram 消息 (设置 ID: ${setting.id}) 时出错:`, errorMessage); } } diff --git a/packages/backend/src/settings/settings.controller.ts b/packages/backend/src/settings/settings.controller.ts index 7da0fcd..626ab44 100644 --- a/packages/backend/src/settings/settings.controller.ts +++ b/packages/backend/src/settings/settings.controller.ts @@ -1,10 +1,12 @@ import { Request, Response } from 'express'; import { settingsService } from '../services/settings.service'; import { AuditLogService } from '../services/audit.service'; +import { NotificationService } from '../services/notification.service'; // 添加导入 import { ipBlacklistService } from '../services/ip-blacklist.service'; import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types const auditLogService = new AuditLogService(); +const notificationService = new NotificationService(); // 添加实例 export const settingsController = { /** @@ -59,6 +61,7 @@ export const settingsController = { auditLogService.logAction('IP_WHITELIST_UPDATED', { updatedKeys }); } else { auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys }); + notificationService.sendNotification('SETTINGS_UPDATED', { updatedKeys }); // 添加通知调用 } } res.status(200).json({ message: '设置已成功更新' }); diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 17a220c..716cc5e 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -7,8 +7,9 @@ import { getDbInstance } from './database/connection'; import { SftpService } from './services/sftp.service'; import { StatusMonitorService } from './services/status-monitor.service'; import * as SshService from './services/ssh.service'; -import { DockerService } from './services/docker.service'; +import { DockerService } from './services/docker.service'; import { AuditLogService } from './services/audit.service'; +import { NotificationService } from './services/notification.service'; // 添加导入 import { settingsService } from './services/settings.service'; // 扩展 WebSocket 类型以包含会话 ID @@ -138,6 +139,7 @@ export const clientStates = new Map(); const sftpService = new SftpService(clientStates); const statusMonitorService = new StatusMonitorService(clientStates); const auditLogService = new AuditLogService(); // 实例化 AuditLogService +const notificationService = new NotificationService(); // 添加实例 const dockerService = new DockerService(); // 实例化 DockerService (主要用于类型或未来可能的本地调用) /** @@ -527,6 +529,13 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re sessionId: newSessionId, ip: newState.ipAddress }); + notificationService.sendNotification('SSH_CONNECT_SUCCESS', { // 添加通知调用 + userId: ws.userId, + username: ws.username, + connectionId: dbConnectionId, + sessionId: newSessionId, + ip: newState.ipAddress + }); // 7. 异步初始化 SFTP 和启动状态监控 console.log(`WebSocket: 会话 ${newSessionId} 正在异步初始化 SFTP...`); @@ -620,6 +629,14 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re ip: newState.ipAddress, reason: shellError.message }); + notificationService.sendNotification('SSH_SHELL_FAILURE', { // 添加通知调用 + userId: ws.userId, + username: ws.username, + connectionId: dbConnectionId, + sessionId: newSessionId, + ip: newState.ipAddress, + reason: shellError.message + }); ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${shellError.message}` })); cleanupClientConnection(newSessionId); } @@ -645,8 +662,15 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re ip: clientIp, reason: connectError.message }); + notificationService.sendNotification('SSH_CONNECT_FAILURE', { // 添加通知调用 + userId: ws.userId, + username: ws.username, + connectionId: dbConnectionId, + ip: clientIp, + reason: connectError.message + }); ws.send(JSON.stringify({ type: 'ssh:error', payload: `连接失败: ${connectError.message}` })); - } + } break; }