All checks were successful
Build & Push Docker Image / build (push) Successful in 6m0s
266 lines
9.4 KiB
JavaScript
266 lines
9.4 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 {
|
|
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;
|