import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; 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, 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), }); 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', 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, 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 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] ); 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] ); 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' }); } }); export default router;