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
+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 ─────────────────────────────────────────────────────