All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
feat(federation): escape LIKE special characters in originDomain to prevent wildcard injection feat(oauth): redirect with token in hash fragment to avoid exposure in logs feat(OAuthCallback): retrieve token from hash fragment for improved security
691 lines
27 KiB
JavaScript
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: '1.4.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;
|