diff --git a/server/config/appName.js b/server/config/appName.js new file mode 100644 index 0000000..11a33dd --- /dev/null +++ b/server/config/appName.js @@ -0,0 +1,20 @@ +import { getDb } from './database.js'; + +/** + * Resolve the configured application name. + * Resolution order: admin-set 'app_name' setting → APP_NAME env var → 'Redlight'. + * + * The app name is stored in the settings table under the key 'app_name' + * (see routes/branding.js). This helper is the single source of truth so the + * configured name is used consistently across emails, the 2FA issuer, etc. + */ +export async function getAppName() { + try { + const db = getDb(); + const row = await db.get("SELECT value FROM settings WHERE key = 'app_name'"); + if (row?.value) return row.value; + } catch { + // fall through to env/default if the DB is unavailable + } + return process.env.APP_NAME || 'Redlight'; +} diff --git a/server/config/bbb.js b/server/config/bbb.js index 0b84c46..80b1850 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -1,6 +1,7 @@ import crypto from 'crypto'; import xml2js from 'xml2js'; import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; +import { t } from './emaili18n.js'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_SECRET = process.env.BBB_SECRET || ''; @@ -73,15 +74,15 @@ function getRoomPasswords(uid) { return { moderatorPW: modPw, attendeePW: attPw }; } -export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) { +export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null, lang = 'en') { const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); // Build welcome message with guest invite link // HTML-escape user-controlled content to prevent stored XSS via BBB - let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome'); + let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : escapeHtml(t(lang, 'bbb.defaultWelcome')); if (logoutURL) { const guestLink = `${logoutURL}/join/${room.uid}`; - welcome += `

To invite other participants, share this link:
${escapeHtml(guestLink)}`; + welcome += `

${escapeHtml(t(lang, 'bbb.inviteHint'))}
${escapeHtml(guestLink)}`; // Access code is intentionally NOT shown in the welcome message to prevent // leaking it to all meeting participants. } diff --git a/server/config/mailer.js b/server/config/mailer.js index 236aba9..1296df4 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -64,7 +64,6 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl const from = process.env.SMTP_FROM || process.env.SMTP_USER; const headerAppName = sanitizeHeaderValue(appName); const safeName = escapeHtml(name); - const safeAppName = escapeHtml(appName); await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, @@ -112,7 +111,6 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me const safeFromUser = escapeHtml(fromUser); const safeRoomName = escapeHtml(roomName); const safeMessage = message ? escapeHtml(message) : null; - const safeAppName = escapeHtml(appName); const introHtml = t(lang, 'email.federationInvite.intro') .replace('{fromUser}', `${safeFromUser}`); diff --git a/server/i18n/de.json b/server/i18n/de.json index e62485e..b1b2401 100644 --- a/server/i18n/de.json +++ b/server/i18n/de.json @@ -42,5 +42,9 @@ "note": "Der Termin wurde automatisch aus deinem Kalender entfernt.", "footer": "Diese Nachricht wurde automatisch von {appName} versendet." } + }, + "bbb": { + "defaultWelcome": "Willkommen im Meeting!", + "inviteHint": "Lade weitere Teilnehmer ein, indem du diesen Link teilst:" } } diff --git a/server/i18n/en.json b/server/i18n/en.json index e36856e..ddbcaa1 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -42,5 +42,9 @@ "note": "The event has been automatically removed from your calendar.", "footer": "This message was sent automatically by {appName}." } + }, + "bbb": { + "defaultWelcome": "Welcome to the meeting!", + "inviteHint": "To invite other participants, share this link:" } } diff --git a/server/index.js b/server/index.js index 7505fa6..3087e4b 100644 --- a/server/index.js +++ b/server/index.js @@ -51,7 +51,14 @@ const corsOptions = process.env.APP_URL ? { origin: process.env.APP_URL, credentials: true } : { origin: false }; app.use(cors(corsOptions)); -app.use(express.json({ limit: '100kb' })); +// Global JSON body limit kept tight as a hardening measure. The BBB learning +// analytics callback can send much larger payloads, so it is excluded here and +// gets its own, more generous limit on the route itself (see routes/analytics.js). +const jsonParser = express.json({ limit: '100kb' }); +app.use((req, res, next) => { + if (req.path.startsWith('/api/analytics/callback/')) return next(); + return jsonParser(req, res, next); +}); // Request/Response logging (filters sensitive fields) app.use(requestResponseLogger); diff --git a/server/routes/admin.js b/server/routes/admin.js index 431a786..5602a4d 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js'; +import { getAppName } from '../config/appName.js'; import { log } from '../config/logger.js'; import { getOAuthConfig, @@ -217,9 +218,8 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => { const baseUrl = getBaseUrl(req); const inviteUrl = `${baseUrl}/register?invite=${token}`; - // Load app name - const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'"); - const appName = brandingSetting?.value || 'Redlight'; + // Load configured app name (admin setting → env → default) + const appName = await getAppName(); if (isMailerConfigured()) { try { diff --git a/server/routes/analytics.js b/server/routes/analytics.js index 0a3f6b2..5f4c367 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router, json } from 'express'; import crypto from 'crypto'; import ExcelJS from 'exceljs'; import PDFDocument from 'pdfkit'; @@ -10,7 +10,9 @@ import { getAnalyticsToken } from '../config/bbb.js'; const router = Router(); // POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured) -router.post('/callback/:uid', async (req, res) => { +// Excluded from the global 100kb body limit (see index.js): learning analytics +// payloads for large meetings can be several MB. +router.post('/callback/:uid', json({ limit: '5mb' }), async (req, res) => { try { const { token } = req.query; const expectedToken = getAnalyticsToken(req.params.uid); diff --git a/server/routes/auth.js b/server/routes/auth.js index 09f18a2..4e65b86 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -13,6 +13,7 @@ import redis from '../config/redis.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { getOAuthConfig, discoverOIDC } from '../config/oauth.js'; +import { getAppName } from '../config/appName.js'; import { log } from '../config/logger.js'; if (!process.env.JWT_SECRET) { @@ -179,7 +180,8 @@ router.post('/register', registerLimiter, async (req, res) => { return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` }); } - const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]); + // Emails are stored lowercased, so compare lowercased to catch case-variant duplicates + const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]); if (existing) { return res.status(409).json({ error: 'Email is already in use' }); } @@ -213,12 +215,8 @@ router.post('/register', registerLimiter, async (req, res) => { const baseUrl = getBaseUrl(req); const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; - // Load app name from branding settings - const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); - let appName = 'Redlight'; - if (brandingSetting?.value) { - try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {} - } + // Load configured app name (admin setting → env → default) + const appName = await getAppName(); try { await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en'); @@ -327,11 +325,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res) const baseUrl = getBaseUrl(req); const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; - const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); - let appName = 'Redlight'; - if (brandingSetting?.value) { - try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {} - } + const appName = await getAppName(); try { await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en'); @@ -774,12 +768,8 @@ router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => { const secret = new OTPAuth.Secret({ size: 20 }); - // Load app name from branding settings - const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); - let issuer = 'Redlight'; - if (brandingSetting?.value) { - try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {} - } + // Use the configured app name as the TOTP issuer (admin setting → env → default) + const issuer = await getAppName(); const totp = new OTPAuth.TOTP({ issuer, diff --git a/server/routes/calendar.js b/server/routes/calendar.js index f7fc946..6b37179 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -4,6 +4,7 @@ import { getDb } from '../config/database.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { sendCalendarInviteEmail } from '../config/mailer.js'; +import { getAppName } from '../config/appName.js'; import { isFederationEnabled, getFederationDomain, @@ -219,7 +220,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => { end_time = COALESCE(?, end_time), room_uid = ?, color = COALESCE(?, color), - reminder_minutes = COALESCE(?, reminder_minutes), + reminder_minutes = ?, reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'}, updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -230,7 +231,9 @@ router.put('/events/:id', authenticateToken, async (req, res) => { end_time || null, room_uid !== undefined ? (room_uid || null) : event.room_uid, color || null, - validReminder !== undefined ? validReminder : null, + // Not in payload → keep existing; present (incl. null) → set/clear directly. + // COALESCE can't be used here: it would treat an explicit null as "keep". + validReminder !== undefined ? validReminder : event.reminder_minutes, req.params.id, ]); @@ -323,7 +326,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => { if (targetUser?.email) { const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; - const appName = process.env.APP_NAME || 'Redlight'; + const appName = await getAppName(); const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]); const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name); sendCalendarInviteEmail( @@ -645,7 +648,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as if (targetUser.email) { const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; - const appName = process.env.APP_NAME || 'Redlight'; + const appName = await getAppName(); sendCalendarInviteEmail( targetUser.email, targetUser.name, from_user, title, start_time, end_time, description || null, diff --git a/server/routes/federation.js b/server/routes/federation.js index 33ddd08..70fa9b9 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -6,6 +6,7 @@ import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; import { createNotification } from '../config/notifications.js'; +import { getAppName } from '../config/appName.js'; // M13: rate limit the unauthenticated federation receive endpoint const federationReceiveLimiter = rateLimit({ @@ -198,7 +199,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { // Look up user by name (case-insensitive) const targetUser = await db.get( - 'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', + 'SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)', [username] ); @@ -238,7 +239,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { if (targetUser.email) { const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; - const appName = process.env.APP_NAME || 'Redlight'; + const appName = await getAppName(); sendFederationInviteEmail( targetUser.email, targetUser.name, from_user, room_name, message || null, inboxUrl, appName, targetUser.language || 'en' @@ -627,7 +628,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res // Notify affected users by email (fire-and-forget) if (affectedUsers.length > 0) { - const appName = process.env.APP_NAME || 'Redlight'; + const appName = await getAppName(); for (const u of affectedUsers) { sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en') .catch(mailErr => { diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 9d2750d..18874bd 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -9,6 +9,7 @@ import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { createNotification } from '../config/notifications.js'; import { sendGuestInviteEmail } from '../config/mailer.js'; +import { getAppName } from '../config/appName.js'; import { createMeeting, joinMeeting, @@ -513,7 +514,9 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { const analyticsCallbackURL = room.learning_analytics ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` : null; - await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL); + // Localise BBB's default welcome message in the room owner's language + const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]); + await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL, owner?.language || 'en'); const avatarURL = getUserAvatarURL(req, req.user); const displayName = req.user.display_name || req.user.name; const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); @@ -662,7 +665,9 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { const analyticsCallbackURL = room.learning_analytics ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` : null; - await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL); + // Localise BBB's default welcome message in the room owner's language + const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]); + await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL, owner?.language || 'en'); } // Check moderator code @@ -908,7 +913,7 @@ router.post('/invite-email', authenticateToken, async (req, res) => { ? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}` : `${baseUrl}/join/${room.uid}`; - const appName = process.env.APP_NAME || 'Redlight'; + const appName = await getAppName(); const fromUser = req.user.display_name || req.user.name; const lang = req.user.language || 'en';