diff --git a/apps/meteor/app/lib/server/functions/setRealName.ts b/apps/meteor/app/lib/server/functions/setRealName.ts index 530f828b2cf5e..b7ac00ef34e0a 100644 --- a/apps/meteor/app/lib/server/functions/setRealName.ts +++ b/apps/meteor/app/lib/server/functions/setRealName.ts @@ -5,6 +5,7 @@ import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import type { ClientSession } from 'mongodb'; +import { callbacks } from '../../../../lib/callbacks'; import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; @@ -34,6 +35,8 @@ export const _setRealName = async function ( return user; } + const oldUser = { ...user }; + // Set new name if (name) { if (updater) { @@ -48,7 +51,7 @@ export const _setRealName = async function ( } user.name = name; - await onceTransactionCommitedSuccessfully(() => { + await onceTransactionCommitedSuccessfully(async () => { if (settings.get('UI_Use_Real_Name') === true) { void api.broadcast('user.nameChanged', { _id: user._id, @@ -61,6 +64,7 @@ export const _setRealName = async function ( name, username: user.username, }); + void callbacks.run('afterSaveUser', { user, oldUser }); }, session); return user; diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index da15ffc50ad01..6a3a29787acaf 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,6 +1,6 @@ -import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; -import { isEditedMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; -import { Rooms } from '@rocket.chat/models'; +import { FederationMatrix, Authorization, Message, MeteorError } from '@rocket.chat/core-services'; +import { isEditedMessage, isRoomNativeFederated, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; @@ -218,3 +218,50 @@ callbacks.add( callbacks.priority.HIGH, 'federation-matrix-after-create-direct-room', ); + +callbacks.add( + 'afterSaveUser', + async ({ user, oldUser }) => { + if (!oldUser || !user) { + return; + } + + const nameChanged = user.name !== oldUser.name; + if (!nameChanged) { + return; + } + + const newDisplayName = user.name || user.username; + if (!newDisplayName) { + return; + } + + // check if user, even if local, is part of some federated room + // TODO have a method to check if user is part of some federated room + const oldDisplayName = oldUser.name || oldUser.username; + const federatedRoomIds: string[] = []; + const subscriptions = Subscriptions.findByUserId(user._id); + for await (const subscription of subscriptions) { + const room = await Rooms.findOneById(subscription.rid, { fields: { _id: 1, federation: 1, federated: 1 } }); + if (!room || !isRoomNativeFederated(room)) { + continue; + } + federatedRoomIds.push(room._id); + } + + if (federatedRoomIds.length > 0) { + await FederationMatrix.updateUserProfile(user._id, newDisplayName); + + // send system message to each federated room + for await (const roomId of federatedRoomIds) { + try { + await Message.saveSystemMessage('user-display-name-changed', roomId, `${oldDisplayName}|${newDisplayName}`, user); + } catch (error) { + console.error(`Failed to send displayname change system message to room ${roomId}:`, error); + } + } + } + }, + callbacks.priority.MEDIUM, + 'native-federation-after-user-profile-update', +); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index b3fbb472d64df..3db2684a6c92b 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -9,12 +9,13 @@ import { } from '@rocket.chat/core-typings'; import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK } from '@rocket.chat/federation-sdk'; -import type { EventID, UserID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk'; +import type { EventID, UserID, FileMessageType, PresenceState, PduForType } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; import emojione from 'emojione'; import { acceptInvite } from './api/_matrix/invite'; +import { constructMatrixId, getUserMatrixId, validateFederatedUsername } from './helpers/matrixId'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; import { MatrixMediaService } from './services/MatrixMediaService'; @@ -24,39 +25,6 @@ export const fileTypes: Record = { audio: 'm.audio', file: 'm.file', }; - -/** helper to validate the username format */ -export function validateFederatedUsername(mxid: string): mxid is UserID { - if (!mxid.startsWith('@')) return false; - - const parts = mxid.substring(1).split(':'); - if (parts.length < 2) return false; - - const localpart = parts[0]; - const domainAndPort = parts.slice(1).join(':'); - - const localpartRegex = /^(?:[a-z0-9._\-]|=[0-9a-fA-F]{2}){1,255}$/; - if (!localpartRegex.test(localpart)) return false; - - const [domain, port] = domainAndPort.split(':'); - - const hostnameRegex = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*$/i; - const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/; - const ipv6Regex = /^\[([0-9a-f:.]+)\]$/i; - - if (!(hostnameRegex.test(domain) || ipv4Regex.test(domain) || ipv6Regex.test(domain))) { - return false; - } - - if (port !== undefined) { - const portNum = Number(port); - if (!/^[0-9]+$/.test(port) || portNum < 1 || portNum > 65535) { - return false; - } - } - - return true; -} export const extractDomainFromMatrixUserId = (mxid: string): string => { const separatorIndex = mxid.indexOf(':', 1); if (separatorIndex === -1) { @@ -88,10 +56,15 @@ export const getUsernameServername = (mxid: string, serverName: string): [mxid: * * Because of historical reasons, we can have users only with federated flag but no federation object * So we need to upsert the user with the federation object + * + * IMPORTANT: This function ensures the Matrix User ID (mui) is IMMUTABLE. + * Once set, it never changes, even if the RC username changes. */ export async function createOrUpdateFederatedUser(options: { username: UserID; name?: string; origin: string }): Promise { const { username, name = username, origin } = options; + const matrixUserId = validateFederatedUsername(username) ? username : constructMatrixId(username, origin); + const result = await Users.updateOne( { username, @@ -108,7 +81,7 @@ export async function createOrUpdateFederatedUser(options: { username: UserID; n federated: true, federation: { version: 1, - mui: username, + mui: matrixUserId, origin, }, _updatedAt: new Date(), @@ -213,11 +186,17 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } try { - const matrixUserId = userIdSchema.parse(`@${owner.username}:${this.serverName}`); + const matrixUserId = userIdSchema.parse(getUserMatrixId(owner, this.serverName)); const roomName = room.name || room.fname || 'Untitled Room'; + const ownerDisplayName = owner.name || owner.username; // canonical alias computed from name - const matrixRoomResult = await federationSDK.createRoom(matrixUserId, roomName, room.t === 'c' ? 'public' : 'invite'); + const matrixRoomResult = await federationSDK.createRoom( + matrixUserId, + roomName, + room.t === 'c' ? 'public' : 'invite', + ownerDisplayName, + ); this.logger.debug('Matrix room created:', matrixRoomResult); @@ -274,7 +253,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error('Creator not found in members list'); } - const actualMatrixUserId = `@${creator.username}:${this.serverName}`; + const actualMatrixUserId = getUserMatrixId(creator, this.serverName); let matrixRoomResult: { room_id: string; event_id?: string }; if (members.length === 2) { @@ -291,9 +270,15 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ); matrixRoomResult = { room_id: roomId }; } else { - // For group DMs (more than 2 members), create a private room + // for group DMs (more than 2 members), create a private room const roomName = room.name || room.fname || `Group chat with ${members.length} members`; - matrixRoomResult = await federationSDK.createRoom(userIdSchema.parse(actualMatrixUserId), roomName, 'invite'); + const creatorDisplayName = creator.name || creator.username; + matrixRoomResult = await federationSDK.createRoom( + userIdSchema.parse(actualMatrixUserId), + roomName, + 'invite', + creatorDisplayName, + ); for await (const member of members) { if (member._id === creatorId) { @@ -305,10 +290,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } try { + const memberDisplayName = member.name || member.username; await federationSDK.inviteUserToRoom( userIdSchema.parse(member.username), roomIdSchema.parse(matrixRoomResult.room_id), userIdSchema.parse(actualMatrixUserId), + true, + memberDisplayName, ); } catch (error) { this.logger.error('Error creating or updating bridged user for DM:', error); @@ -456,7 +444,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async sendMessage(message: IMessage, room: IRoomNativeFederated, user: IUser): Promise { try { - const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); let result; if (message.files && message.files.length > 0) { @@ -542,7 +530,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async inviteUsersToRoom(room: IRoomNativeFederated, matrixUsersUsername: string[], inviter: IUser): Promise { try { - const inviterUserId = `@${inviter.username}:${this.serverName}`; + const inviterUserId = getUserMatrixId(inviter, this.serverName); await Promise.all( matrixUsersUsername.map(async (username) => { @@ -561,10 +549,18 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } + const userToInvite = await Users.findOneByUsername(username); + const inviteeMatrixId = userToInvite + ? getUserMatrixId(userToInvite, this.serverName) + : userIdSchema.parse(`@${username}:${this.serverName}`); + + const displayName = userToInvite ? userToInvite.name || userToInvite.username : undefined; const result = await federationSDK.inviteUserToRoom( - userIdSchema.parse(`@${username}:${this.serverName}`), + inviteeMatrixId, roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(inviterUserId), + false, + displayName, ); return acceptInvite(result.event, username); @@ -595,7 +591,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const reactionKey = emojione.shortnameToUnicode(reaction); - const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); const eventId = await federationSDK.sendReaction( roomIdSchema.parse(room.federation.mrid), @@ -635,7 +631,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const reactionKey = emojione.shortnameToUnicode(reaction); - const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); const reactionData = oldMessage.reactions?.[reaction]; if (!reactionData?.federationReactionEventIds) { @@ -684,7 +680,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const actualMatrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const subscription = await Subscriptions.findOne({ 'rid': room._id, 'u._id': user._id }); + if (!subscription) { + this.logger.debug(`User ${user.username} is not subscribed to room ${room._id}, skipping leave operation`); + return; + } + + const actualMatrixUserId = getUserMatrixId(user, this.serverName); await federationSDK.leaveRoom(roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(actualMatrixUserId)); @@ -697,13 +699,9 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async kickUser(room: IRoomNativeFederated, removedUser: IUser, userWhoRemoved: IUser): Promise { try { - const actualKickedMatrixUserId = isUserNativeFederated(removedUser) - ? removedUser.federation.mui - : `@${removedUser.username}:${this.serverName}`; + const actualKickedMatrixUserId = getUserMatrixId(removedUser, this.serverName); - const actualSenderMatrixUserId = isUserNativeFederated(userWhoRemoved) - ? userWhoRemoved.federation.mui - : `@${userWhoRemoved.username}:${this.serverName}`; + const actualSenderMatrixUserId = getUserMatrixId(userWhoRemoved, this.serverName); await federationSDK.kickUser( roomIdSchema.parse(room.federation.mrid), @@ -732,7 +730,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); const parsedMessage = await toExternalMessageFormat({ message: message.msg, @@ -765,7 +763,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const userMui = `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); await federationSDK.updateRoomName(roomIdSchema.parse(room.federation.mrid), displayName, userIdSchema.parse(userMui)); } @@ -780,7 +778,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const userMui = `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); await federationSDK.setRoomTopic(roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(userMui), topic); } @@ -805,13 +803,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const senderMui = `@${userSender.username}:${this.serverName}`; + const senderMui = getUserMatrixId(userSender, this.serverName); const user = await Users.findOneById(userId); if (!user) { throw new Error(`No user found for ID ${userId}`); } - const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const userMui = getUserMatrixId(user, this.serverName); let powerLevel = 0; if (role === 'owner') { @@ -847,7 +845,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const userMui = isUserNativeFederated(localUser) ? localUser.federation.mui : `@${localUser.username}:${this.serverName}`; + const userMui = getUserMatrixId(localUser, this.serverName); void federationSDK.sendTypingNotification(room.federation.mrid, userMui, isTyping); } @@ -898,4 +896,67 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return results; } + + async updateUserProfile(userId: string, displayName: string): Promise { + try { + const user = await Users.findOneById(userId); + if (!user) { + this.logger.error(`User not found: ${userId}`); + return; + } + + let matrixUserId: string; + if (isUserNativeFederated(user) && user.federation.mui) { + matrixUserId = user.federation.mui; + this.logger.info(`Updating Matrix profile for native federated user ${userId} (${matrixUserId}) to "${displayName}"`); + } else { + if (!user.username) { + this.logger.error(`Local user ${userId} has no username, cannot update profile`); + return; + } + matrixUserId = constructMatrixId(user.username, this.serverName); + this.logger.info(`Updating Matrix profile for local user ${userId} (${matrixUserId}) to "${displayName}" in federated rooms`); + } + + // get all rooms user is member of + const subscriptions = Subscriptions.findByUserId(user._id); + for await (const sub of subscriptions) { + try { + const room = await Rooms.findOneById(sub.rid); + if (!room || !isRoomNativeFederated(room)) { + continue; + } + + await federationSDK.updateMemberProfile( + roomIdSchema.parse(room.federation.mrid), + userIdSchema.parse(matrixUserId), + displayName, + ); + } catch (error) { + // expected: user not a member of the room (invited but never joined, or left) + if (error instanceof Error && error.message.includes('is not a member')) { + this.logger.debug(`Skipping room ${sub.rid}: user not a member`); + } else { + // unexpected error + this.logger.error(`Failed to update profile in room ${sub.rid}:`, error); + } + } + } + } catch (error) { + this.logger.error('Failed to update user profile:', error); + throw error; + } + } + + async emitJoin(membershipEvent: PduForType<'m.room.member'>, eventId: EventID) { + void federationSDK.emit('homeserver.matrix.membership', { + event_id: eventId, + event: membershipEvent, + room_id: membershipEvent.room_id, + state_key: membershipEvent.state_key, + content: { membership: 'join' }, + sender: membershipEvent.sender, + origin_server_ts: Date.now(), + }); + } } diff --git a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts index f0338bfc4821a..4624c3f75653c 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts @@ -373,7 +373,7 @@ export const getMatrixProfilesRoutes = () => { if (field) { return { body: { - [field]: response[field as 'displayname' | 'avatar_url'] || null, + [field]: response?.[field as 'displayname' | 'avatar_url'] || null, }, statusCode: 200, }; diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 704b1b22e0b4a..9932202a311a4 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,4 +1,4 @@ -import { Room } from '@rocket.chat/core-services'; +import { Room, api, Message } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; import { federationSDK, type HomeserverEventSignatures } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; @@ -59,9 +59,41 @@ async function membershipJoinAction(data: HomeserverEventSignatures['homeserver. if (localUser) { const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, localUser._id); + if (subscription) { + const newDisplayName = data.content.displayname; + const prevContent = data.event.unsigned?.prev_content; + const oldDisplayName = prevContent?.displayname; + const isDisplaynameChange = oldDisplayName && oldDisplayName !== newDisplayName; + + if (newDisplayName && newDisplayName !== localUser.name) { + // direct DB update for inbound federation events (don't use _setRealName) + // this is intentional: using _setRealName would trigger afterSaveUser callback + // which would try to send the update back to Matrix, creating a loop + await Users.updateOne({ _id: localUser._id }, { $set: { name: newDisplayName } }); + + // direct broadcast for federation events (bypasses notifyOnUserChange) + // we always want to notify clients about federation updates regardless of DB watcher settings + void api.broadcast('watch.users', { + clientAction: 'updated', + id: localUser._id, + diff: { name: 1 }, + unset: {}, + }); + + // send system message if this is a displayname change (not initial join) + if (isDisplaynameChange) { + try { + await Message.saveSystemMessage('user-display-name-changed', room._id, `${oldDisplayName}|${newDisplayName}`, localUser); + } catch (error) { + logger.error('Failed to send displayname change system message:', error); + } + } + } + return; } + await Room.addUserToRoom(room._id, localUser); return; } @@ -70,10 +102,58 @@ async function membershipJoinAction(data: HomeserverEventSignatures['homeserver. throw new Error('Invalid sender format, missing server name'); } + // first, check if this is a displayname change for an existing user + const [existingUsername] = getUsernameServername(data.state_key, federationSDK.getConfig('serverName')); + const existingUser = await Users.findOneByUsername(existingUsername); + + const newDisplayName = data.content.displayname; + const prevContent = data.event.unsigned?.prev_content; + const oldDisplayName = prevContent?.displayname; + + const isDisplaynameChange = + existingUser && // user exists + prevContent && // has previous state + prevContent.membership === 'join' && // was already joined + oldDisplayName !== newDisplayName && // displayname actually changed + newDisplayName; // new displayname exists + + if (isDisplaynameChange) { + // this is a displayname change, not a new join + // check if user is in this room + const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, existingUser._id); + + if (existingSubscription) { + // direct DB update for inbound federation events (don't use _setRealName) + // this is intentional: using _setRealName would trigger afterSaveUser callback + // which would try to send the update back to Matrix, creating a loop + await Users.updateOne({ _id: existingUser._id }, { $set: { name: newDisplayName } }); + + // direct broadcast for federation events (bypasses notifyOnUserChange) + // we always want to notify clients about federation updates regardless of DB watcher settings + void api.broadcast('watch.users', { + clientAction: 'updated', + id: existingUser._id, + diff: { name: 1 }, + unset: {}, + }); + + // send system message to the room about the displayname change + try { + await Message.saveSystemMessage('user-display-name-changed', room._id, `${oldDisplayName}|${newDisplayName}`, existingUser); + } catch (error) { + logger.error('Failed to send displayname change system message:', error); + } + + logger.info(`Updated displayname for federated user ${existingUser.username} (${existingUser._id})`); + return; + } + } + + // either not a displayname change, or user not in room yet - proceed with createOrUpdate const insertedId = await createOrUpdateFederatedUser({ username: data.event.state_key, origin: serverName, - name: data.content.displayname || (data.state_key as `@${string}:${string}`), + name: newDisplayName || (data.state_key as `@${string}:${string}`), }); const user = await Users.findOneById(insertedId); diff --git a/ee/packages/federation-matrix/src/helpers/matrixId.spec.ts b/ee/packages/federation-matrix/src/helpers/matrixId.spec.ts new file mode 100644 index 0000000000000..24045e15a2c9a --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/matrixId.spec.ts @@ -0,0 +1,203 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { constructMatrixId, getUserMatrixId, validateFederatedUsername } from './matrixId'; + +describe('Federation - Infrastructure - Matrix - matrixId helpers', () => { + describe('#validateFederatedUsername()', () => { + it('should validate valid Matrix User IDs', () => { + expect(validateFederatedUsername('@alice:matrix.org')).toBe(true); + expect(validateFederatedUsername('@user:example.com')).toBe(true); + expect(validateFederatedUsername('@test.user:server.domain.io')).toBe(true); + }); + + it('should validate Matrix User IDs with ports', () => { + expect(validateFederatedUsername('@alice:localhost:8008')).toBe(true); + expect(validateFederatedUsername('@user:192.168.1.1:8448')).toBe(true); + }); + + it('should validate Matrix User IDs with IPv4 addresses', () => { + expect(validateFederatedUsername('@user:192.168.1.1')).toBe(true); + expect(validateFederatedUsername('@test:10.0.0.1')).toBe(true); + }); + + it('should reject invalid formats', () => { + expect(validateFederatedUsername('alice:matrix.org')).toBe(false); // missing @ + expect(validateFederatedUsername('@alice')).toBe(false); // missing domain + expect(validateFederatedUsername('alice')).toBe(false); // not a Matrix ID + expect(validateFederatedUsername('@:matrix.org')).toBe(false); // empty localpart + }); + + it('should reject invalid localparts', () => { + expect(validateFederatedUsername('@ALICE:matrix.org')).toBe(false); // uppercase + expect(validateFederatedUsername('@alice!:matrix.org')).toBe(false); // invalid char + expect(validateFederatedUsername('@alice bob:matrix.org')).toBe(false); // space + }); + + it('should reject invalid ports', () => { + expect(validateFederatedUsername('@alice:matrix.org:99999')).toBe(false); // port too large + expect(validateFederatedUsername('@alice:matrix.org:0')).toBe(false); // port too small + expect(validateFederatedUsername('@alice:matrix.org:abc')).toBe(false); // non-numeric port + }); + }); + + describe('#constructMatrixId()', () => { + it('should construct valid Matrix ID', () => { + expect(constructMatrixId('alice', 'example.com')).toBe('@alice:example.com'); + expect(constructMatrixId('bob', 'matrix.org')).toBe('@bob:matrix.org'); + }); + + it('should sanitize username: convert to lowercase', () => { + expect(constructMatrixId('Alice', 'example.com')).toBe('@alice:example.com'); + expect(constructMatrixId('JOHN', 'example.com')).toBe('@john:example.com'); + }); + + it('should sanitize username: replace invalid characters', () => { + expect(constructMatrixId('Alice!Bob', 'example.com')).toBe('@alice_bob:example.com'); + expect(constructMatrixId('User Name', 'server.com')).toBe('@user_name:server.com'); + expect(constructMatrixId('user@domain', 'server.com')).toBe('@user_domain:server.com'); + }); + + it('should sanitize username: preserve valid characters (. _ - =)', () => { + expect(constructMatrixId('alice.bob', 'example.com')).toBe('@alice.bob:example.com'); + expect(constructMatrixId('user_name', 'example.com')).toBe('@user_name:example.com'); + expect(constructMatrixId('test-user', 'example.com')).toBe('@test-user:example.com'); + expect(constructMatrixId('user=123', 'example.com')).toBe('@user=123:example.com'); + }); + + it('should sanitize username: remove leading/trailing underscores', () => { + expect(constructMatrixId('_alice_', 'example.com')).toBe('@alice:example.com'); + expect(constructMatrixId('___test___', 'example.com')).toBe('@test:example.com'); + }); + + it('should use fallback "user" for all-invalid characters', () => { + expect(constructMatrixId('___', 'example.com')).toBe('@user:example.com'); + expect(constructMatrixId('!!!', 'example.com')).toBe('@user:example.com'); + }); + + it('should throw error for empty server name', () => { + expect(() => constructMatrixId('alice', '')).toThrow('Server name cannot be empty'); + }); + + it('should handle server names with ports', () => { + expect(constructMatrixId('alice', 'example.com:8448')).toBe('@alice:example.com:8448'); + }); + + it('should validate server name format: valid hostnames', () => { + expect(constructMatrixId('alice', 'example.com')).toBe('@alice:example.com'); + expect(constructMatrixId('alice', 'sub.domain.example.com')).toBe('@alice:sub.domain.example.com'); + expect(constructMatrixId('alice', 'localhost')).toBe('@alice:localhost'); + }); + + it('should validate server name format: valid IPv4 addresses', () => { + expect(constructMatrixId('alice', '192.168.1.1')).toBe('@alice:192.168.1.1'); + expect(constructMatrixId('alice', '10.0.0.1')).toBe('@alice:10.0.0.1'); + }); + + it('should validate server name format: valid IPv6 addresses', () => { + expect(constructMatrixId('alice', '[::1]')).toBe('@alice:[::1]'); + expect(constructMatrixId('alice', '[2001:db8::1]')).toBe('@alice:[2001:db8::1]'); + }); + + it('should validate server name format: valid ports', () => { + expect(constructMatrixId('alice', 'example.com:8448')).toBe('@alice:example.com:8448'); + expect(constructMatrixId('alice', 'localhost:8008')).toBe('@alice:localhost:8008'); + expect(constructMatrixId('alice', '192.168.1.1:8448')).toBe('@alice:192.168.1.1:8448'); + }); + + it('should reject server name with spaces', () => { + expect(() => constructMatrixId('alice', 'example .com')).toThrow('Server name cannot contain spaces'); + expect(() => constructMatrixId('alice', ' example.com')).toThrow('Server name cannot contain leading or trailing whitespace'); + expect(() => constructMatrixId('alice', 'example.com ')).toThrow('Server name cannot contain leading or trailing whitespace'); + }); + + it('should reject invalid server name formats', () => { + expect(() => constructMatrixId('alice', 'invalid..domain')).toThrow('Invalid server name format'); + expect(() => constructMatrixId('alice', '-invalid.com')).toThrow('Invalid server name format'); + expect(() => constructMatrixId('alice', 'invalid-.com')).toThrow('Invalid server name format'); + }); + + it('should reject invalid ports', () => { + expect(() => constructMatrixId('alice', 'example.com:0')).toThrow('Invalid port in server name: 0 (must be 1-65535)'); + expect(() => constructMatrixId('alice', 'example.com:99999')).toThrow('Invalid port in server name: 99999 (must be 1-65535)'); + expect(() => constructMatrixId('alice', 'example.com:abc')).toThrow('Invalid port in server name: abc (must be 1-65535)'); + }); + }); + + describe('#getUserMatrixId()', () => { + const mockServerName = 'example.com'; + + it('should return existing Matrix ID for native federated user', () => { + const user = { + _id: 'user1', + username: 'alice', + federated: true, + federation: { + version: 1, + mui: '@alice:example.com', + origin: 'example.com', + }, + } as Pick; + + const result = getUserMatrixId(user, mockServerName); + expect(result).toBe('@alice:example.com'); + }); + + it('should throw error for native federated user without mui', () => { + const user = { + _id: 'user2', + username: 'bob', + federated: true, + federation: { + version: 1, + origin: 'example.com', + }, + } as Pick; + + expect(() => getUserMatrixId(user, mockServerName)).toThrow('Native federated user user2 is missing Matrix ID (mui)'); + }); + + it('should generate Matrix ID for local user without storing', () => { + const user = { + _id: 'user3', + username: 'carol', + } as Pick; + + const result = getUserMatrixId(user, mockServerName); + expect(result).toBe('@carol:example.com'); + }); + + it('should sanitize username when generating Matrix ID for local user', () => { + const user = { + _id: 'user4', + username: 'Alice!Bob', + } as Pick; + + const result = getUserMatrixId(user, mockServerName); + expect(result).toBe('@alice_bob:example.com'); + }); + + it('should throw error for user without username', () => { + const user = { + _id: 'user5', + } as Pick; + + expect(() => getUserMatrixId(user, mockServerName)).toThrow('User user5 has no username, cannot generate Matrix ID'); + }); + + it('should return stored mui even if username changed (immutable)', () => { + const user = { + _id: 'user6', + username: 'newusername', + federated: true, + federation: { + version: 1, + mui: '@oldusername:example.com', + origin: 'example.com', + }, + } as Pick; + + const result = getUserMatrixId(user, mockServerName); + expect(result).toBe('@oldusername:example.com'); + }); + }); +}); diff --git a/ee/packages/federation-matrix/src/helpers/matrixId.ts b/ee/packages/federation-matrix/src/helpers/matrixId.ts new file mode 100644 index 0000000000000..5d1e08972ec1e --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/matrixId.ts @@ -0,0 +1,139 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { isUserNativeFederated } from '@rocket.chat/core-typings'; +import type { UserID } from '@rocket.chat/federation-sdk'; + +function sanitizeForMatrixLocalpart(username: string): string { + if (!username) { + throw new Error('Username cannot be empty'); + } + + let sanitized = username.toLowerCase().replace(/[^a-z0-9._=-]/g, '_'); + sanitized = sanitized.replace(/^_+/, '').replace(/_+$/, ''); + + if (sanitized.length === 0) { + sanitized = 'user'; + } + + if (sanitized.length > 255) { + sanitized = sanitized.substring(0, 255); + } + + return sanitized; +} + +function validateServerNameFormat(serverName: string): boolean { + // Handle IPv6 addresses with brackets - they contain multiple colons + let domain: string; + let port: string | undefined; + + if (serverName.startsWith('[')) { + // IPv6 format: [address] or [address]:port + const ipv6Match = serverName.match(/^(\[[0-9a-f:.]+\])(?::(\d+))?$/i); + if (!ipv6Match) { + return false; + } + domain = ipv6Match[1]; + port = ipv6Match[2]; + } else { + // Hostname or IPv4: split on last colon for port + const lastColonIndex = serverName.lastIndexOf(':'); + if (lastColonIndex === -1) { + domain = serverName; + } else { + domain = serverName.substring(0, lastColonIndex); + port = serverName.substring(lastColonIndex + 1); + } + } + + // validate domain (hostname, IPv4, or IPv6) + const hostnameRegex = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*$/i; + const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/; + const ipv6Regex = /^\[([0-9a-f:.]+)\]$/i; + + if (!(hostnameRegex.test(domain) || ipv4Regex.test(domain) || ipv6Regex.test(domain))) { + return false; + } + + if (port !== undefined) { + const portNum = Number(port); + if (!/^[0-9]+$/.test(port) || portNum < 1 || portNum > 65535) { + return false; + } + } + + return true; +} + +export function constructMatrixId(username: string, serverName: string): UserID { + if (!serverName) { + throw new Error('Server name cannot be empty'); + } + + const trimmed = serverName.trim(); + if (trimmed !== serverName) { + throw new Error('Server name cannot contain leading or trailing whitespace'); + } + + if (/\s/.test(serverName)) { + throw new Error('Server name cannot contain spaces'); + } + + // Check for invalid port specifically to provide a better error message + let port: string | undefined; + if (serverName.startsWith('[')) { + const ipv6Match = serverName.match(/^(\[[0-9a-f:.]+\])(?::(\d+))?$/i); + if (ipv6Match) { + port = ipv6Match[2]; + } + } else { + const lastColonIndex = serverName.lastIndexOf(':'); + if (lastColonIndex !== -1) { + port = serverName.substring(lastColonIndex + 1); + } + } + + if (port !== undefined) { + const portNum = Number(port); + if (!/^[0-9]+$/.test(port) || portNum < 1 || portNum > 65535) { + throw new Error(`Invalid port in server name: ${port} (must be 1-65535)`); + } + } + + if (!validateServerNameFormat(serverName)) { + throw new Error(`Invalid server name format: ${serverName}`); + } + + const localpart = sanitizeForMatrixLocalpart(username); + return `@${localpart}:${serverName}` as UserID; +} + +export function validateFederatedUsername(mxid: string): mxid is UserID { + if (!mxid.startsWith('@')) return false; + + const parts = mxid.substring(1).split(':'); + if (parts.length < 2) return false; + + const localpart = parts[0]; + const domainAndPort = parts.slice(1).join(':'); + + // validate localpart: only lowercase alphanumeric, ., _, -, or encoded characters + const localpartRegex = /^(?:[a-z0-9._\-]|=[0-9a-fA-F]{2}){1,255}$/; + if (!localpartRegex.test(localpart)) return false; + + return validateServerNameFormat(domainAndPort); +} + +export function getUserMatrixId(user: Pick, serverName: string): UserID { + if (isUserNativeFederated(user)) { + if (!user.federation.mui) { + throw new Error(`Native federated user ${user._id} is missing Matrix ID (mui)`); + } + return user.federation.mui as UserID; + } + + if (!user.username) { + throw new Error(`User ${user._id} has no username, cannot generate Matrix ID`); + } + + return constructMatrixId(user.username, serverName); +} diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 1d036069a8d86..0a7298aef2aaf 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,5 +1,5 @@ import type { IMessage, IRoomFederated, IRoomNativeFederated, IUser } from '@rocket.chat/core-typings'; -import type { EventStore } from '@rocket.chat/federation-sdk'; +import type { EventID, EventStore, PduForType } from '@rocket.chat/federation-sdk'; export interface IFederationMatrixService { createRoom(room: IRoomFederated, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }>; @@ -28,4 +28,6 @@ export interface IFederationMatrixService { inviteUsersToRoom(room: IRoomFederated, usersUserName: string[], inviter: IUser): Promise; notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise; verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; + emitJoin(membershipEvent: PduForType<'m.room.member'>, eventId: EventID): Promise; + updateUserProfile(userId: string, displayName: string): Promise; } diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index d0442adc148d4..a44e475f53b11 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -89,6 +89,7 @@ const MessageTypes = [ 'room_e2e_disabled', 'user-muted', 'user-unmuted', + 'user-display-name-changed', 'room-removed-read-only', 'room-set-read-only', 'room-allowed-reacting', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9c8c6be57728e..935f2df20c46a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5547,6 +5547,7 @@ "User_has_been_removed_from_team": "User has been removed from team", "User_has_been_unignored": "User is no longer ignored", "User_has_been_unmuted": "unmuted {{user_unmuted}}", + "User_changed_display_name": "changed display name from \"{{old_name}}\" to \"{{new_name}}\"", "User_is_blocked": "User is blocked", "User_is_no_longer_an_admin": "User is no longer an admin", "User_is_now_an_admin": "User is now an admin", diff --git a/packages/message-types/src/registrations/common.ts b/packages/message-types/src/registrations/common.ts index 684ea00633096..0fac3750432fd 100644 --- a/packages/message-types/src/registrations/common.ts +++ b/packages/message-types/src/registrations/common.ts @@ -115,6 +115,17 @@ export default (instance: MessageTypes) => { text: (t, message) => t('User_has_been_unmuted', { user_unmuted: message.msg }), }); + instance.registerType({ + id: 'user-display-name-changed', + system: true, + text: (t, message) => + t('User_changed_display_name', { + username: message.u.username, + old_name: message.msg.split('|')[0], + new_name: message.msg.split('|')[1], + }), + }); + instance.registerType({ id: 'subscription-role-added', system: true,