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';