From a9c4b29f9f2d6d7fc0fecd4ca2a42014eefbefff Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 1 Mar 2026 19:21:05 +0000 Subject: [PATCH] Improved notifications. --- src/keycloak.js | 14 +++++++++++--- src/mailworker.js | 36 +++++++++++++++++++++++++++--------- src/services/misc/auth.js | 4 +++- src/services/misc/notes.js | 4 ++++ src/utils.js | 33 ++++++++++++++++++++++++++++++--- 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/keycloak.js b/src/keycloak.js index d5e9019..11d1245 100644 --- a/src/keycloak.js +++ b/src/keycloak.js @@ -8,7 +8,7 @@ import NodeCache from 'node-cache'; import { userModel } from './database/schemas/management/user.schema.js'; import { getObject } from './database/database.js'; import { hostModel } from './database/schemas/management/host.schema.js'; -import { getSession } from './services/misc/auth.js'; +import { getSession, lookupUserByToken } from './services/misc/auth.js'; const logger = log4js.getLogger('Keycloak'); logger.level = config.server.logLevel || 'info'; @@ -50,7 +50,7 @@ const lookupUser = async (preferredUsername) => { /** * Middleware to check if the user is authenticated. - * Supports: 1) Bearer token (Redis session), 2) x-host-id + x-auth-code (host auth) + * Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), 3) x-host-id + x-auth-code (host auth) */ const isAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; @@ -64,6 +64,14 @@ const isAuthenticated = async (req, res, next) => { req.session = session; return next(); } + + // Try email-render JWT (short-lived token for Puppeteer email notifications) + const user = await lookupUserByToken(token); + if (user) { + req.user = user; + req.session = { user }; + return next(); + } } catch (error) { logger.error('Session lookup error:', error.message); } @@ -77,7 +85,7 @@ const isAuthenticated = async (req, res, next) => { return next(); } } - + logger.debug('Not authenticated', { hostId, authCode }, 'req.headers', req.headers); return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; diff --git a/src/mailworker.js b/src/mailworker.js index 1ff7735..ee4044c 100644 --- a/src/mailworker.js +++ b/src/mailworker.js @@ -41,8 +41,18 @@ const logger = log4js.getLogger('MailWorker'); logger.level = config.server.logLevel; async function sendEmail(payload) { - const { email, title, message, type, metadata, smtpConfig, urlClient, createdAt, updatedAt, authCode } = - payload; + const { + email, + title, + message, + type, + metadata, + smtpConfig, + urlClient, + createdAt, + updatedAt, + authCode, + } = payload; if (!email || !smtpConfig?.host) { logger.warn('Missing email or SMTP config, skipping...'); @@ -56,11 +66,9 @@ async function sendEmail(payload) { email: email || '', createdAt: createdAt || new Date(), updatedAt: updatedAt || new Date(), + authCode: authCode || '', metadata: JSON.stringify(metadata || {}), }); - if (authCode) { - params.set('authCode', authCode); - } const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`; logger.debug('Rendering template...'); @@ -70,14 +78,24 @@ async function sendEmail(payload) { let browser; try { browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-features=SameSiteByDefaultCookies', + ], }); const page = await browser.newPage(); - await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 15000 }); + page.on('console', (msg) => { + const text = msg.text(); + const type = msg.type(); + logger.trace(`Puppeteer [${type}]: ${text}`); + }); + await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 30000 }); await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 }); // Wait for Ant Design CSS-in-JS to finish injecting styles - await new Promise((r) => setTimeout(r, 300)); + logger.debug('Waiting for 1.5 seconds for page to render...'); + await new Promise((r) => setTimeout(r, 1500)); html = await page.evaluate(() => { const root = document.getElementById('email-notification-root'); if (!root) return document.documentElement.outerHTML; diff --git a/src/services/misc/auth.js b/src/services/misc/auth.js index 3f37eb4..6b2f331 100644 --- a/src/services/misc/auth.js +++ b/src/services/misc/auth.js @@ -39,7 +39,7 @@ const lookupUserByEmailRenderToken = async (token) => { return cachedUser; } - const decodedToken = jwt.decode(token); + const decodedToken = jwt.verify(token, config.auth.sessionSecret); if (!decodedToken || !decodedToken.preferred_username) { logger.trace('Invalid token or missing preferred_username'); return null; @@ -325,12 +325,14 @@ export const logoutRouteHandler = async (req, res) => { export const validateTokenMiddleware = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { + logger.debug('No auth header or not bearer token'); return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' }); } const token = authHeader.substring(7); const session = await getSession(token); if (!session) { + logger.debug('Session not found'); return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' }); } diff --git a/src/services/misc/notes.js b/src/services/misc/notes.js index a944438..b0a8e2e 100644 --- a/src/services/misc/notes.js +++ b/src/services/misc/notes.js @@ -13,6 +13,7 @@ import { getModelHistory, } from '../../database/database.js'; import mongoose from 'mongoose'; +import { newNoteNotification } from '../../utils.js'; const logger = log4js.getLogger('Notes'); logger.level = config.server.logLevel; @@ -127,6 +128,9 @@ export const newNoteRouteHandler = async (req, res) => { newData, user: req.user, }); + + await newNoteNotification({ ...result, ...newData }, req.user); + if (result.error) { logger.error('No note created:', result.error); return res.status(result.code).send(result); diff --git a/src/utils.js b/src/utils.js index 2e93f01..26ca1d9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -434,8 +434,12 @@ async function editNotification(oldValue, newValue, parentId, parentType, user) old: changedOldValues, new: changedNewValues, objectType: parentType, - object: { _id: parentId }, - user: { _id: user._id, firstName: user.firstName, lastName: user.lastName }, + object: { _id: String(parentId ?? '') }, + user: { + _id: String(user?._id ?? ''), + firstName: user.firstName, + lastName: user.lastName, + }, } ); } @@ -461,7 +465,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) { async function deleteNotification(object, parentId, parentType, user) { const model = getModelByName(parentType); - const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType; + const objectName = object?.name || model?.label || parentType; await notfiyObjectUserNotifiers( parentId, parentType, @@ -477,6 +481,28 @@ async function deleteNotification(object, parentId, parentType, user) { ); } +async function newNoteNotification(note, user) { + const model = getModelByName(note.parentType); + const objectName = model?.label ?? note.parentType; + await notfiyObjectUserNotifiers( + note.parent, + note.parentType, + `New note added to ${objectName.toLowerCase()} by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`, + `A new note has been created.`, + 'newNote', + { + objectType: note.parentType, + object: { _id: String(note.parent ?? '') }, + note: note, + user: { + _id: String(user?._id ?? ''), + firstName: user?.firstName, + lastName: user?.lastName, + }, + } + ); +} + async function getAuditLogs(idOrIds) { if (Array.isArray(idOrIds)) { return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner'); @@ -875,6 +901,7 @@ export { getAuditLogs, flatternObjectIds, expandObjectIds, + newNoteNotification, distributeUpdate, distributeStats, distributeNew,