All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
- Escape XML and HTML special characters to prevent injection attacks. - Implement rate limiting for various endpoints to mitigate abuse. - Add validation for email formats, password lengths, and field limits. - Ensure proper access control for recordings and room management.
358 lines
14 KiB
JavaScript
358 lines
14 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 } 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;
|