Harden server security, rework landing page and refresh branding
Build & Push Docker Image / build (push) Successful in 4m3s
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:
@@ -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;">"${safeDesc}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user