Files
redlight/server/routes/federation.js
Miichelle e5b6c225e9
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m58s
New federation features
2026-02-27 15:24:18 +01:00

325 lines
12 KiB
JavaScript

import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { sendFederationInviteEmail } from '../config/mailer.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, 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]
);
// Send notification email (fire-and-forget, don't fail the request if mail fails)
try {
const appUrl = process.env.APP_URL || '';
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
await 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)
VALUES (?, ?, ?, ?, ?)`,
[req.user.id, invitation.invite_id, invitation.room_name, invitation.from_user, invitation.join_url]
);
}
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;