diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 43c073e037667..086221803d87e 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,12 +1,10 @@ import { api } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; -import { isRoomFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { addUserToRoom } from '../functions/addUserToRoom'; @@ -79,12 +77,6 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; }); } - // Validate each user, then add to room - if (isRoomFederated(room)) { - await callbacks.run('federation.onAddUsersToRoom', { invitees: data.users, inviter: user }, room); - return true; - } - await Promise.all( data.users.map(async (username) => { const newUser = await Users.findOneByUsernameIgnoringCase(username); diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 67ad46bcd15e9..d59a15f144a20 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -79,21 +79,6 @@ callbacks.add( 'native-federation-after-delete-message', ); -callbacks.add( - 'federation.onAddUsersToRoom', - async ({ invitees, inviter }, room) => { - if (FederationActions.shouldPerformFederationAction(room)) { - await FederationMatrix.inviteUsersToRoom( - room, - invitees.map((invitee) => (typeof invitee === 'string' ? invitee : invitee.username)).filter((v) => v != null), - inviter, - ); - } - }, - callbacks.priority.MEDIUM, - 'native-federation-on-add-users-to-room ', -); - beforeAddUserToRoom.add( async ({ user, inviter }, room) => { if (!user.username || !inviter) { diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 7fe2240f2342e..65b37c418bf3f 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -85,7 +85,6 @@ interface EventLikeCallbackSignatures { message: IMessage, params: { user: IUser; reaction: string; shouldReact: boolean; oldMessage: IMessage; room: IRoom }, ) => void; - 'federation.onAddUsersToRoom': (params: { invitees: IUser[] | Username[]; inviter: IUser }, room: IRoom) => void; 'onJoinVideoConference': (callId: VideoConference['_id'], userId?: IUser['_id']) => Promise; 'usernameSet': () => void; 'beforeJoinRoom': (user: IUser, room: IRoom) => void; diff --git a/apps/meteor/server/methods/addRoomModerator.ts b/apps/meteor/server/methods/addRoomModerator.ts index c0cd23b53e4a0..70a332683f9b3 100644 --- a/apps/meteor/server/methods/addRoomModerator.ts +++ b/apps/meteor/server/methods/addRoomModerator.ts @@ -24,7 +24,7 @@ export const addRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom['_id check(rid, String); check(userId, String); - const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'addRoomModerator', diff --git a/apps/meteor/server/methods/addRoomOwner.ts b/apps/meteor/server/methods/addRoomOwner.ts index 36d65c6796803..b60779d648ca7 100644 --- a/apps/meteor/server/methods/addRoomOwner.ts +++ b/apps/meteor/server/methods/addRoomOwner.ts @@ -24,7 +24,7 @@ export const addRoomOwner = async (fromUserId: IUser['_id'], rid: IRoom['_id'], check(rid, String); check(userId, String); - const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'addRoomOwner', diff --git a/apps/meteor/server/methods/removeRoomModerator.ts b/apps/meteor/server/methods/removeRoomModerator.ts index db7808d592d7c..a9a7c482c87cc 100644 --- a/apps/meteor/server/methods/removeRoomModerator.ts +++ b/apps/meteor/server/methods/removeRoomModerator.ts @@ -23,7 +23,7 @@ export const removeRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom[' check(rid, String); check(userId, String); - const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'removeRoomModerator', diff --git a/apps/meteor/server/methods/removeRoomOwner.ts b/apps/meteor/server/methods/removeRoomOwner.ts index f39d81ba31f0d..555879ac5132f 100644 --- a/apps/meteor/server/methods/removeRoomOwner.ts +++ b/apps/meteor/server/methods/removeRoomOwner.ts @@ -22,7 +22,7 @@ export const removeRoomOwner = async (fromUserId: string, rid: string, userId: s check(rid, String); check(userId, String); - const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'removeRoomOwner', diff --git a/ee/apps/federation-service/package.json b/ee/apps/federation-service/package.json index d625a925ee6f6..7bd65159fe636 100644 --- a/ee/apps/federation-service/package.json +++ b/ee/apps/federation-service/package.json @@ -25,7 +25,7 @@ "@rocket.chat/core-typings": "workspace:*", "@rocket.chat/emitter": "^0.31.25", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.1.11", + "@rocket.chat/federation-sdk": "0.1.13", "@rocket.chat/http-router": "workspace:*", "@rocket.chat/instance-status": "workspace:^", "@rocket.chat/license": "workspace:^", diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 87b6bfebc04be..a4c2cfabeabe0 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -38,7 +38,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", - "@rocket.chat/federation-sdk": "0.1.11", + "@rocket.chat/federation-sdk": "0.1.13", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 507d12fd9470d..effcc4633c9c4 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -26,7 +26,7 @@ import { Users, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; import emojione from 'emojione'; import { getWellKnownRoutes } from './api/.well-known/server'; -import { getMatrixInviteRoutes } from './api/_matrix/invite'; +import { acceptInvite, getMatrixInviteRoutes } from './api/_matrix/invite'; import { getKeyServerRoutes } from './api/_matrix/key/server'; import { getMatrixMediaRoutes } from './api/_matrix/media'; import { getMatrixProfilesRoutes } from './api/_matrix/profiles'; @@ -88,6 +88,24 @@ export const extractDomainFromMatrixUserId = (mxid: string): string => { return mxid.substring(separatorIndex + 1); }; +/** + * Extract the username and the servername from a matrix user id + * if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username) + * otherwise, return the full mxid and the servername + */ +export const getUsernameServername = (mxid: string, serverName: string): [mxid: string, serverName: string, isLocal: boolean] => { + const senderServerName = extractDomainFromMatrixUserId(mxid); + // if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username) + if (serverName === senderServerName) { + const separatorIndex = mxid.indexOf(':', 1); + if (separatorIndex === -1) { + throw new Error(`Invalid federated username: ${mxid}`); + } + return [mxid.substring(1, separatorIndex), senderServerName, true]; // removers also the @ + } + + return [mxid, senderServerName, false]; +}; /** * Helper function to create a federated user * @@ -99,7 +117,7 @@ export async function createOrUpdateFederatedUser(options: { name?: string; origin: string; }): Promise { - const { username, name = username } = options; + const { username, name = username, origin } = options; const result = await Users.updateOne( { @@ -108,7 +126,7 @@ export async function createOrUpdateFederatedUser(options: { { $set: { username, - name, + name: name || username, type: 'user' as const, status: UserStatus.OFFLINE, active: true, @@ -294,7 +312,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async created(): Promise { try { - registerEvents(this.eventHandler, this.serverName, { typing: this.processEDUTyping, presence: this.processEDUPresence }); + registerEvents( + this.eventHandler, + this.serverName, + { typing: this.processEDUTyping, presence: this.processEDUPresence }, + this.homeserverServices, + ); } catch (error) { this.logger.warn('Homeserver module not available, running in limited mode'); } @@ -669,24 +692,23 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - async inviteUsersToRoom(room: IRoomNativeFederated, usersUserName: string[], inviter: IUser): Promise { + async inviteUsersToRoom(room: IRoomNativeFederated, matrixUsersUsername: string[], inviter: IUser): Promise { try { const inviterUserId = `@${inviter.username}:${this.serverName}`; await Promise.all( - usersUserName - .filter((username) => { - const isExternalUser = username.includes(':'); - return isExternalUser; - }) - .map(async (username) => { - const alreadyMember = await Subscriptions.findOneByRoomIdAndUsername(room._id, username, { projection: { _id: 1 } }); - if (alreadyMember) { - return; - } + matrixUsersUsername.map(async (username) => { + if (validateFederatedUsername(username)) { + return this.homeserverServices.invite.inviteUserToRoom(username, room.federation.mrid, inviterUserId); + } + const result = await this.homeserverServices.invite.inviteUserToRoom( + `@${username}:${this.serverName}`, + room.federation.mrid, + inviterUserId, + ); - await this.homeserverServices.invite.inviteUserToRoom(username, room.federation.mrid, inviterUserId); - }), + return acceptInvite(result.event, username, this.homeserverServices); + }), ); } catch (error) { this.logger.error({ msg: 'Failed to invite an user to Matrix:', err: error }); diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index 39c05d975be3e..35b7ff924db5e 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -1,5 +1,5 @@ import { Room } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import { isUserNativeFederated, type IUser } from '@rocket.chat/core-typings'; import type { HomeserverServices, RoomService, @@ -9,10 +9,10 @@ import type { RoomVersion, } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Users, Rooms } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; -import { createOrUpdateFederatedUser } from '../../FederationMatrix'; +import { createOrUpdateFederatedUser, getUsernameServername } from '../../FederationMatrix'; const EventBaseSchema = { type: 'object', @@ -139,19 +139,34 @@ const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); // 5 seconds // 25 seconds // 625 seconds = 10 minutes 25 seconds // max -async function runWithBackoff(fn: () => Promise, delaySec = 5) { - try { - await fn(); - } catch (e) { - const delay = delaySec === 625 ? 625 : delaySec ** 2; - console.log(`error occurred, retrying in ${delay}ms`, e); - setTimeout(() => { - runWithBackoff(fn, delay * 1000); - }, delay); - } +function runWithBackoff Promise>(fn: T, delaySec = 5): T & { stop: () => void } { + let timeoutId: NodeJS.Timeout | null = null; + let currentDelay = delaySec; + + const execute = async (...args: Parameters) => { + try { + await fn(...args); + currentDelay = delaySec; // Reset delay on success + } catch (e) { + const delay = currentDelay === 625 ? 625 : currentDelay ** 2; + console.log(`error occurred, retrying in ${delay}ms`, e); + currentDelay = delay; + timeoutId = setTimeout(() => execute(...args), delay); + } + }; + + return Object.assign( + (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + execute(...args); + }, + { stop: () => clearTimeout(timeoutId ?? 0) }, + ) as T & { stop: () => void }; } -async function joinRoom({ +export async function joinRoom({ inviteEvent, user, // ours trying to join the room room, @@ -190,7 +205,7 @@ async function joinRoom({ const senderUserId = senderUser?._id || (await createOrUpdateFederatedUser({ - username: inviteEvent.sender, + username: inviteEvent.sender as `@${string}:${string}`, origin: matrixRoom.origin, })); @@ -264,16 +279,51 @@ async function joinRoom({ } await Room.addUserToRoom(internalRoomId, { _id: user._id }, { _id: senderUserId, username: inviteEvent.sender }); - - // TODO is this needed? - // if (isDM) { - // await MatrixBridgedRoom.createOrUpdateByLocalRoomId(internalRoomId, inviteEvent.roomId, matrixRoom.origin); - // } } -async function startJoiningRoom(...opts: Parameters) { - void runWithBackoff(() => joinRoom(...opts)); -} +export const startJoiningRoom = runWithBackoff(joinRoom); + +// This is a special case where inside rocket chat we invite users inside rockechat, so if the sender or the invitee are external iw should throw an error +export const acceptInvite = async ( + inviteEvent: PersistentEventBase, + username: string, + services: HomeserverServices, +) => { + if (!inviteEvent.stateKey) { + throw new Error('join event has missing state key, unable to determine user to join'); + } + + const internalMappedRoom = await Rooms.findOne({ 'federation.mrid': inviteEvent.roomId }); + if (!internalMappedRoom) { + throw new Error('room not found not processing invite'); + } + + const inviter = await Users.findOneByUsername>( + getUsernameServername(inviteEvent.sender, services.config.serverName)[0], + { + projection: { _id: 1, username: 1 }, + }, + ); + + if (!inviter) { + throw new Error('Sender user ID not found'); + } + if (isUserNativeFederated(inviter)) { + throw new Error('Sender user is native federated'); + } + + const user = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); + + // we cannot accept invites from users that are external + if (!user) { + throw new Error('User not found'); + } + if (isUserNativeFederated(user)) { + throw new Error('User is native federated'); + } + + await services.room.joinUser(inviteEvent.roomId, inviteEvent.stateKey); +}; export const getMatrixInviteRoutes = (services: HomeserverServices) => { const { invite, state, room } = services; diff --git a/ee/packages/federation-matrix/src/events/index.ts b/ee/packages/federation-matrix/src/events/index.ts index 9cf3abd0a9539..8ce68182e3f95 100644 --- a/ee/packages/federation-matrix/src/events/index.ts +++ b/ee/packages/federation-matrix/src/events/index.ts @@ -1,5 +1,5 @@ import type { Emitter } from '@rocket.chat/emitter'; -import type { HomeserverEventSignatures } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, HomeserverServices } from '@rocket.chat/federation-sdk'; import { edus } from './edu'; import { member } from './member'; @@ -12,11 +12,12 @@ export function registerEvents( emitter: Emitter, serverName: string, eduProcessTypes: { typing: boolean; presence: boolean }, + services: HomeserverServices, ) { ping(emitter); message(emitter, serverName); reaction(emitter); - member(emitter); + member(emitter, services); edus(emitter, eduProcessTypes); room(emitter); } diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 65494bcb64937..b165d9cbc958c 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,9 +1,10 @@ import { Room } from '@rocket.chat/core-services'; -import { UserStatus } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; -import type { HomeserverEventSignatures } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, HomeserverServices } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix'; const logger = new Logger('federation-matrix:member'); @@ -39,44 +40,39 @@ async function membershipLeaveAction(data: HomeserverEventSignatures['homeserver } } -async function membershipJoinAction(data: HomeserverEventSignatures['homeserver.matrix.membership']) { +async function membershipJoinAction(data: HomeserverEventSignatures['homeserver.matrix.membership'], services: HomeserverServices) { const room = await Rooms.findOne({ 'federation.mrid': data.room_id }); if (!room) { logger.warn(`No bridged room found for room_id: ${data.room_id}`); return; } - const internalUsername = data.sender; - const localUser = await Users.findOneByUsername(internalUsername); + const [username, serverName, isLocal] = getUsernameServername(data.sender, services.config.serverName); + + // for local users we must to remove the @ and the server domain + const localUser = isLocal && (await Users.findOneByUsername(username)); + if (localUser) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, localUser._id); + if (subscription) { + return; + } await Room.addUserToRoom(room._id, localUser); return; } - const [, serverName] = data.sender.split(':'); if (!serverName) { throw new Error('Invalid sender format, missing server name'); } - const { insertedId } = await Users.insertOne({ - username: internalUsername, - type: 'user', - status: UserStatus.OFFLINE, - active: true, - roles: ['user'], - name: data.content.displayname || internalUsername, - requirePasswordChange: false, - createdAt: new Date(), - _updatedAt: new Date(), - federated: true, - federation: { - version: 1, - mui: data.sender, - origin: serverName, - }, + const insertedId = await createOrUpdateFederatedUser({ + username: data.state_key as `@${string}:${string}`, + origin: serverName, + name: data.content.displayname || (data.state_key as `@${string}:${string}`), }); const user = await Users.findOneById(insertedId); + if (!user) { console.warn(`User with ID ${insertedId} not found after insertion`); return; @@ -84,7 +80,7 @@ async function membershipJoinAction(data: HomeserverEventSignatures['homeserver. await Room.addUserToRoom(room._id, user); } -export function member(emitter: Emitter) { +export function member(emitter: Emitter, services: HomeserverServices) { emitter.on('homeserver.matrix.membership', async (data) => { try { if (data.content.membership === 'leave') { @@ -92,7 +88,7 @@ export function member(emitter: Emitter) { } if (data.content.membership === 'join') { - return membershipJoinAction(data); + return membershipJoinAction(data, services); } logger.debug(`Ignoring membership event with membership: ${data.content.membership}`); diff --git a/yarn.lock b/yarn.lock index cfe9634690d63..e4559144af7d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7543,7 +7543,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.1.11" + "@rocket.chat/federation-sdk": "npm:0.1.13" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -7569,9 +7569,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.1.11": - version: 0.1.11 - resolution: "@rocket.chat/federation-sdk@npm:0.1.11" +"@rocket.chat/federation-sdk@portal:/Users/guilhermegazzo/dev/homeserver/federation-bundle::locator=rocket.chat%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@rocket.chat/federation-sdk@portal:/Users/guilhermegazzo/dev/homeserver/federation-bundle::locator=rocket.chat%40workspace%3A." dependencies: "@datastructures-js/priority-queue": "npm:^6.3.3" "@noble/ed25519": "npm:^3.0.0" @@ -7584,9 +7584,8 @@ __metadata: zod: "npm:^3.22.4" peerDependencies: typescript: ~5.9.2 - checksum: 10/f45d1d43e28033e3b20022cedbd5825967bbf85346cebc2d6600d490f306fd253713a95bc51ce571e90027087d96497349c694e72a2c2913319d08e1046b0c06 languageName: node - linkType: hard + linkType: soft "@rocket.chat/federation-service@workspace:^, @rocket.chat/federation-service@workspace:ee/apps/federation-service": version: 0.0.0-use.local @@ -7597,7 +7596,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:*" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.1.11" + "@rocket.chat/federation-sdk": "npm:0.1.13" "@rocket.chat/http-router": "workspace:*" "@rocket.chat/instance-status": "workspace:^" "@rocket.chat/license": "workspace:^" @@ -9783,7 +9782,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.3 - "@rocket.chat/ui-contexts": 23.0.0-rc.0 + "@rocket.chat/ui-contexts": 23.0.0-rc.1 "@tanstack/react-query": "*" react: "*" react-hook-form: "*"