import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; import { createNotification } from '../config/notifications.js'; // M13: rate limit the unauthenticated federation receive endpoint const federationReceiveLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many federation requests. Please try again later.' }, }); import { getFederationDomain, isFederationEnabled, getPublicKey, signPayload, verifyPayload, discoverInstance, parseAddress, } from '../config/federation.js'; const router = Router(); // ── Well-known discovery endpoint ─────────────────────────────────────────── // Mounted at /.well-known/redlight in index.js export function wellKnownHandler(req, res) { const domain = getFederationDomain(); if (!domain) { return res.status(404).json({ error: 'Federation not configured' }); } res.json({ domain, federation_api: '/api/federation', public_key: getPublicKey(), software: 'Redlight', version: '2.1.2', }); } // ── POST /api/federation/invite — Send invitation to remote user ──────────── router.post('/invite', authenticateToken, async (req, res) => { try { if (!isFederationEnabled()) { return res.status(400).json({ error: 'Federation is not configured on this instance' }); } const { room_uid, to, message } = req.body; if (!room_uid || !to) { return res.status(400).json({ error: 'room_uid and to are required' }); } const { username, domain } = parseAddress(to); if (!domain) { return res.status(400).json({ error: 'Remote address must be in format username@domain' }); } // Don't allow inviting to own instance if (domain === getFederationDomain()) { return res.status(400).json({ error: 'Cannot send federation invite to your own instance. Use local sharing instead.' }); } const db = getDb(); // Verify room exists and user has access const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } const isOwner = room.user_id === req.user.id; if (!isOwner) { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission to invite from this room' }); } } // Build guest join URL for the remote user // If the room has an access code, embed it so the recipient can join without manual entry const baseUrl = getBaseUrl(req); const joinUrl = room.access_code ? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}` : `${baseUrl}/join/${room.uid}`; // Build invitation payload const inviteId = uuidv4(); const payload = { invite_id: inviteId, from_user: `@${req.user.name}@${getFederationDomain()}`, to_user: to, room_name: room.name, room_uid: room.uid, max_participants: room.max_participants ?? 0, allow_recording: room.record_meeting ?? 1, message: message || null, join_url: joinUrl, timestamp: new Date().toISOString(), }; // Sign and send to remote instance const signature = signPayload(payload); const { baseUrl: remoteApi } = await discoverInstance(domain); const response = await fetch(`${remoteApi}/receive`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Federation-Signature': signature, 'X-Federation-Origin': getFederationDomain(), }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15_000), // 15 second timeout }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Remote server responded with ${response.status}`); } // Track outbound invite for deletion propagation try { await db.run( `INSERT INTO federation_outbound_invites (room_uid, remote_domain) VALUES (?, ?) ON CONFLICT(room_uid, remote_domain) DO NOTHING`, [room.uid, domain] ); } catch { /* table may not exist yet on upgrade */ } res.json({ success: true, invite_id: inviteId }); } catch (err) { log.federation.error('Federation invite error:', err); res.status(500).json({ error: err.message || 'Failed to send federation invite' }); } }); // ── POST /api/federation/receive — Accept incoming invitation from remote ─── router.post('/receive', federationReceiveLimiter, 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' }); } // Extract expected fields from the incoming payload const { invite_id, from_user, to_user, room_name, room_uid, max_participants, allow_recording, message, join_url } = payload; if (!invite_id || !from_user || !to_user || !room_name || !join_url) { return res.status(400).json({ error: 'Incomplete invitation payload' }); } // Validate join_url scheme to prevent javascript: or other malicious URIs try { const parsedUrl = new URL(join_url); if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { return res.status(400).json({ error: 'join_url must use https:// or http://' }); } } catch { return res.status(400).json({ error: 'Invalid join_url format' }); } // S4: validate field lengths from remote to prevent oversized DB entries if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 || room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) { return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' }); } // Fetch the sender's public key dynamically 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' }); } // Parse the target address and find local user const { username } = parseAddress(to_user); const db = getDb(); // Look up user by name (case-insensitive) 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 for duplicate const existing = await db.get( 'SELECT id FROM federation_invitations WHERE invite_id = ?', [invite_id] ); if (existing) { return res.json({ success: true, message: 'Invitation already received' }); } // Store the invitation await db.run( `INSERT INTO federation_invitations (invite_id, from_user, to_user_id, room_name, message, join_url) VALUES (?, ?, ?, ?, ?, ?)`, [invite_id, from_user, targetUser.id, room_name, message || null, join_url] ); // Store room_uid, max_participants, allow_recording if those columns already exist // (we update after initial insert to stay compatible with old schema) const inv = await db.get('SELECT id FROM federation_invitations WHERE invite_id = ? AND to_user_id = ?', [invite_id, targetUser.id]); if (inv && room_uid !== undefined) { try { await db.run( 'UPDATE federation_invitations SET room_uid = ?, max_participants = ?, allow_recording = ? WHERE id = ?', [room_uid || null, max_participants ?? 0, allow_recording ?? 1, inv.id] ); } catch { /* column may not exist on very old installs */ } } // Send notification email (truly fire-and-forget - never blocks the response) if (targetUser.email) { const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; sendFederationInviteEmail( targetUser.email, targetUser.name, from_user, room_name, message || null, inboxUrl, appName, targetUser.language || 'en' ).catch(mailErr => { log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); }); } // In-app notification await createNotification( targetUser.id, 'federation_invite_received', from_user, room_name, '/federation/inbox', ); res.json({ success: true }); } catch (err) { log.federation.error('Federation receive error:', err); res.status(500).json({ error: 'Failed to process federation invitation' }); } }); // ── GET /api/federation/invitations — List invitations for current user ───── router.get('/invitations', authenticateToken, async (req, res) => { try { const db = getDb(); const invitations = await db.all( `SELECT * FROM federation_invitations WHERE to_user_id = ? ORDER BY created_at DESC`, [req.user.id] ); res.json({ invitations }); } catch (err) { log.federation.error('List invitations error:', err); res.status(500).json({ error: 'Failed to load invitations' }); } }); // ── GET /api/federation/invitations/pending-count — Badge count ───────────── router.get('/invitations/pending-count', authenticateToken, async (req, res) => { try { const db = getDb(); const roomResult = await db.get( `SELECT COUNT(*) as count FROM federation_invitations WHERE to_user_id = ? AND status = 'pending'`, [req.user.id] ); let calResult = { count: 0 }; try { calResult = await db.get( `SELECT COUNT(*) as count FROM calendar_invitations WHERE to_user_id = ? AND status = 'pending'`, [req.user.id] ); } catch { /* table may not exist yet */ } let localCalResult = { count: 0 }; try { localCalResult = await db.get( `SELECT COUNT(*) as count FROM calendar_local_invitations WHERE to_user_id = ? AND status = 'pending'`, [req.user.id] ); } catch { /* table may not exist yet */ } res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) }); } catch (err) { res.json({ count: 0 }); } }); // ── POST /api/federation/invitations/:id/accept — Accept an invitation ────── router.post('/invitations/:id/accept', authenticateToken, async (req, res) => { try { const db = getDb(); const invitation = await db.get( 'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?', [req.params.id, req.user.id] ); if (!invitation) { return res.status(404).json({ error: 'Invitation not found' }); } await db.run( "UPDATE federation_invitations SET status = 'accepted' WHERE id = ?", [invitation.id] ); // Upsert into federated_rooms so the room appears in the user's dashboard const existing = await db.get( 'SELECT id FROM federated_rooms WHERE invite_id = ? AND user_id = ?', [invitation.invite_id, req.user.id] ); if (!existing) { await db.run( `INSERT INTO federated_rooms (user_id, invite_id, room_name, from_user, join_url, meet_id, max_participants, allow_recording) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ req.user.id, invitation.invite_id, invitation.room_name, invitation.from_user, invitation.join_url, invitation.room_uid || null, invitation.max_participants ?? 0, invitation.allow_recording ?? 1, ] ); } res.json({ success: true, join_url: invitation.join_url }); } catch (err) { log.federation.error('Accept invitation error:', err); res.status(500).json({ error: 'Failed to accept invitation' }); } }); // ── DELETE /api/federation/invitations/:id — Decline/dismiss invitation ───── router.delete('/invitations/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const invitation = await db.get( 'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?', [req.params.id, req.user.id] ); if (!invitation) { return res.status(404).json({ error: 'Invitation not found' }); } await db.run('DELETE FROM federation_invitations WHERE id = ?', [invitation.id]); res.json({ success: true }); } catch (err) { log.federation.error('Decline invitation error:', err); res.status(500).json({ error: 'Failed to decline invitation' }); } }); // ── GET /api/federation/calendar-invitations — List calendar invitations ───── router.get('/calendar-invitations', authenticateToken, async (req, res) => { try { const db = getDb(); const invitations = await db.all( `SELECT * FROM calendar_invitations WHERE to_user_id = ? ORDER BY created_at DESC`, [req.user.id] ); res.json({ invitations }); } catch (err) { log.federation.error('List calendar invitations error:', err); res.status(500).json({ error: 'Failed to load calendar invitations' }); } }); // ── POST /api/federation/calendar-invitations/:id/accept ───────────────────── router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => { try { const db = getDb(); const inv = await db.get( `SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`, [req.params.id, req.user.id] ); if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' }); if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' }); await db.run( `UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`, [inv.id] ); // Check if event was already previously accepted (duplicate guard) const existing = await db.get( 'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [inv.event_uid, req.user.id] ); if (!existing) { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ inv.event_uid, inv.title, inv.description || null, inv.start_time, inv.end_time, inv.room_uid || null, req.user.id, inv.color || '#6366f1', inv.from_user, inv.join_url || null, ]); } res.json({ success: true }); } catch (err) { log.federation.error('Accept calendar invitation error:', err); res.status(500).json({ error: 'Failed to accept calendar invitation' }); } }); // ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ──────── router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const inv = await db.get( `SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`, [req.params.id, req.user.id] ); if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' }); if (inv.status === 'pending') { // mark as declined await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]); } else { // accepted or declined — permanently remove from inbox await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]); } res.json({ success: true }); } catch (err) { log.federation.error('Delete calendar invitation error:', err); res.status(500).json({ error: 'Failed to remove calendar invitation' }); } }); // ── GET /api/federation/federated-rooms — List saved federated rooms ──────── router.get('/federated-rooms', authenticateToken, async (req, res) => { try { const db = getDb(); const rooms = await db.all( `SELECT * FROM federated_rooms WHERE user_id = ? ORDER BY created_at DESC`, [req.user.id] ); res.json({ rooms }); } catch (err) { log.federation.error('List federated rooms error:', err); res.status(500).json({ error: 'Failed to load federated rooms' }); } }); // ── DELETE /api/federation/federated-rooms/:id — Remove a federated room ──── router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const room = await db.get( 'SELECT * FROM federated_rooms WHERE id = ? AND user_id = ?', [req.params.id, req.user.id] ); if (!room) return res.status(404).json({ error: 'Room not found' }); await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]); res.json({ success: true }); } catch (err) { log.federation.error('Delete federated room error:', err); res.status(500).json({ error: 'Failed to remove room' }); } }); // ── POST /api/federation/room-sync — Remote instances query room settings ─── // Called by federated instances to pull current room info for one or more UIDs. // Signed request from remote, no auth token needed. router.post('/room-sync', federationReceiveLimiter, 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 originDomain = req.headers['x-federation-origin']; const payload = req.body || {}; if (!signature || !originDomain) { return res.status(401).json({ error: 'Missing federation signature or origin' }); } // Verify signature using the remote instance's public key const { publicKey } = await discoverInstance(originDomain); if (!publicKey || !verifyPayload(payload, signature, publicKey)) { return res.status(403).json({ error: 'Invalid federation signature' }); } const { room_uids } = payload; if (!Array.isArray(room_uids) || room_uids.length === 0 || room_uids.length > 100) { return res.status(400).json({ error: 'room_uids must be an array of 1-100 UIDs' }); } const db = getDb(); const result = {}; for (const uid of room_uids) { if (typeof uid !== 'string' || uid.length > 100) continue; const room = await db.get('SELECT uid, name, max_participants, record_meeting FROM rooms WHERE uid = ?', [uid]); if (room) { result[uid] = { room_name: room.name, max_participants: room.max_participants ?? 0, allow_recording: room.record_meeting ?? 1, deleted: false, }; } else { result[uid] = { deleted: true }; } } res.json({ rooms: result }); } catch (err) { log.federation.error('Room-sync error:', err); res.status(500).json({ error: 'Failed to process room sync request' }); } }); // ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─ router.post('/calendar-event-deleted', federationReceiveLimiter, 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 originDomain = req.headers['x-federation-origin']; const payload = req.body || {}; if (!signature || !originDomain) { return res.status(401).json({ error: 'Missing federation signature or origin' }); } const { publicKey } = await discoverInstance(originDomain); if (!publicKey || !verifyPayload(payload, signature, publicKey)) { return res.status(403).json({ error: 'Invalid federation signature' }); } const { event_uid } = payload; if (!event_uid || typeof event_uid !== 'string') { return res.status(400).json({ error: 'event_uid is required' }); } const db = getDb(); // Escape LIKE special characters in originDomain to prevent wildcard injection. const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&'); // Collect all affected users before deleting (for email notifications) let affectedUsers = []; try { // Users with pending/declined invitations const invUsers = await db.all( `SELECT u.email, u.name, u.language, ci.title, ci.from_user FROM calendar_invitations ci JOIN users u ON ci.to_user_id = u.id WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`, [event_uid, `%@${safeDomain}`] ); // Users who already accepted (event in their calendar) const calUsers = await db.all( `SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`, [event_uid, `%@${safeDomain}`] ); // Merge, deduplicate by email const seen = new Set(); for (const row of [...invUsers, ...calUsers]) { if (row.email && !seen.has(row.email)) { seen.add(row.email); affectedUsers.push(row); } } } catch { /* non-fatal */ } // Remove from calendar_invitations for all users on this instance await db.run( `DELETE FROM calendar_invitations WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`, [event_uid, `%@${safeDomain}`] ); // Remove from calendar_events (accepted invitations) for all users on this instance await db.run( `DELETE FROM calendar_events WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`, [event_uid, `%@${safeDomain}`] ); log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`); // Notify affected users by email (fire-and-forget) if (affectedUsers.length > 0) { const appName = process.env.APP_NAME || 'Redlight'; for (const u of affectedUsers) { sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en') .catch(mailErr => { log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`); }); } } res.json({ success: true }); } catch (err) { log.federation.error('Calendar-event-deleted error:', err); res.status(500).json({ error: 'Failed to process calendar event deletion' }); } }); // ── POST /api/federation/room-deleted — Receive deletion notification ─────── // Origin instance pushes this to notify that a room has been deleted. router.post('/room-deleted', federationReceiveLimiter, 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 originDomain = req.headers['x-federation-origin']; const payload = req.body || {}; if (!signature || !originDomain) { return res.status(401).json({ error: 'Missing federation signature or origin' }); } const { publicKey } = await discoverInstance(originDomain); if (!publicKey || !verifyPayload(payload, signature, publicKey)) { return res.status(403).json({ error: 'Invalid federation signature' }); } const { room_uid } = payload; if (!room_uid || typeof room_uid !== 'string') { return res.status(400).json({ error: 'room_uid is required' }); } const db = getDb(); // Escape LIKE special characters in originDomain to prevent wildcard injection. const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&'); // Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted await db.run( `UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`, [room_uid, `%@${safeDomain}`] ); log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`); res.json({ success: true }); } catch (err) { log.federation.error('Room-deleted error:', err); res.status(500).json({ error: 'Failed to process deletion notification' }); } }); export default router;