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