diff --git a/packages/federation-sdk/src/repositories/event.repository.ts b/packages/federation-sdk/src/repositories/event.repository.ts index 03b6a090a..a9bdb55a3 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -13,6 +13,7 @@ import type { Collection, FindCursor, FindOptions, + InsertOneResult, UpdateResult, WithId, } from 'mongodb'; @@ -405,6 +406,23 @@ export class EventRepository { ); } + insertOutlierEvent( + eventId: EventID, + event: Pdu, + origin: string, + ): Promise> { + return this.collection.insertOne({ + _id: eventId, + event, + createdAt: new Date(), + origin, + stateId: '' as StateID, + nextEventId: '', + outlier: true, + partial: false, + }); + } + async updateNextEventReferences( newEventId: EventID, previousEventIds: EventID[], diff --git a/packages/federation-sdk/src/sdk.ts b/packages/federation-sdk/src/sdk.ts index 0b3093f85..7ea2118a1 100644 --- a/packages/federation-sdk/src/sdk.ts +++ b/packages/federation-sdk/src/sdk.ts @@ -83,6 +83,14 @@ export class FederationSDK { return this.eventService.getEventById(...args); } + makeLeave(...args: Parameters) { + return this.roomService.makeLeave(...args); + } + + sendLeave(...args: Parameters) { + return this.roomService.sendLeave(...args); + } + leaveRoom(...args: Parameters) { return this.roomService.leaveRoom(...args); } @@ -141,10 +149,21 @@ export class FederationSDK { return this.eventAuthorizationService.verifyRequestSignature(...args); } + /** + * @deprecated + */ joinUser(...args: Parameters) { return this.roomService.joinUser(...args); } + acceptInvite(...args: Parameters) { + return this.roomService.acceptInvite(...args); + } + + rejectInvite(...args: Parameters) { + return this.roomService.rejectInvite(...args); + } + getLatestRoomState2( ...args: Parameters ) { diff --git a/packages/federation-sdk/src/services/federation.service.ts b/packages/federation-sdk/src/services/federation.service.ts index 911f350aa..fc7e7f860 100644 --- a/packages/federation-sdk/src/services/federation.service.ts +++ b/packages/federation-sdk/src/services/federation.service.ts @@ -98,6 +98,50 @@ export class FederationService { } } + async makeLeave( + domain: string, + roomId: string, + userId: string, + ): Promise<{ event: Pdu; room_version: string }> { + try { + const uri = FederationEndpoints.makeLeave(roomId, userId); + return await this.requestService.get<{ + event: Pdu; + room_version: string; + }>(domain, uri); + } catch (error: any) { + this.logger.error({ msg: 'makeLeave failed', err: error }); + throw error; + } + } + + async sendLeave(leaveEvent: PersistentEventBase): Promise { + try { + const uri = FederationEndpoints.sendLeave( + leaveEvent.roomId, + leaveEvent.eventId, + ); + + const residentServer = leaveEvent.roomId.split(':').pop(); + + if (!residentServer) { + this.logger.debug({ msg: 'invalid room_id', event: leaveEvent.event }); + throw new Error( + `invalid room_id ${leaveEvent.roomId}, no server_name part`, + ); + } + + await this.requestService.put( + residentServer, + uri, + leaveEvent.event, + ); + } catch (error: any) { + this.logger.error({ msg: 'sendLeave failed', err: error }); + throw error; + } + } + /** * Send a transaction to a remote server */ diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index 19117e496..8b971502a 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -1,4 +1,4 @@ -import { EventBase, createLogger } from '@rocket.chat/federation-core'; +import { createLogger } from '@rocket.chat/federation-core'; import { EventID, PduForType, @@ -9,24 +9,13 @@ import { UserID, extractDomainFromId, } from '@rocket.chat/federation-room'; -import { singleton } from 'tsyringe'; +import { delay, inject, singleton } from 'tsyringe'; +import { EventRepository } from '../repositories/event.repository'; import { ConfigService } from './config.service'; import { EventAuthorizationService } from './event-authorization.service'; import { EventEmitterService } from './event-emitter.service'; -import { EventService } from './event.service'; import { FederationService } from './federation.service'; -import { - RoomInfoNotReadyError, - StateService, - UnknownRoomError, -} from './state.service'; -// TODO: Have better (detailed/specific) event input type -export type ProcessInviteEvent = { - event: EventBase; - invite_room_state: unknown; - room_version: string; -}; - +import { StateService } from './state.service'; export class NotAllowedError extends Error { constructor(message: string) { super(message); @@ -44,6 +33,8 @@ export class InviteService { private readonly configService: ConfigService, private readonly eventAuthorizationService: EventAuthorizationService, private readonly emitterService: EventEmitterService, + @inject(delay(() => EventRepository)) + private readonly eventRepository: EventRepository, ) {} /** @@ -110,11 +101,6 @@ export class InviteService { // without it join events will not be processed if /event/{eventId} causes problems void federationService.sendEventToAllServersInRoom(inviteEvent); - this.emitterService.emit('homeserver.matrix.membership', { - event_id: inviteEvent.eventId, - event: inviteEvent.event, - }); - return { event_id: inviteEvent.eventId, event: PersistentEventFactory.createFromRawEvent( @@ -145,11 +131,6 @@ export class InviteService { // let everyone know void federationService.sendEventToAllServersInRoom(inviteEvent); - this.emitterService.emit('homeserver.matrix.membership', { - event_id: inviteEvent.eventId, - event: inviteEvent.event, - }); - return { event_id: inviteEvent.eventId, event: PersistentEventFactory.createFromRawEvent( @@ -196,10 +177,8 @@ export class InviteService { async processInvite( event: PduForType<'m.room.member'>, - roomId: RoomID, eventId: EventID, roomVersion: RoomVersion, - authenticatedServer: string, strippedStateEvents: PduForType< | 'm.room.create' | 'm.room.name' @@ -209,15 +188,9 @@ export class InviteService { | 'm.room.canonical_alias' | 'm.room.encryption' >[], - ) { - // SPEC: when a user invites another user on a different homeserver, a request to that homeserver to have the event signed and verified must be made + ): Promise> { await this.shouldProcessInvite(strippedStateEvents); - const residentServer = roomId.split(':').pop(); - if (!residentServer) { - throw new Error(`Invalid roomId ${roomId}`); - } - const inviteEvent = PersistentEventFactory.createFromRawEvent<'m.room.member'>( event, @@ -228,28 +201,59 @@ export class InviteService { throw new Error(`Invalid eventId ${eventId}`); } - await this.stateService.signEvent(inviteEvent); + const { residentServer } = inviteEvent; - // we are the host of the server if (residentServer === this.configService.serverName) { await this.eventAuthorizationService.checkAclForInvite( - roomId, - authenticatedServer, + event.room_id, + residentServer, ); - // attempt to persist the invite event as we already have the state - await this.stateService.handlePdu(inviteEvent); - // we do not send transaction here - // the asking server will handle the transactions + return inviteEvent; + } - this.emitterService.emit('homeserver.matrix.membership', { - event_id: inviteEvent.eventId, - event: inviteEvent.event, - }); + const invitedServer = extractDomainFromId(event.state_key); + if (!invitedServer) { + throw new Error( + `invalid state_key ${event.state_key}, no server_name part`, + ); + } + if (invitedServer !== this.configService.serverName) { + throw new Error( + `Cannot sign invite for user ${event.state_key}: user does not belong to this server (${this.configService.serverName})`, + ); + } + + await this.stateService.signEvent(inviteEvent); + + // we have no specific structure to store the invite_room_state received on the invite route, + // so we store it in the unsigned section of the invite event. + inviteEvent.event.unsigned.invite_room_state = strippedStateEvents; + + // check if we are already in the room, if so we can handlePdu because we have the state and should save + // the invite in the state as well + const createEvent = await this.eventRepository.findByRoomIdAndType( + event.room_id, + 'm.room.create', + ); + if (createEvent) { + await this.stateService.handlePdu(inviteEvent); + } else { + // otherwise we save as outlier only so we can deal with it later + await this.eventRepository.insertOutlierEvent( + inviteEvent.eventId, + inviteEvent.event, + residentServer, + ); } + this.emitterService.emit('homeserver.matrix.membership', { + event_id: inviteEvent.eventId, + event: inviteEvent.event, + }); + // we are not the host of the server // so being the origin of the user, we sign the event and send it to the asking server, let them handle the transactions return inviteEvent; diff --git a/packages/federation-sdk/src/services/room.service.spec.ts b/packages/federation-sdk/src/services/room.service.spec.ts index f610e66f9..3d10bc1c4 100644 --- a/packages/federation-sdk/src/services/room.service.spec.ts +++ b/packages/federation-sdk/src/services/room.service.spec.ts @@ -10,6 +10,7 @@ import { import { container } from 'tsyringe'; import { federationSDK, init } from '..'; import { AppConfig, ConfigService } from './config.service'; +import { RoomService } from './room.service'; import { StateService } from './state.service'; describe('RoomService', async () => { @@ -38,11 +39,13 @@ describe('RoomService', async () => { signingKey: '', serverName: 'example.com', } as AppConfig); - const stateService = container - .register(ConfigService, { - useValue: configService, - }) - .resolve(StateService); + + container.register(ConfigService, { + useValue: configService, + }); + + const stateService = container.resolve(StateService); + const roomService = container.resolve(RoomService); const createRoom = async ( username: room.UserID, @@ -212,7 +215,7 @@ describe('RoomService', async () => { expect(imtialStateKeys).toEqual(expectedStateKeys); - await federationSDK.joinUser(roomCreateEvent, secondaryUsername); + await roomService.joinUser(roomId, username, secondaryUsername); const state = await stateService.getLatestRoomState(roomId); @@ -312,7 +315,7 @@ describe('RoomService', async () => { const roomId = roomCreateEvent.roomId; - await federationSDK.joinUser(roomCreateEvent, secondaryUsername); + await roomService.joinUser(roomId, username, secondaryUsername); expect( await stateService.getLatestRoomState(roomId).then((state) => { @@ -339,4 +342,72 @@ describe('RoomService', async () => { }); }); }); + + describe('acceptInvite', () => { + it('should accept invite and join user to room correctly', async () => { + const username = '@alice:example.com' as room.UserID; + const invitedUsername = '@bob:example.com' as room.UserID; + const { roomCreateEvent } = await createRoom(username, 'invite'); + const roomId = roomCreateEvent.roomId; + + await federationSDK.inviteUserToRoom(invitedUsername, roomId, username); + + const stateBeforeAccept = await stateService.getLatestRoomState(roomId); + const inviteMemberEvent = stateBeforeAccept.get( + `m.room.member:${invitedUsername}`, + ) as PersistentEventBase | undefined; + + expect(inviteMemberEvent?.getContent().membership).toBe('invite'); + + await federationSDK.acceptInvite(roomId, invitedUsername); + + const stateAfterAccept = await stateService.getLatestRoomState(roomId); + const joinMemberEvent = stateAfterAccept.get( + `m.room.member:${invitedUsername}`, + ) as PersistentEventBase; + + expect(joinMemberEvent.getContent().membership).toBe('join'); + expect([...stateAfterAccept.keys()]).toEqual( + expect.arrayContaining([ + 'm.room.create:', + `m.room.member:${username}`, + `m.room.member:${invitedUsername}`, + ]), + ); + }); + }); + + describe('rejectInvite', () => { + it('should reject invite and leave room correctly', async () => { + const username = '@alice:example.com' as room.UserID; + const invitedUsername = '@bob:example.com' as room.UserID; + const { roomCreateEvent } = await createRoom(username, 'invite'); + const roomId = roomCreateEvent.roomId; + + await federationSDK.inviteUserToRoom(invitedUsername, roomId, username); + + const stateBeforeReject = await stateService.getLatestRoomState(roomId); + const inviteMemberEvent = stateBeforeReject.get( + `m.room.member:${invitedUsername}`, + ) as PersistentEventBase | undefined; + + expect(inviteMemberEvent?.getContent().membership).toBe('invite'); + + await federationSDK.rejectInvite(roomId, invitedUsername); + + const stateAfterReject = await stateService.getLatestRoomState(roomId); + const leaveMemberEvent = stateAfterReject.get( + `m.room.member:${invitedUsername}`, + ) as PersistentEventBase; + + expect(leaveMemberEvent.getContent().membership).toBe('leave'); + expect([...stateAfterReject.keys()]).toEqual( + expect.arrayContaining([ + 'm.room.create:', + `m.room.member:${username}`, + `m.room.member:${invitedUsername}`, + ]), + ); + }); + }); }); diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index f8ddc65bd..de14e86eb 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -568,11 +568,80 @@ export class RoomService { return event.eventId; } + async makeLeave( + roomId: RoomID, + senderId: UserID, + ): Promise<{ + event: PduForType<'m.room.member'> & { origin: string }; + room_version: RoomVersion; + }> { + const roomInfo = await this.stateService.getRoomInformation(roomId); + const leaveEvent = await this.stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + content: { membership: 'leave' }, + room_id: roomId, + state_key: senderId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: senderId, + }, + roomInfo.room_version, + ); + + return { + event: { + ...leaveEvent.event, + origin: this.configService.serverName, + }, + room_version: roomInfo.room_version, + }; + } + + async sendLeave( + roomId: RoomID, + eventId: EventID, + event: PduForType<'m.room.member'>, + ): Promise { + const roomVersion = await this.stateService.getRoomVersion(roomId); + if (!roomVersion) { + throw new Error('Room version not found while sending leave'); + } + + const leaveEvent = + PersistentEventFactory.createFromRawEvent<'m.room.member'>( + event, + roomVersion, + ); + if (leaveEvent.eventId !== eventId) { + throw new Error(`Invalid eventId ${eventId}`); + } + + const residentServer = extractDomainFromId(roomId); + if (residentServer !== this.configService.serverName) { + throw new Error( + `Server ${residentServer} is not the resident server for room ${roomId}`, + ); + } + + await this.stateService.handlePdu(leaveEvent); + if (leaveEvent.rejected) { + throw new Error(leaveEvent.rejectReason); + } + + void this.federationService.sendEventToAllServersInRoom(leaveEvent); + + this.emitterService.emit('homeserver.matrix.membership', { + event_id: leaveEvent.eventId, + event: leaveEvent.event, + }); + } + async leaveRoom(roomId: RoomID, senderId: UserID): Promise { logger.info(`User ${senderId} leaving room ${roomId}`); - const roomInfo = await this.stateService.getRoomInformation(roomId); - const authEventIds = await this.eventService.getAuthEventIds( 'm.room.member', { roomId, senderId }, @@ -611,19 +680,10 @@ export class RoomService { ); } - const leaveEvent = await this.stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - content: { membership: 'leave' }, - room_id: roomId, - state_key: senderId, - auth_events: [], - depth: 0, - prev_events: [], - origin_server_ts: Date.now(), - sender: senderId, - }, - roomInfo.room_version, + const makeLeaveResponse = await this.makeLeave(roomId, senderId); + const leaveEvent = PersistentEventFactory.createFromRawEvent( + makeLeaveResponse.event, + makeLeaveResponse.room_version, ); await this.stateService.handlePdu(leaveEvent); @@ -846,14 +906,13 @@ export class RoomService { // if local room, add the user to the room if allowed. // if remote room, run through the join process - async joinUser(invite: PersistentEventBase, userId: UserID) { + async joinUser(roomId: RoomID, sender: UserID, userId: UserID) { const configService = this.configService; const stateService = this.stateService; const federationService = this.federationService; - const roomId = invite.roomId; // where the room is hosted at - const residentServer = extractDomainFromId(invite.sender); + const residentServer = extractDomainFromId(sender); // our own room, we can validate the join event by ourselves // once done, emit the event to all participating servers @@ -885,11 +944,6 @@ export class RoomService { await stateService.handlePdu(membershipEvent); - this.eventEmitterService.emit('homeserver.matrix.membership', { - event_id: membershipEvent.eventId, - event: membershipEvent.event, - }); - if (membershipEvent.rejected) { throw new Error(membershipEvent.rejectReason); } @@ -1061,6 +1115,66 @@ export class RoomService { return joinEventFinal.eventId; } + async acceptInvite(roomId: RoomID, userId: UserID) { + const inviteEventStore = await this.eventService.findInviteEvent( + roomId, + userId, + ); + if (!inviteEventStore) { + throw new Error( + `Invite event not found for user ${userId} in room ${roomId}`, + ); + } + + return this.joinUser(roomId, inviteEventStore.event.sender, userId); + } + + async rejectInvite(roomId: RoomID, userId: UserID): Promise { + const inviteEventStore = await this.eventService.findInviteEvent( + roomId, + userId, + ); + if (!inviteEventStore) { + throw new Error( + `Invite event not found for user ${userId} in room ${roomId}`, + ); + } + + const invitingServer = extractDomainFromId(inviteEventStore.event.sender); + if (!invitingServer) { + throw new Error( + `Invalid sender in invite event: ${inviteEventStore.event.sender}`, + ); + } + + // if inviting server is our own, we can handle the leave event ourselves + // otherwise, we need to send the leave event to the inviting server + if (invitingServer === this.configService.serverName) { + await this.leaveRoom(roomId, userId); + return; + } + + // important to note that on rejections from remote servers, we might not have the room state yet, + // that's why we handle it manually instead of calling this.leaveRoom + const { event: leaveTemplate, room_version } = + await this.federationService.makeLeave(invitingServer, roomId, userId); + + const leaveEvent = + PersistentEventFactory.createFromRawEvent<'m.room.member'>( + leaveTemplate, + room_version, + ); + + await this.stateService.signEvent(leaveEvent); + + await this.federationService.sendLeave(leaveEvent); + + this.eventEmitterService.emit('homeserver.matrix.membership', { + event_id: leaveEvent.eventId, + event: leaveEvent.event, + }); + } + private async _fetchFullBranch( eventIds: EventID[], residentServer: string, diff --git a/packages/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index 60967a86b..fc6249c89 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -12,7 +12,7 @@ import { export const invitePlugin = (app: Elysia) => { return app.use(isAuthenticatedMiddleware()).put( '/_matrix/federation/v2/invite/:roomId/:eventId', - async ({ body, set, params: { roomId, eventId }, authenticatedServer }) => { + async ({ body, set, params: { eventId }, authenticatedServer }) => { if (!authenticatedServer) { throw new Error('Missing authenticated server from request'); } @@ -20,10 +20,8 @@ export const invitePlugin = (app: Elysia) => { try { return await federationSDK.processInvite( body.event, - roomId as RoomID, eventId as EventID, body.room_version, - authenticatedServer, body.invite_room_state, ); } catch (error) {