Harden server security, rework landing page and refresh branding
Build & Push Docker Image / build (push) Successful in 4m3s

Security:
- rooms: rate-limit /invite-email (SMTP spam relay), validate share
  target user exists, guard timingSafeEqual against length mismatch
  in the presentation route (500 -> 403)
- analytics: verify callback token before parsing the 5mb body so
  unauthenticated callers cannot buffer large payloads
- caldav: rate-limit failed Basic-Auth attempts (token brute force),
  lowercase email lookup, case-insensitive principal check
- auth: fall back to the in-memory rate-limit store when Redis is
  unavailable; previously every rate-limited endpoint (incl. login)
  returned 500 when the Redis connection was down

UI/copy:
- Home: factual hero copy and feature cards (6 instead of 9), fix
  double-rendered feature icon, remove fake stats row and pill badge;
  keep the background gradient and card layout
- i18n: consistent informal tone, drop trailing exclamation marks
  from status toasts, remove emoji from transactional emails
- new favicon (logo.svg), restore theme-based default brand logo

Chore:
- gitignore SQLite WAL/SHM files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:14:36 +02:00
parent 4621010bd7
commit 7dd834cd35
14 changed files with 224 additions and 209 deletions
+1 -1
View File
@@ -228,7 +228,7 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
+2 -2
View File
@@ -1,6 +1,6 @@
{
"email": {
"greeting": "Hey {name} 👋",
"greeting": "Hallo {name},",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
@@ -13,7 +13,7 @@
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"title": "Du wurdest eingeladen",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"email": {
"greeting": "Hey {name} 👋",
"greeting": "Hello {name},",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
@@ -13,7 +13,7 @@
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"title": "You have been invited",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
+15 -12
View File
@@ -9,22 +9,25 @@ import { getAnalyticsToken } from '../config/bbb.js';
const router = Router();
// Token check runs BEFORE the 5mb body parser so unauthenticated callers
// cannot make the server buffer large payloads.
// Constant-time comparison to prevent timing attacks. Reject non-string tokens
// (e.g. ?token=a&token=b would yield an array and crash Buffer.from).
function verifyCallbackToken(req, res, next) {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
if (typeof token !== 'string' || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
next();
}
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
// 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) => {
router.post('/callback/:uid', verifyCallbackToken, json({ limit: '5mb' }), async (req, res) => {
try {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
// Constant-time comparison to prevent timing attacks.
// Reject non-string tokens (e.g. ?token=a&token=b would yield an array and
// crash Buffer.from).
if (typeof token !== 'string' || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
const db = getDb();
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
+59 -9
View File
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { rateLimit, MemoryStore } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.js';
@@ -23,15 +23,65 @@ if (!process.env.JWT_SECRET) {
const JWT_SECRET = process.env.JWT_SECRET;
// ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) {
try {
return new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
return undefined; // falls back to in-memory if Redis unavailable
// Shared Redis store with in-memory fallback. Without the fallback, a dead
// Redis connection makes every rate-limited endpoint (incl. login) return 500 —
// rate limiting must degrade, not take auth down with it.
class ResilientStore {
constructor(prefix) {
try {
this.redisStore = new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
this.redisStore = null;
}
this.memoryStore = new MemoryStore();
}
init(options) {
try {
// RedisStore.init returns the SCRIPT LOAD promise; catch it so a dead
// Redis connection surfaces as a warning instead of an unhandled
// rejection that kills the process.
const initResult = this.redisStore?.init?.(options);
if (typeof initResult?.catch === 'function') {
initResult.catch(err => log.auth.warn(`Rate-limit Redis init failed: ${err.message}`));
}
} catch (err) {
log.auth.warn(`Rate-limit Redis init failed: ${err.message}`);
}
this.memoryStore.init(options);
}
async increment(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.increment(key);
} catch (err) {
log.auth.warn(`Rate-limit Redis store failed, using in-memory fallback: ${err.message}`);
}
}
return this.memoryStore.increment(key);
}
async decrement(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.decrement(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.decrement(key);
}
async resetKey(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.resetKey(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.resetKey(key);
}
}
function makeRedisStore(prefix) {
return new ResilientStore(prefix);
}
// ── Validation helpers ─────────────────────────────────────────────────────
+16 -2
View File
@@ -12,12 +12,25 @@
import { Router, text } from 'express';
import crypto from 'crypto';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { getBaseUrl } from '../middleware/auth.js';
const router = Router();
// Brute-force protection for the Basic-Auth tokens: only failed requests count
// (skipSuccessfulRequests), so legitimate sync clients polling frequently are
// not throttled.
router.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many failed CalDAV requests. Please try again later.',
}));
// ── Body parsing for XML and iCalendar payloads ────────────────────────────
router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] }));
@@ -194,9 +207,10 @@ async function caldavAuth(req, res, next) {
const token = decoded.slice(colonIdx + 1);
const db = getDb();
// Emails are stored lowercased
const user = await db.get(
'SELECT id, name, display_name, email FROM users WHERE email = ?',
[email],
[email.toLowerCase()],
);
if (!user) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
@@ -243,7 +257,7 @@ function setDAVHeaders(res) {
// ── CalDAV username authorization ──────────────────────────────────────────
// Ensures the :username param matches the authenticated user's email
function validateCalDAVUser(req, res, next) {
if (req.params.username && decodeURIComponent(req.params.username) !== req.caldavUser.email) {
if (req.params.username && decodeURIComponent(req.params.username).toLowerCase() !== req.caldavUser.email) {
return res.status(403).end();
}
next();
+19 -2
View File
@@ -67,6 +67,16 @@ const guestJoinLimiter = rateLimit({
message: { error: 'Too many join attempts. Please try again later.' },
});
// Rate limit email invitations: each request may carry up to 50 addresses, so
// without a cap any registered account could be abused as an SMTP spam relay.
const inviteEmailLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many email invitations. Please try again later.' },
});
const router = Router();
// Build avatar URL for a user (uploaded image or generated initials)
@@ -425,6 +435,10 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
if (user_id === req.user.id) {
return res.status(400).json({ error: 'You cannot share the room with yourself' });
}
const targetUser = await db.get('SELECT id FROM users WHERE id = ?', [user_id]);
if (!targetUser) {
return res.status(404).json({ error: 'User not found' });
}
// Check if already shared
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
if (existing) {
@@ -740,7 +754,10 @@ router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
.update(`${roomUid}/${filename}:${expires}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
// timingSafeEqual throws on length mismatch, so compare lengths first
const tokenBuf = Buffer.from(token);
const expectedBuf = Buffer.from(expected);
if (tokenBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(tokenBuf, expectedBuf)) {
return res.status(403).json({ error: 'Invalid token' });
}
@@ -868,7 +885,7 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
});
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
router.post('/invite-email', authenticateToken, async (req, res) => {
router.post('/invite-email', authenticateToken, inviteEmailLimiter, async (req, res) => {
try {
const { room_uid, emails, message } = req.body;
if (!room_uid || !emails || !emails.length) {