import type { FastifyInstance } from 'fastify'; import { randomBytes } from 'node:crypto'; import { eq, and, or, isNull } from 'drizzle-orm'; import { authenticate, requireAdmin } from '../utils/auth.js'; import { generateHmacSecret, getOurOrigin, parseFederationHeaders, verifySignature } from '../utils/federationAuth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { getDb, getRawDb, schema } from '../db/index.js'; import { config } from '../config.js'; import { connectionManager } from '../ws/handler.js'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js'; import { getDmMessageWithUser } from './dm.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser } from '@backspace/shared'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; lastSyncedAt: number | null; createdAt: number; } function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer { return { id: row.id, origin: row.origin, instanceName: row.instanceName, status: row.status, lastSeenAt: row.lastSeenAt, lastFailureAt: row.lastFailureAt, consecutiveFailures: row.consecutiveFailures, lastSyncedAt: row.lastSyncedAt, createdAt: row.createdAt, }; } /** * Determine this instance's public origin. * Prefer the explicit DOMAIN env var; fall back to the request Host header. */ function resolveLocalOrigin(request: { headers: Record }): string { if (config.domain) { return `https://${config.domain}`; } const hostHeader = request.headers.host; const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; if (!host) { throw new Error('Cannot determine local origin: no DOMAIN configured and no Host header'); } const protocol = (request as Record).protocol === 'https' ? 'https' : 'http'; return `${protocol}://${host}`; } /** * Validate that a string is a well-formed HTTP(S) URL origin. * Returns the normalized origin (no trailing slash) or null if invalid. */ function validateOrigin(raw: string): string | null { try { const url = new URL(raw); if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; // origin is scheme + host (+ port if non-default) — no trailing slash return url.origin; } catch { return null; } } // ─── In-memory rate limiter for the accept endpoint ────────────────────────── const acceptRateBuckets = new Map(); const ACCEPT_RATE_WINDOW_MS = 60_000; const ACCEPT_RATE_MAX = 10; function isAcceptRateLimited(ip: string): boolean { const now = Date.now(); let timestamps = acceptRateBuckets.get(ip); if (!timestamps) { timestamps = []; acceptRateBuckets.set(ip, timestamps); } // Prune entries outside the window const cutoff = now - ACCEPT_RATE_WINDOW_MS; while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { timestamps.shift(); } if (timestamps.length >= ACCEPT_RATE_MAX) { return true; } timestamps.push(now); return false; } // Periodically clean stale buckets to prevent unbounded memory growth setInterval(() => { const cutoff = Date.now() - ACCEPT_RATE_WINDOW_MS; for (const [ip, timestamps] of acceptRateBuckets) { // Remove expired entries while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { timestamps.shift(); } if (timestamps.length === 0) { acceptRateBuckets.delete(ip); } } }, ACCEPT_RATE_WINDOW_MS).unref(); export async function federationRoutes(app: FastifyInstance): Promise { // ─── POST /api/federation/peer/initiate ──────────────────────────────────── // Admin-only: start a peering handshake with a remote instance. app.post<{ Body: { remoteOrigin: string } }>( '/api/federation/peer/initiate', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { const { remoteOrigin: rawOrigin } = request.body ?? {}; if (!rawOrigin || typeof rawOrigin !== 'string') { return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 }); } const remoteOrigin = validateOrigin(rawOrigin); if (!remoteOrigin) { return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTP/HTTPS URL', statusCode: 400 }); } const db = getDb(); // Check if a peer already exists for this origin const existing = db .select() .from(schema.federationPeers) .where(eq(schema.federationPeers.origin, remoteOrigin)) .get(); if (existing) { if (existing.status === 'active') { return reply.code(200).send({ peer: sanitizePeer(existing) }); } if (existing.status === 'pending') { return reply.code(409).send({ error: 'A peering handshake with this instance is already in progress', statusCode: 409, }); } // If revoked, allow re-initiation by removing the old record if (existing.status === 'revoked') { db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, existing.id)).run(); } } let localOrigin: string; try { localOrigin = resolveLocalOrigin(request); } catch { return reply.code(500).send({ error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.', statusCode: 500, }); } // Prevent self-peering if (localOrigin === remoteOrigin) { return reply.code(400).send({ error: 'Cannot peer with yourself', statusCode: 400 }); } const hmacSecret = generateHmacSecret(); const challenge = randomBytes(16).toString('hex'); const peerId = generateSnowflake(); const now = Date.now(); // Store peer as pending db.insert(schema.federationPeers).values({ id: peerId, origin: remoteOrigin, hmacSecret, status: 'pending', createdAt: now, }).run(); // Initiate the server-to-server handshake try { const response = await fetch(`${remoteOrigin}/api/federation/peer/accept`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceOrigin: localOrigin, challenge, hmacSecret, }), signal: AbortSignal.timeout(10_000), }); if (!response.ok) { let errorMessage = `Remote instance rejected peering (HTTP ${response.status})`; try { const body = await response.json() as { error?: string }; if (body.error) { errorMessage = body.error; } } catch { // Ignore parse failures — use the default error message } // Clean up the pending peer db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run(); return reply.code(502).send({ error: errorMessage, statusCode: 502 }); } // Remote accepted — activate the peer db.update(schema.federationPeers) .set({ status: 'active', lastSeenAt: Date.now() }) .where(eq(schema.federationPeers.id, peerId)) .run(); const peer = db .select() .from(schema.federationPeers) .where(eq(schema.federationPeers.id, peerId)) .get(); if (!peer) { return reply.code(500).send({ error: 'Failed to read peer after activation', statusCode: 500 }); } return reply.code(200).send({ peer: sanitizePeer(peer) }); } catch (err: unknown) { // Clean up the pending peer on network/timeout errors db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run(); const message = err instanceof Error ? err.message : 'Unknown error'; if (err instanceof DOMException && err.name === 'TimeoutError') { return reply.code(504).send({ error: 'Remote instance did not respond within 10 seconds', statusCode: 504, }); } return reply.code(502).send({ error: `Failed to reach remote instance: ${message}`, statusCode: 502, }); } }, ); // ─── POST /api/federation/peer/accept ────────────────────────────────────── // Server-to-server: accept a peering request from a remote instance. // No JWT auth — this is first contact. Rate-limited by IP. app.post<{ Body: { sourceOrigin: string; challenge: string; hmacSecret: string } }>( '/api/federation/peer/accept', async (request, reply) => { const clientIp = request.ip; if (isAcceptRateLimited(clientIp)) { return reply.code(429).send({ error: 'Too many peering requests — try again later', statusCode: 429, }); } const { sourceOrigin: rawOrigin, challenge, hmacSecret } = request.body ?? {}; if (!rawOrigin || typeof rawOrigin !== 'string') { return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 }); } if (!challenge || typeof challenge !== 'string') { return reply.code(400).send({ error: 'challenge is required', statusCode: 400 }); } if (!hmacSecret || typeof hmacSecret !== 'string') { return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 }); } const sourceOrigin = validateOrigin(rawOrigin); if (!sourceOrigin) { return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTP/HTTPS URL', statusCode: 400 }); } const db = getDb(); // Check if peer already exists const existing = db .select() .from(schema.federationPeers) .where(eq(schema.federationPeers.origin, sourceOrigin)) .get(); if (existing) { if (existing.status === 'active') { // Idempotent — already peered return reply.code(200).send({ accepted: true }); } if (existing.status === 'revoked') { return reply.code(403).send({ error: 'Peering with this instance has been revoked', statusCode: 403, }); } // Pending — update with new secret and activate db.update(schema.federationPeers) .set({ hmacSecret, status: 'active', lastSeenAt: Date.now(), }) .where(eq(schema.federationPeers.id, existing.id)) .run(); return reply.code(200).send({ accepted: true }); } // New peer — create and activate const peerId = generateSnowflake(); db.insert(schema.federationPeers).values({ id: peerId, origin: sourceOrigin, hmacSecret, status: 'active', lastSeenAt: Date.now(), createdAt: Date.now(), }).run(); return reply.code(200).send({ accepted: true }); }, ); // ─── GET /api/federation/peers ───────────────────────────────────────────── // Admin-only: list all federation peers (hmacSecret excluded). app.get( '/api/federation/peers', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => { const db = getDb(); const peers = db .select() .from(schema.federationPeers) .all(); return reply.code(200).send({ peers: peers.map(sanitizePeer) }); }, ); // ─── DELETE /api/federation/peers/:id ────────────────────────────────────── // Admin-only: revoke a federation peer and clean up its outbox. app.delete<{ Params: { id: string } }>( '/api/federation/peers/:id', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { const { id } = request.params; const db = getDb(); const peer = db .select() .from(schema.federationPeers) .where(eq(schema.federationPeers.id, id)) .get(); if (!peer) { return reply.code(404).send({ error: 'Peer not found', statusCode: 404 }); } // Revoke the peer db.update(schema.federationPeers) .set({ status: 'revoked' }) .where(eq(schema.federationPeers.id, id)) .run(); // Delete all outbox entries for this peer db.delete(schema.federationOutbox) .where(eq(schema.federationOutbox.peerId, id)) .run(); return reply.code(200).send({ success: true }); }, ); // ─── POST /api/federation/relay ──────────────────────────────────────────── // Server-to-server: receive relayed DM events from a peer instance. // Authenticated via HMAC-SHA256 signature, NOT JWT. app.post<{ Body: FederationRelayRequest }>( '/api/federation/relay', { bodyLimit: 10 * 1024 * 1024 }, async (request, reply) => { const db = getDb(); // 1. Verify HMAC signature const fedHeaders = parseFederationHeaders(request.headers as Record); if (!fedHeaders) { return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); } const peer = db .select() .from(schema.federationPeers) .where(eq(schema.federationPeers.origin, fedHeaders.origin)) .get(); if (!peer || peer.status !== 'active') { return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 }); } // Serialize body back to JSON for HMAC verification (we control both sides) const bodyString = JSON.stringify(request.body); if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) { return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 }); } // 2. Validate request body shape const body = request.body; if (!body || body.version !== 1 || !Array.isArray(body.events)) { return reply.code(400).send({ error: 'Invalid relay request format', statusCode: 400 }); } if (body.events.length > 50) { return reply.code(400).send({ error: 'Maximum 50 events per batch', statusCode: 400 }); } const sourceInstance = body.sourceInstance; if (!sourceInstance || typeof sourceInstance !== 'string') { return reply.code(400).send({ error: 'sourceInstance is required', statusCode: 400 }); } // 3. Process each event const accepted: string[] = []; const rejected: Array<{ messageId: string; reason: string }> = []; for (const event of body.events) { try { switch (event.eventType) { case 'create': processCreateEvent(event, sourceInstance, peer.origin, db, accepted, rejected); break; case 'update': processUpdateEvent(event, sourceInstance, db, accepted, rejected); break; case 'delete': processDeleteEvent(event, sourceInstance, db, accepted, rejected); break; case 'reaction_add': processReactionAddEvent(event, sourceInstance, db, accepted, rejected); break; case 'reaction_remove': processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected); break; default: rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' }); break; } } catch (err) { const errMsg = err instanceof Error ? err.message : 'unknown_error'; console.error(`[federation-relay] Error processing event ${event.messageId}:`, errMsg); rejected.push({ messageId: event.messageId, reason: 'processing_error' }); } } // 4. Update peer status db.update(schema.federationPeers) .set({ lastSeenAt: Date.now(), consecutiveFailures: 0, }) .where(eq(schema.federationPeers.id, peer.id)) .run(); // 5. Return response with max upload size info const settings = db .select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes }) .from(schema.instanceSettings) .where(eq(schema.instanceSettings.id, 1)) .get(); const response: FederationRelayResponse = { accepted, rejected, maxUploadSize: settings?.maxUploadSizeBytes ?? config.maxUploadSize, }; return reply.code(200).send(response); }, ); // ─── POST /api/federation/sync ────────────────────────────────────────────── // Server-to-server: checkpoint catch-up sync. A peer calls this after downtime // to retrieve missed DM mutations from the mutation log. // Authenticated via HMAC-SHA256 signature, same as /relay. app.post<{ Body: FederationSyncRequest }>( '/api/federation/sync', { bodyLimit: 1024 * 64 }, async (request, reply) => { const db = getDb(); const rawDb = getRawDb(); // 1. Verify HMAC signature const fedHeaders = parseFederationHeaders(request.headers as Record); if (!fedHeaders) { return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); } const peer = db .select() .from(schema.federationPeers) .where(eq(schema.federationPeers.origin, fedHeaders.origin)) .get(); if (!peer || peer.status !== 'active') { return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 }); } const bodyString = JSON.stringify(request.body); if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) { return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 }); } // 2. Validate & normalize request body const body = request.body; if (!body || typeof body.sinceTimestamp !== 'number' || body.sinceTimestamp < 0) { return reply.code(400).send({ error: 'sinceTimestamp must be a non-negative number', statusCode: 400 }); } const sinceTimestamp = body.sinceTimestamp; const dmChannelIdFilter = body.dmChannelId && typeof body.dmChannelId === 'string' ? body.dmChannelId : null; // Clamp limit: min 1, max 500, default 100 let limit = typeof body.limit === 'number' ? body.limit : 100; limit = Math.max(1, Math.min(500, Math.floor(limit))); // 3. Determine which DM channels to sync. // Use federated_id: any channel with a federated ID is a federated DM // that should be synced. The peer's relay endpoint will create the channel // if it doesn't exist, or match by federated_id if it does. const sharedChannelRows = rawDb.prepare(` SELECT id as dm_channel_id FROM dm_channels WHERE federated_id IS NOT NULL AND deleted_at IS NULL `).all() as Array<{ dm_channel_id: string }>; const sharedChannelIds = sharedChannelRows.map(r => r.dm_channel_id); if (sharedChannelIds.length === 0) { const syncResponse: FederationSyncResponse = { events: [], hasMore: false, checkpoint: sinceTimestamp, }; return reply.code(200).send(syncResponse); } // 4. Query mutation log for the relevant channels // Return mutations for ALL locally-created messages (source_instance IS NULL). // This includes messages by replicated users (e.g., Jannis browsing orbit) // because they were created on THIS instance and need to be synced to the peer. let mutationRows: Array<{ id: string; dm_message_id: string; dm_channel_id: string; mutation_type: string; mutated_at: number; payload: string | null; }>; if (dmChannelIdFilter) { // Validate that the requested channel is actually shared with this peer if (!sharedChannelIds.includes(dmChannelIdFilter)) { const syncResponse: FederationSyncResponse = { events: [], hasMore: false, checkpoint: sinceTimestamp, }; return reply.code(200).send(syncResponse); } mutationRows = rawDb.prepare(` SELECT ml.id, ml.dm_message_id, ml.dm_channel_id, ml.mutation_type, ml.mutated_at, ml.payload FROM federation_mutation_log ml JOIN dm_messages dm ON ml.dm_message_id = dm.id WHERE ml.dm_channel_id = ? AND ml.mutated_at > ? ORDER BY ml.mutated_at ASC LIMIT ? `).all(dmChannelIdFilter, sinceTimestamp, limit) as typeof mutationRows; // For delete mutations, the dm_messages row won't exist — handle separately const deleteMutations = rawDb.prepare(` SELECT ml.id, ml.dm_message_id, ml.dm_channel_id, ml.mutation_type, ml.mutated_at, ml.payload FROM federation_mutation_log ml WHERE ml.dm_channel_id = ? AND ml.mutated_at > ? AND ml.mutation_type = 'delete' AND ml.dm_message_id NOT IN (SELECT dm.id FROM dm_messages dm WHERE dm.id = ml.dm_message_id) ORDER BY ml.mutated_at ASC LIMIT ? `).all(dmChannelIdFilter, sinceTimestamp, limit) as typeof mutationRows; // Merge, deduplicate, sort, and re-limit const seen = new Set(mutationRows.map(r => r.id)); for (const row of deleteMutations) { if (!seen.has(row.id)) { mutationRows.push(row); seen.add(row.id); } } mutationRows.sort((a, b) => a.mutated_at - b.mutated_at); if (mutationRows.length > limit) { mutationRows = mutationRows.slice(0, limit); } } else { // All shared channels — build IN clause with placeholders const placeholders = sharedChannelIds.map(() => '?').join(','); mutationRows = rawDb.prepare(` SELECT ml.id, ml.dm_message_id, ml.dm_channel_id, ml.mutation_type, ml.mutated_at, ml.payload FROM federation_mutation_log ml JOIN dm_messages dm ON ml.dm_message_id = dm.id WHERE ml.dm_channel_id IN (${placeholders}) AND ml.mutated_at > ? ORDER BY ml.mutated_at ASC LIMIT ? `).all(...sharedChannelIds, sinceTimestamp, limit) as typeof mutationRows; // For delete mutations, the dm_messages row won't exist — handle separately const deleteMutations = rawDb.prepare(` SELECT ml.id, ml.dm_message_id, ml.dm_channel_id, ml.mutation_type, ml.mutated_at, ml.payload FROM federation_mutation_log ml WHERE ml.dm_channel_id IN (${placeholders}) AND ml.mutated_at > ? AND ml.mutation_type = 'delete' AND ml.dm_message_id NOT IN (SELECT dm.id FROM dm_messages dm WHERE dm.id = ml.dm_message_id) ORDER BY ml.mutated_at ASC LIMIT ? `).all(...sharedChannelIds, sinceTimestamp, limit) as typeof mutationRows; // Merge, deduplicate, sort, and re-limit const seen = new Set(mutationRows.map(r => r.id)); for (const row of deleteMutations) { if (!seen.has(row.id)) { mutationRows.push(row); seen.add(row.id); } } mutationRows.sort((a, b) => a.mutated_at - b.mutated_at); if (mutationRows.length > limit) { mutationRows = mutationRows.slice(0, limit); } } // 5. Build response events from mutation log entries const events: FederationRelayEvent[] = []; for (const mutation of mutationRows) { const mutationType = mutation.mutation_type as 'create' | 'update' | 'delete' | 'reaction_add' | 'reaction_remove'; if (mutationType === 'delete') { // For deletes, we don't need the message content — just the ID and channel events.push({ eventType: 'delete', dmChannelId: mutation.dm_channel_id, messageId: mutation.dm_message_id, encryptionVersion: 0, timestamp: mutation.mutated_at, }); continue; } if (mutationType === 'reaction_add' || mutationType === 'reaction_remove') { // Use the stored payload from the mutation log if (mutation.payload) { let reactionData: { userId: string; homeUserId: string; emoji: string; createdAt?: number } | null = null; try { reactionData = JSON.parse(mutation.payload) as { userId: string; homeUserId: string; emoji: string; createdAt?: number }; } catch { // Skip malformed payload continue; } events.push({ eventType: mutationType, dmChannelId: mutation.dm_channel_id, messageId: mutation.dm_message_id, encryptionVersion: 0, timestamp: mutation.mutated_at, reaction: { userId: reactionData.userId, homeUserId: reactionData.homeUserId, emoji: reactionData.emoji, createdAt: reactionData.createdAt ?? mutation.mutated_at, }, }); } continue; } // For create and update: fetch the current message state const message = db .select() .from(schema.dmMessages) .where(eq(schema.dmMessages.id, mutation.dm_message_id)) .get(); if (!message) { // Message was deleted after this create/update mutation was logged — skip it. // The delete mutation will handle the cleanup on the peer side. continue; } // Resolve the author user to get homeUserId and homeInstance const authorUser = db .select() .from(schema.users) .where(eq(schema.users.id, message.userId)) .get(); if (!authorUser) { continue; } const homeUserId = authorUser.homeUserId || authorUser.id; const homeInstance = authorUser.homeInstance || (config.domain ? `https://${config.domain}` : ''); // Fetch attachments for the message const attachmentRows = db .select() .from(schema.attachments) .where(eq(schema.attachments.dmMessageId, message.id)) .all(); let localOrigin: string; try { localOrigin = resolveLocalOrigin(request); } catch { localOrigin = config.domain ? `https://${config.domain}` : ''; } const attachments: FederationRelayAttachment[] = attachmentRows.map(a => ({ id: a.id, filename: a.filename, originalName: a.originalName, mimetype: a.mimetype, size: a.size, width: a.width ?? undefined, height: a.height ?? undefined, duration: a.duration ?? undefined, thumbnailFilename: a.thumbnailFilename ?? undefined, sourceUrl: `${localOrigin}/api/uploads/${a.filename}`, })); events.push({ eventType: mutationType, dmChannelId: mutation.dm_channel_id, messageId: message.id, encryptionVersion: 0, timestamp: mutation.mutated_at, participants: getDmParticipants(mutation.dm_channel_id), message: { userId: message.userId, homeUserId, homeInstance, content: message.content, replyToId: message.replyToId ?? null, editedAt: message.editedAt ?? null, createdAt: message.createdAt, attachments: attachments.length > 0 ? attachments : undefined, }, }); } // 6. Compute pagination metadata const hasMore = mutationRows.length >= limit; const checkpoint = mutationRows.length > 0 ? mutationRows[mutationRows.length - 1]!.mutated_at : sinceTimestamp; // 7. Update peer last-seen timestamp db.update(schema.federationPeers) .set({ lastSeenAt: Date.now() }) .where(eq(schema.federationPeers.id, peer.id)) .run(); const syncResponse: FederationSyncResponse = { events, hasMore, checkpoint, }; return reply.code(200).send(syncResponse); }, ); } // ─── Relay Event Processors ────────────────────────────────────────────────── /** * Resolve a home user ID to a local user. * Matches users where home_user_id = homeUserId, or where * the user's own id equals homeUserId and they have no home_instance set (local user). */ function resolveLocalUser( homeUserId: string, db: ReturnType, ): typeof schema.users.$inferSelect | undefined { const candidates = db .select() .from(schema.users) .where( and( or( eq(schema.users.homeUserId, homeUserId), and(eq(schema.users.id, homeUserId), isNull(schema.users.homeInstance)), ), eq(schema.users.isDeleted, 0), ), ) .all(); // Prefer non-deleted active users; if multiple, prefer the one with homeUserId set // (replicated user) over a local user match if (candidates.length === 0) return undefined; if (candidates.length === 1) return candidates[0]; return candidates.find(u => u.homeUserId === homeUserId) ?? candidates[0]; } /** * Find or create a local DM channel for a federated DM. * Uses federated_id for deterministic cross-instance lookup. */ function findOrCreateDmChannel( federatedId: string, localUserIds: string[], db: ReturnType, ): string { // Try to find existing channel by federated ID const existing = db .select() .from(schema.dmChannels) .where(eq(schema.dmChannels.federatedId, federatedId)) .get(); if (existing) { // Ensure all users are members (they might have been removed) for (const userId of localUserIds) { const member = db .select() .from(schema.dmMembers) .where( and( eq(schema.dmMembers.dmChannelId, existing.id), eq(schema.dmMembers.userId, userId), ), ) .get(); if (!member) { db.insert(schema.dmMembers) .values({ dmChannelId: existing.id, userId, closed: 0, }) .run(); } } return existing.id; } // Create new DM channel with federated ID const channelId = generateSnowflake(); const now = Date.now(); db.insert(schema.dmChannels) .values({ id: channelId, federatedId, createdAt: now, }) .run(); for (const userId of localUserIds) { db.insert(schema.dmMembers) .values({ dmChannelId: channelId, userId, closed: 0, }) .run(); } return channelId; } /** * Build a DmMessageWithUser payload for WebSocket broadcasting. */ function buildDmMessagePayload( messageRow: { id: string; dmChannelId: string; userId: string; content: string | null; replyToId: string | null; editedAt: number | null; createdAt: number; }, userRow: typeof schema.users.$inferSelect, ): DmMessageWithUser { return { id: messageRow.id, dmChannelId: messageRow.dmChannelId, channelId: messageRow.dmChannelId, userId: messageRow.userId, content: messageRow.content, replyToId: messageRow.replyToId, editedAt: messageRow.editedAt, createdAt: messageRow.createdAt, user: sanitizeUser(userRow), attachments: [], embeds: [], reactions: [], }; } /** * Validate that a URL's hostname matches the peer origin's hostname (SSRF protection). */ function isUrlFromPeer(sourceUrl: string, peerOrigin: string): boolean { try { const sourceHost = new URL(sourceUrl).hostname; const peerHost = new URL(peerOrigin).hostname; return sourceHost === peerHost; } catch { return false; } } function processCreateEvent( event: FederationRelayEvent, sourceInstance: string, peerOrigin: string, db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, ): void { if (!event.message) { rejected.push({ messageId: event.messageId, reason: 'missing_message_payload' }); return; } if (!event.participants || event.participants.length < 2) { rejected.push({ messageId: event.messageId, reason: 'missing_participants' }); return; } // Dedup: check for existing message with same source const existingMsg = db .select() .from(schema.dmMessages) .where( and( eq(schema.dmMessages.sourceInstance, sourceInstance), eq(schema.dmMessages.sourceMessageId, event.messageId), ), ) .get(); if (existingMsg) { rejected.push({ messageId: event.messageId, reason: 'duplicate' }); return; } // Resolve ALL participants to local users const resolvedParticipants: Array<{ localUser: typeof schema.users.$inferSelect; homeUserId: string; }> = []; for (const p of event.participants) { const localUser = resolveLocalUser(p.homeUserId, db); if (localUser) { resolvedParticipants.push({ localUser, homeUserId: p.homeUserId }); } } if (resolvedParticipants.length < 2) { rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); return; } // Find the author among the resolved participants const authorEntry = resolvedParticipants.find( p => p.homeUserId === event.message!.homeUserId, ); if (!authorEntry) { rejected.push({ messageId: event.messageId, reason: 'author_not_found' }); return; } const authorUser = authorEntry.localUser; // Compute federated ID from participants' home user IDs and find/create channel const federatedId = computeFederatedId( resolvedParticipants[0]!.homeUserId, resolvedParticipants[1]!.homeUserId, ); const localDmChannelId = findOrCreateDmChannel( federatedId, [resolvedParticipants[0]!.localUser.id, resolvedParticipants[1]!.localUser.id], db, ); // Insert the message const localMessageId = generateSnowflake(); db.insert(schema.dmMessages) .values({ id: localMessageId, dmChannelId: localDmChannelId, userId: authorUser.id, content: event.message.content, replyToId: null, createdAt: event.message.createdAt, editedAt: null, sourceInstance, sourceMessageId: event.messageId, encryptionVersion: 0, }) .run(); // Create attachment rows and queue file downloads (SSRF-validated). // Attachment rows are created immediately with filename = sourceUrl so the // initial WebSocket broadcast includes working remote URLs. The background // file worker will UPDATE the filename to the local path after download. if (event.message.attachments && event.message.attachments.length > 0) { const now = Date.now(); for (const attachment of event.message.attachments) { if (!isUrlFromPeer(attachment.sourceUrl, peerOrigin)) { console.warn( `[federation-relay] Rejecting attachment URL ${attachment.sourceUrl} — hostname does not match peer ${peerOrigin}`, ); continue; } // Create the attachment row with sourceUrl as the interim filename. // AttachmentRenderer already handles filenames starting with 'http' — // it uses them as direct URLs. When the file worker downloads the file, // it updates this row's filename to the local path. const attachmentId = generateSnowflake(); db.insert(schema.attachments) .values({ id: attachmentId, dmMessageId: localMessageId, uploaderId: null, filename: attachment.sourceUrl, originalName: attachment.originalName, mimetype: attachment.mimetype, size: attachment.size, width: attachment.width ?? null, height: attachment.height ?? null, duration: attachment.duration ?? null, thumbnailFilename: null, // Don't copy source thumbnail — it doesn't exist locally sourceUrl: attachment.sourceUrl, createdAt: now, }) .run(); // Queue the background file download db.insert(schema.federationFileQueue) .values({ id: generateSnowflake(), peerOrigin, dmMessageId: localMessageId, sourceUrl: attachment.sourceUrl, originalName: attachment.originalName, mimetype: attachment.mimetype, size: attachment.size, status: 'pending', nextRetryAt: now, expiresAt: now + 30 * 86_400_000, createdAt: now, }) .run(); } } // Broadcast to local WebSocket clients, but skip members whose home instance // is the source instance — they already have the original message via their // home instance's WebSocket connection. const fullMessage = getDmMessageWithUser(localMessageId); if (fullMessage) { const dmMembers = db.select() .from(schema.dmMembers) .where(eq(schema.dmMembers.dmChannelId, localDmChannelId)) .all(); for (const member of dmMembers) { const memberUser = db.select() .from(schema.users) .where(eq(schema.users.id, member.userId)) .get(); // Skip members whose home instance is the source — they already have this message if (memberUser?.homeInstance === sourceInstance) continue; connectionManager.sendToUser(member.userId, { type: 'dm_message_created', message: fullMessage, }); } } accepted.push(event.messageId); } function processUpdateEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, ): void { const localMsg = db .select() .from(schema.dmMessages) .where( and( eq(schema.dmMessages.sourceInstance, sourceInstance), eq(schema.dmMessages.sourceMessageId, event.messageId), ), ) .get(); if (!localMsg) { rejected.push({ messageId: event.messageId, reason: 'unknown_message' }); return; } const content = event.message?.content ?? null; const editedAt = event.message?.editedAt ?? Date.now(); db.update(schema.dmMessages) .set({ content, editedAt }) .where(eq(schema.dmMessages.id, localMsg.id)) .run(); // Broadcast update to local clients const authorUser = db .select() .from(schema.users) .where(eq(schema.users.id, localMsg.userId)) .get(); if (authorUser) { const updatedPayload = buildDmMessagePayload( { id: localMsg.id, dmChannelId: localMsg.dmChannelId, userId: localMsg.userId, content, replyToId: localMsg.replyToId, editedAt, createdAt: localMsg.createdAt, }, authorUser, ); // Re-fetch reactions and attachments for the complete payload const reactions = db .select() .from(schema.dmReactions) .where(eq(schema.dmReactions.dmMessageId, localMsg.id)) .all(); const attachments = db .select() .from(schema.attachments) .where(eq(schema.attachments.dmMessageId, localMsg.id)) .all(); updatedPayload.reactions = reactions.map(r => ({ id: r.id, messageId: r.dmMessageId, userId: r.userId, emoji: r.emoji, createdAt: r.createdAt, })); updatedPayload.attachments = attachments.map(a => ({ id: a.id, messageId: a.dmMessageId ?? a.messageId ?? '', filename: a.filename, originalName: a.originalName, mimetype: a.mimetype, size: a.size, thumbnailFilename: a.thumbnailFilename, width: a.width, height: a.height, duration: a.duration, createdAt: a.createdAt, })); connectionManager.sendToDmMembers(localMsg.dmChannelId, { type: 'dm_message_updated', message: updatedPayload, }); } accepted.push(event.messageId); } function processDeleteEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, ): void { const localMsg = db .select() .from(schema.dmMessages) .where( and( eq(schema.dmMessages.sourceInstance, sourceInstance), eq(schema.dmMessages.sourceMessageId, event.messageId), ), ) .get(); if (!localMsg) { rejected.push({ messageId: event.messageId, reason: 'unknown_message' }); return; } // Collect attachment filenames before deletion for disk cleanup const attachmentRows = db .select({ filename: schema.attachments.filename }) .from(schema.attachments) .where(eq(schema.attachments.dmMessageId, localMsg.id)) .all(); // Delete attachments, reactions, and message atomically db.transaction((tx) => { tx.delete(schema.attachments) .where(eq(schema.attachments.dmMessageId, localMsg.id)) .run(); tx.delete(schema.dmReactions) .where(eq(schema.dmReactions.dmMessageId, localMsg.id)) .run(); tx.delete(schema.dmMessages) .where(eq(schema.dmMessages.id, localMsg.id)) .run(); }); // Clean up files from disk deleteAttachmentFiles(attachmentRows); // Broadcast deletion to local clients connectionManager.sendToDmMembers(localMsg.dmChannelId, { type: 'dm_message_deleted', messageId: localMsg.id, dmChannelId: localMsg.dmChannelId, }); accepted.push(event.messageId); } /** * Resolve a local DM message from a federation relay event's canonical identity. * Uses messageHomeInstance to branch the lookup: * - If the message originated on THIS instance → find by local ID * - Otherwise → find by sourceInstance + sourceMessageId tracking * Falls back to relay sender origin when messageHomeInstance is absent (backward compat). */ function resolveLocalDmMessage( canonicalMessageId: string, messageHomeInstance: string | undefined, sourceInstance: string, db: ReturnType, ): typeof schema.dmMessages.$inferSelect | undefined { if (messageHomeInstance && messageHomeInstance === getOurOrigin()) { return db .select() .from(schema.dmMessages) .where( and( eq(schema.dmMessages.id, canonicalMessageId), isNull(schema.dmMessages.sourceInstance), ), ) .get(); } const originInstance = messageHomeInstance || sourceInstance; return db .select() .from(schema.dmMessages) .where( and( eq(schema.dmMessages.sourceInstance, originInstance), eq(schema.dmMessages.sourceMessageId, canonicalMessageId), ), ) .get(); } function processReactionAddEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, ): void { if (!event.reaction) { rejected.push({ messageId: event.messageId, reason: 'missing_reaction_payload' }); return; } const canonicalMessageId = event.reaction.messageId ?? event.messageId; const localMsg = resolveLocalDmMessage( canonicalMessageId, event.reaction.messageHomeInstance, sourceInstance, db, ); if (!localMsg) { rejected.push({ messageId: event.messageId, reason: 'unknown_message' }); return; } // Resolve the reacting user const reactingUser = resolveLocalUser(event.reaction.homeUserId, db); if (!reactingUser) { rejected.push({ messageId: event.messageId, reason: 'user_not_found' }); return; } // Dedup: check if this user already reacted with this emoji const existingReaction = db .select() .from(schema.dmReactions) .where( and( eq(schema.dmReactions.dmMessageId, localMsg.id), eq(schema.dmReactions.userId, reactingUser.id), eq(schema.dmReactions.emoji, event.reaction.emoji), ), ) .get(); if (existingReaction) { // Already exists — treat as accepted (idempotent) accepted.push(event.messageId); return; } const reactionId = generateSnowflake(); const now = event.reaction.createdAt || Date.now(); db.insert(schema.dmReactions) .values({ id: reactionId, dmMessageId: localMsg.id, userId: reactingUser.id, emoji: event.reaction.emoji, createdAt: now, }) .run(); // Broadcast to local clients connectionManager.sendToDmMembers(localMsg.dmChannelId, { type: 'reaction_added', messageId: localMsg.id, reaction: { id: reactionId, messageId: localMsg.id, userId: reactingUser.id, emoji: event.reaction.emoji, createdAt: now, user: sanitizeUser(reactingUser), }, }); accepted.push(event.messageId); } function processReactionRemoveEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, ): void { if (!event.reaction) { rejected.push({ messageId: event.messageId, reason: 'missing_reaction_payload' }); return; } const canonicalMessageId = event.reaction.messageId ?? event.messageId; const localMsg = resolveLocalDmMessage( canonicalMessageId, event.reaction.messageHomeInstance, sourceInstance, db, ); if (!localMsg) { rejected.push({ messageId: event.messageId, reason: 'unknown_message' }); return; } // Resolve the reacting user const reactingUser = resolveLocalUser(event.reaction.homeUserId, db); if (!reactingUser) { rejected.push({ messageId: event.messageId, reason: 'user_not_found' }); return; } const result = db .delete(schema.dmReactions) .where( and( eq(schema.dmReactions.dmMessageId, localMsg.id), eq(schema.dmReactions.userId, reactingUser.id), eq(schema.dmReactions.emoji, event.reaction.emoji), ), ) .run(); if (result.changes > 0) { connectionManager.sendToDmMembers(localMsg.dmChannelId, { type: 'reaction_removed', messageId: localMsg.id, userId: reactingUser.id, emoji: event.reaction.emoji, }); } accepted.push(event.messageId); }