import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { sendFederationInviteEmail } from '../config/mailer.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: '1.1.0', }); } // ── 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 const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const joinUrl = `${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}`); } res.json({ success: true, invite_id: inviteId }); } catch (err) { console.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' }); } // 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 RSA 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 = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; 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 ).catch(mailErr => { console.warn('Federation invite mail failed (non-fatal):', mailErr.message); }); } res.json({ success: true }); } catch (err) { console.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) { console.error('List federation 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 result = await db.get( `SELECT COUNT(*) as count FROM federation_invitations WHERE to_user_id = ? AND status = 'pending'`, [req.user.id] ); res.json({ count: result?.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) { console.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) { console.error('Decline invitation error:', err); res.status(500).json({ error: 'Failed to decline 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) { console.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) { console.error('Delete federated room error:', err); res.status(500).json({ error: 'Failed to remove room' }); } }); export default router;