Files
redlight/server/routes/federation.js
Michelle eed5d98ccc
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m9s
chore: update dependencies for Vite and React plugin
2026-03-13 22:48:08 +01:00

691 lines
27 KiB
JavaScript

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.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
// 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;