feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.
This commit is contained in:
262
server/routes/federation.js
Normal file
262
server/routes/federation.js
Normal file
@@ -0,0 +1,262 @@
|
||||
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' });
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user