import { Router } from 'express'; import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { isFederationEnabled, getFederationDomain, signPayload, verifyPayload, discoverInstance, parseAddress, } from '../config/federation.js'; import { rateLimit } from 'express-rate-limit'; const router = Router(); // Rate limit for federation calendar receive const calendarFederationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests. Please try again later.' }, }); // ── GET /api/calendar/events — List events for the current user ───────────── router.get('/events', authenticateToken, async (req, res) => { try { const db = getDb(); const { from, to } = req.query; let sql = ` SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE (ce.user_id = ? OR ce.id IN ( SELECT event_id FROM calendar_event_shares WHERE user_id = ? )) `; const params = [req.user.id, req.user.id]; if (from) { sql += ' AND ce.end_time >= ?'; params.push(from); } if (to) { sql += ' AND ce.start_time <= ?'; params.push(to); } sql += ' ORDER BY ce.start_time ASC'; const events = await db.all(sql, params); // Mark shared events for (const ev of events) { ev.is_owner = ev.user_id === req.user.id; } res.json({ events }); } catch (err) { log.server.error(`Calendar list error: ${err.message}`); res.status(500).json({ error: 'Events could not be loaded' }); } }); // ── GET /api/calendar/events/:id — Get single event ───────────────────────── router.get('/events/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get(` SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE ce.id = ? `, [req.params.id]); if (!event) return res.status(404).json({ error: 'Event not found' }); // Check access if (event.user_id !== req.user.id) { const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]); if (!share) return res.status(403).json({ error: 'No permission' }); } // Get shared users const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces JOIN users u ON ces.user_id = u.id WHERE ces.event_id = ? `, [event.id]); event.is_owner = event.user_id === req.user.id; res.json({ event, sharedUsers }); } catch (err) { log.server.error(`Calendar get event error: ${err.message}`); res.status(500).json({ error: 'Event could not be loaded' }); } }); // ── POST /api/calendar/events — Create event ──────────────────────────────── router.post('/events', authenticateToken, async (req, res) => { try { const { title, description, start_time, end_time, room_uid, color } = req.body; if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' }); if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' }); if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' }); if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' }); const startDate = new Date(start_time); const endDate = new Date(end_time); if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { return res.status(400).json({ error: 'Invalid date format' }); } if (endDate <= startDate) { return res.status(400).json({ error: 'End time must be after start time' }); } // Verify room exists if specified const db = getDb(); if (room_uid) { const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]); if (!room) return res.status(400).json({ error: 'Linked room not found' }); } const uid = crypto.randomBytes(12).toString('hex'); const result = await db.run(` INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, [ uid, title.trim(), description || null, startDate.toISOString(), endDate.toISOString(), room_uid || null, req.user.id, color || '#6366f1', ]); const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]); res.status(201).json({ event }); } catch (err) { log.server.error(`Calendar create error: ${err.message}`); res.status(500).json({ error: 'Event could not be created' }); } }); // ── PUT /api/calendar/events/:id — Update event ───────────────────────────── router.put('/events/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); const { title, description, start_time, end_time, room_uid, color } = req.body; if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' }); if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' }); if (start_time && end_time) { const s = new Date(start_time); const e = new Date(end_time); if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' }); if (e <= s) return res.status(400).json({ error: 'End time must be after start time' }); } if (room_uid) { const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]); if (!room) return res.status(400).json({ error: 'Linked room not found' }); } await db.run(` UPDATE calendar_events SET title = COALESCE(?, title), description = ?, start_time = COALESCE(?, start_time), end_time = COALESCE(?, end_time), room_uid = ?, color = COALESCE(?, color), updated_at = CURRENT_TIMESTAMP WHERE id = ? `, [ title || null, description !== undefined ? description : event.description, start_time || null, end_time || null, room_uid !== undefined ? (room_uid || null) : event.room_uid, color || null, req.params.id, ]); const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]); res.json({ event: updated }); } catch (err) { log.server.error(`Calendar update error: ${err.message}`); res.status(500).json({ error: 'Event could not be updated' }); } }); // ── DELETE /api/calendar/events/:id — Delete event ────────────────────────── router.delete('/events/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]); res.json({ message: 'Event deleted' }); } catch (err) { log.server.error(`Calendar delete error: ${err.message}`); res.status(500).json({ error: 'Event could not be deleted' }); } }); // ── POST /api/calendar/events/:id/share — Share event with local user ─────── router.post('/events/:id/share', authenticateToken, async (req, res) => { try { const { user_id } = req.body; if (!user_id) return res.status(400).json({ error: 'User ID is required' }); const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' }); const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]); if (existing) return res.status(400).json({ error: 'Already shared with this user' }); await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [event.id, user_id]); const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces JOIN users u ON ces.user_id = u.id WHERE ces.event_id = ? `, [event.id]); res.json({ sharedUsers }); } catch (err) { log.server.error(`Calendar share error: ${err.message}`); res.status(500).json({ error: 'Could not share event' }); } }); // ── DELETE /api/calendar/events/:id/share/:userId — Remove share ──────────── router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]); const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces JOIN users u ON ces.user_id = u.id WHERE ces.event_id = ? `, [event.id]); res.json({ sharedUsers }); } catch (err) { log.server.error(`Calendar unshare error: ${err.message}`); res.status(500).json({ error: 'Could not remove share' }); } }); // ── GET /api/calendar/events/:id/ics — Download event as ICS ──────────────── router.get('/events/:id/ics', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get(` SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE ce.id = ? `, [req.params.id]); if (!event) return res.status(404).json({ error: 'Event not found' }); // Check access if (event.user_id !== req.user.id) { const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]); if (!share) return res.status(403).json({ error: 'No permission' }); } // Build room join URL if linked const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; let location = ''; if (event.room_uid) { location = `${baseUrl}/join/${event.room_uid}`; } const ics = generateICS(event, location, baseUrl); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`); res.send(ics); } catch (err) { log.server.error(`ICS download error: ${err.message}`); res.status(500).json({ error: 'Could not generate ICS file' }); } }); // ── POST /api/calendar/events/:id/federation — Send event to remote user ──── router.post('/events/:id/federation', authenticateToken, async (req, res) => { try { if (!isFederationEnabled()) { return res.status(400).json({ error: 'Federation is not configured on this instance' }); } const { to } = req.body; if (!to) return res.status(400).json({ error: 'Remote address is required' }); const { username, domain } = parseAddress(to); if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' }); if (domain === getFederationDomain()) { return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' }); } const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; let joinUrl = null; if (event.room_uid) { joinUrl = `${baseUrl}/join/${event.room_uid}`; } const payload = { type: 'calendar_event', event_uid: event.uid, title: event.title, description: event.description || '', start_time: event.start_time, end_time: event.end_time, room_uid: event.room_uid || null, join_url: joinUrl, from_user: `@${req.user.name}@${getFederationDomain()}`, to_user: to, timestamp: new Date().toISOString(), }; const signature = signPayload(payload); const { baseUrl: remoteApi } = await discoverInstance(domain); const response = await fetch(`${remoteApi}/calendar-event`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Federation-Signature': signature, 'X-Federation-Origin': getFederationDomain(), }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15_000), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Remote server responded with ${response.status}`); } res.json({ success: true }); } catch (err) { log.server.error(`Calendar federation send error: ${err.message}`); res.status(500).json({ error: err.message || 'Could not send event to remote instance' }); } }); // ── POST /receive-event or /calendar-event — Receive calendar event from remote ── // '/receive-event' when mounted at /api/calendar // '/calendar-event' when mounted at /api/federation (for remote instance discovery) router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => { try { if (!isFederationEnabled()) { return res.status(400).json({ error: 'Federation is not configured on this instance' }); } const signature = req.headers['x-federation-signature']; const payload = req.body || {}; if (!signature) return res.status(401).json({ error: 'Missing federation signature' }); const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload; if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) { return res.status(400).json({ error: 'Incomplete event payload' }); } // Validate lengths if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) || from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) { return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' }); } // Verify signature const { domain: senderDomain } = parseAddress(from_user); if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' }); const { publicKey } = await discoverInstance(senderDomain); if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' }); if (!verifyPayload(payload, signature, publicKey)) { return res.status(403).json({ error: 'Invalid federation signature' }); } // Find local user const { username } = parseAddress(to_user); const db = getDb(); const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]); if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' }); // Check duplicate const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]); if (existing) return res.json({ success: true, message: 'Event already received' }); // Create event for the target user await db.run(` INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ event_uid, title, description || null, start_time, end_time, room_uid || null, targetUser.id, '#6366f1', from_user, join_url || null, ]); res.json({ success: true }); } catch (err) { log.server.error(`Calendar federation receive error: ${err.message}`); res.status(500).json({ error: 'Failed to process calendar event' }); } }); // ── Helper: Generate ICS content ──────────────────────────────────────────── function generateICS(event, location, prodIdDomain) { const formatDate = (dateStr) => { const d = new Date(dateStr); return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); }; const escapeICS = (str) => { if (!str) return ''; return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); }; const now = formatDate(new Date().toISOString()); const dtStart = formatDate(event.start_time); const dtEnd = formatDate(event.end_time); let ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:-//${prodIdDomain}//Redlight Calendar//EN`, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', `UID:${event.uid}@${prodIdDomain}`, `DTSTAMP:${now}`, `DTSTART:${dtStart}`, `DTEND:${dtEnd}`, `SUMMARY:${escapeICS(event.title)}`, ]; if (event.description) { ics.push(`DESCRIPTION:${escapeICS(event.description)}`); } if (location) { ics.push(`LOCATION:${escapeICS(location)}`); ics.push(`URL:${location}`); } if (event.organizer_name && event.organizer_email) { ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`); } ics.push('END:VEVENT', 'END:VCALENDAR'); return ics.join('\r\n'); } export default router;