diff --git a/apps/meteor/client/startup/slashCommands/federation.ts b/apps/meteor/client/startup/slashCommands/federation.ts index 25728ad4601a6..fefba935eddaf 100644 --- a/apps/meteor/client/startup/slashCommands/federation.ts +++ b/apps/meteor/client/startup/slashCommands/federation.ts @@ -18,3 +18,12 @@ slashCommands.add({ previewer, previewCallback, }); + +slashCommands.add({ + command: 'xmpp-join', + options: { + description: 'Join xmpp rooms', + params: '#channel', + }, + providesPreview: false, +}); diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 259aeded43a2b..7721ba528333d 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,7 +1,7 @@ import { FederationMatrix, Message, MeteorError, Room } from '@rocket.chat/core-services'; import { isEditedMessage, isRoomNativeFederated, isUserNativeFederated, isBannedSubscription } from '@rocket.chat/core-typings'; import type { IRoomNativeFederated, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; -import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; +import { isReservedByExclusiveBridge, validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { callbacks } from '../../../../server/lib/callbacks'; @@ -12,6 +12,7 @@ import { afterUnbanFromRoomCallback } from '../../../../server/lib/callbacks/aft import { beforeAddUsersToRoom, beforeAddUserToRoom } from '../../../../server/lib/callbacks/beforeAddUserToRoom'; import { beforeChangeRoomRole } from '../../../../server/lib/callbacks/beforeChangeRoomRole'; import { prepareCreateRoomCallback } from '../../../../server/lib/callbacks/beforeCreateRoomCallback'; +import { checkUsernameAvailabilityCallback } from '../../../../server/lib/callbacks/checkUsernameAvailabilityCallback'; import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../../../../server/lib/notifyListener'; import { FederationActions } from '../../../../server/services/room/hooks/BeforeFederationActions'; @@ -378,3 +379,20 @@ callbacks.add('afterSaveUser', async ({ user: userUpdated, oldUser: oldUserData void FederationMatrix.updateUserName(userUpdated); } }); + +// Reserve handles that fall within a bridge's exclusive namespace, so a regular user cannot +// register or rename into a localpart only the bridge is allowed to own. Usernames are checked +// against the bridge's exclusive `users` namespace; room/team names against its `aliases`/`rooms` +// namespaces. Inert when federation is disabled (the helper matches against loaded appservice +// registrations, of which there are none). Every handle assignment path — user creation, +// SSO/LDAP assignment and renames, plus room renames and team creation — funnels through +// `checkUsernameAvailability`. +checkUsernameAvailabilityCallback.add( + (name, type) => { + if (isReservedByExclusiveBridge(type, name)) { + throw new MeteorError('error-username-reserved-by-bridge', 'Name is reserved by a federation bridge'); + } + }, + callbacks.priority.HIGH, + 'federation-bridge-namespace', +); diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index 8a8b53bc28bf5..abe432fd8521a 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -1,9 +1,13 @@ import { api, FederationMatrix as FederationMatrixService } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { FederationMatrix, configureFederationMatrixSettings, setupFederationMatrix } from '@rocket.chat/federation-matrix'; import { InstanceStatus } from '@rocket.chat/instance-status'; import { License } from '@rocket.chat/license'; import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; +import { i18n } from '../../../server/lib/i18n'; +import { slashCommands } from '../../../server/lib/utils/slashCommand'; import { StreamerCentral } from '../../../server/modules/streamer/streamer.module'; import { settings } from '../../../server/settings'; import { registerFederationRoutes } from '../api/federation'; @@ -20,7 +24,7 @@ const configureFederation = async () => { } try { - configureFederationMatrixSettings({ + await configureFederationMatrixSettings({ instanceId: InstanceStatus.id(), domain: settings.get('Federation_Service_Domain'), signingKey: settings.get('Federation_Service_Matrix_Signing_Key'), @@ -31,6 +35,10 @@ const configureFederation = async () => { processEDUTyping: settings.get('Federation_Service_EDU_Process_Typing'), processEDUPresence: settings.get('Federation_Service_EDU_Process_Presence'), processEDUReceipt: settings.get('Federation_Service_EDU_Process_Receipt'), + xmppEnabled: settings.get('Federation_XMPP_Enabled'), + xmppBridgeURL: settings.get('Federation_XMPP_Bridge_URL'), + xmppBridgeHSToken: settings.get('Federation_XMPP_Bridge_HS_Token'), + xmppBridgeASToken: settings.get('Federation_XMPP_Bridge_AS_Token'), }); } catch (err) { logger.error({ msg: 'Failed to start federation-matrix service', err }); @@ -55,6 +63,16 @@ export const startFederationService = async (): Promise => { } }); + // `setupFederationMatrix()` runs the SDK's `init()`, which registers the DB + // collections (including `AppServiceStateCollection`). It must complete + // before the settings watcher below, whose initial fire calls `setConfig` + // and resolves repositories that depend on those collections. + try { + await setupFederationMatrix(); + } catch (err) { + logger.error({ msg: 'Failed to setup federation-matrix:', err }); + } + settings.watchMultiple( [ 'Federation_Service_Enabled', @@ -67,15 +85,52 @@ export const startFederationService = async (): Promise => { 'Federation_Service_Matrix_Signing_Version', 'Federation_Service_Join_Encrypted_Rooms', 'Federation_Service_Join_Non_Private_Rooms', + 'Federation_XMPP_Enabled', + 'Federation_XMPP_Bridge_URL', + 'Federation_XMPP_Bridge_HS_Token', + 'Federation_XMPP_Bridge_AS_Token', ], async () => { await configureFederation(); }, ); - try { - await setupFederationMatrix(); - } catch (err) { - logger.error({ msg: 'Failed to setup federation-matrix:', err }); - } + slashCommands.add({ + command: 'xmpp-join', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'xmpp-join'>): Promise => { + // the helper advertises `#channel`, so accept the leading # and strip it before joining + const channel = params.trim().replace(/^#/, ''); + if (!channel) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Federation_XMPP_Join_Channel_Required', { + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + const user = await Users.findOneById(userId); + if (!user) { + logger.error({ msg: 'User not found for joining xmpp room', userId }); + return; + } + + const joined = await FederationMatrixService.joinAppServiceRoom(`_xmpp_${channel}`, user); + + const lng = settings.get('Language') || 'en'; + if (joined) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: `${i18n.t('Federation_XMPP_Join_Channel_Success', { lng })}`, + }); + } else { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: `${i18n.t('Federation_XMPP_Join_Channel_Failed', { lng })}`, + }); + } + }, + options: { + description: 'Join xmpp rooms', + params: '#channel', + }, + }); }; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b612964e327b2..db7e25f28c5f5 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -107,7 +107,7 @@ "@rocket.chat/emitter": "^0.33.0", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.6.3", + "@rocket.chat/federation-sdk": "0.7.0", "@rocket.chat/fuselage": "^0.82.0", "@rocket.chat/fuselage-forms": "~1.5.0", "@rocket.chat/fuselage-hooks": "~0.43.0", diff --git a/apps/meteor/server/lib/callbacks/checkUsernameAvailabilityCallback.ts b/apps/meteor/server/lib/callbacks/checkUsernameAvailabilityCallback.ts new file mode 100644 index 0000000000000..97b4e3c687b19 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/checkUsernameAvailabilityCallback.ts @@ -0,0 +1,17 @@ +import { Callbacks } from './callbacksBase'; + +/** + * Discriminates what kind of handle is being validated, so handlers can check the right + * namespace: `user` for a person's username, `room` for a room/team name. Rooms, teams + * and users share one local handle namespace. + */ +export type UsernameAvailabilityCheckType = 'user' | 'room'; + +/** + * Runs while validating whether a handle may be used (user creation, SSO/LDAP assignment and + * renames, plus room renames and team creation, all funnel through `checkUsernameAvailability`). + * Handlers should throw to reject the handle. The `type` tells the handler whether a user or + * a room/team is being validated. With no handler registered this is a no-op. + */ +export const checkUsernameAvailabilityCallback = + Callbacks.create<(username: string, type: UsernameAvailabilityCheckType) => void>('checkUsernameAvailability'); diff --git a/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts b/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts index 98ace431b8bf3..98c4ecdb16d6b 100644 --- a/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts +++ b/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts @@ -140,20 +140,22 @@ export const FileUpload = { }, async validateFileUpload(file: IUpload, content?: Buffer | string) { - if (!Match.test(file.rid, String)) { + const isFederationUpload = Boolean(file.federation?.mxcUri); + + if (!isFederationUpload && !Match.test(file.rid, String)) { return false; } // livechat users can upload files but they don't have an userId const user = (file.userId && (await Users.findOne(file.userId))) || undefined; - const room = await Rooms.findOneById(file.rid); - if (!room) { + const room = file.rid ? await Rooms.findOneById(file.rid) : undefined; + if (!isFederationUpload && !room) { return false; } const directMessageAllowed = settings.get('FileUpload_Enabled_Direct'); const fileUploadAllowed = settings.get('FileUpload_Enabled'); - if (user?.type !== 'app' && (await canAccessRoomAsync(room, user, file)) !== true) { + if (!isFederationUpload && room && user?.type !== 'app' && (await canAccessRoomAsync(room, user, file)) !== true) { return false; } const language = user?.language || 'en'; @@ -162,7 +164,7 @@ export const FileUpload = { throw new Meteor.Error('error-file-upload-disabled', reason); } - if (!directMessageAllowed && room.t === 'd') { + if (room && !directMessageAllowed && room.t === 'd') { const reason = i18n.t('File_not_allowed_direct_messages', { lng: language }); throw new Meteor.Error('error-direct-message-file-upload-not-allowed', reason); } diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomName.ts b/apps/meteor/server/lib/rooms/settings/saveRoomName.ts index 6116e0b4bde97..4853e4f36e6f9 100644 --- a/apps/meteor/server/lib/rooms/settings/saveRoomName.ts +++ b/apps/meteor/server/lib/rooms/settings/saveRoomName.ts @@ -22,8 +22,8 @@ const updateFName = async (rid: string, displayName: string): Promise<(UpdateRes }; const updateRoomName = async (rid: string, displayName: string, slugifiedRoomName: string) => { - // Check if the username is available - if (!(await checkUsernameAvailability(slugifiedRoomName))) { + // Check if the name is available + if (!(await checkUsernameAvailability(slugifiedRoomName, 'room'))) { throw new Meteor.Error('error-duplicate-handle', `A room, team or user with name '${slugifiedRoomName}' already exists`, { function: 'RocketChat.updateRoomName', handle: slugifiedRoomName, diff --git a/apps/meteor/server/lib/users/checkUsernameAvailability.ts b/apps/meteor/server/lib/users/checkUsernameAvailability.ts index 6d32e16fef244..b9a3238a0456e 100644 --- a/apps/meteor/server/lib/users/checkUsernameAvailability.ts +++ b/apps/meteor/server/lib/users/checkUsernameAvailability.ts @@ -5,6 +5,8 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { settings } from '../../settings'; +import type { UsernameAvailabilityCheckType } from '../callbacks/checkUsernameAvailabilityCallback'; +import { checkUsernameAvailabilityCallback } from '../callbacks/checkUsernameAvailabilityCallback'; import { validateName } from '../shared/validateName'; let usernameBlackList: RegExp[] = []; @@ -39,7 +41,7 @@ export const checkUsernameAvailabilityWithValidation = async function (userId: s return checkUsernameAvailability(username); }; -export const checkUsernameAvailability = async function (username: string): Promise { +export const checkUsernameAvailability = async function (username: string, type: UsernameAvailabilityCheckType = 'user'): Promise { if (usernameIsBlocked(username, usernameBlackList) || !validateName(username)) { throw new Meteor.Error('error-blocked-username', `${_.escape(username)} is blocked and can't be used!`, { method: 'checkUsernameAvailability', @@ -61,5 +63,11 @@ export const checkUsernameAvailability = async function (username: string): Prom return false; } - return true; + try { + await checkUsernameAvailabilityCallback.run(username, type); + + return true; + } catch (error) { + return false; + } }; diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index 27b442bcf6da0..15e9530d66359 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -40,7 +40,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService { protected name = 'team'; async create(uid: string, { team, room = { name: team.name, extraData: {} }, members, owner }: ITeamCreateParams): Promise { - if (!(await checkUsernameAvailability(team.name))) { + if (!(await checkUsernameAvailability(team.name, 'room'))) { throw new Error('team-name-already-exists'); } diff --git a/apps/meteor/server/services/upload/service.ts b/apps/meteor/server/services/upload/service.ts index ac956815716ca..790638e7d9d09 100644 --- a/apps/meteor/server/services/upload/service.ts +++ b/apps/meteor/server/services/upload/service.ts @@ -27,9 +27,9 @@ const logger = new Logger('UploadService'); export class UploadService extends ServiceClassInternal implements IUploadService { protected name = 'upload'; - async uploadFile({ buffer, details }: IUploadFileParams): Promise { + async uploadFile({ buffer, details, federation }: IUploadFileParams): Promise { const fileStore = FileUpload.getStore('Uploads'); - return fileStore.insert(details, buffer); + return fileStore.insert({ ...details, ...(federation && { federation }) }, buffer); } async sendFileMessage({ roomId, file, userId, message }: ISendFileMessageParams): Promise { diff --git a/apps/meteor/server/settings/federation-service.ts b/apps/meteor/server/settings/federation-service.ts index 5a250b65484aa..ba0310deb8714 100644 --- a/apps/meteor/server/settings/federation-service.ts +++ b/apps/meteor/server/settings/federation-service.ts @@ -116,5 +116,40 @@ export const createFederationServiceSettings = async (): Promise => { modules: ['federation'], invalidValue: false, }); + + await this.section('XMPP', async function () { + await this.add('Federation_XMPP_Enabled', false, { + type: 'boolean', + enterprise: true, + modules: ['federation'], + i18nLabel: 'Enabled', + invalidValue: false, + enableQuery: { _id: 'Federation_Service_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_URL', '', { + type: 'string', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_XMPP_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_HS_Token', '', { + type: 'password', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_XMPP_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_AS_Token', '', { + type: 'password', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_XMPP_Enabled', value: true }, + }); + }); }); }; diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 427874843bc3d..a7a6e74eeb55e 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -22,7 +22,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.33.0", - "@rocket.chat/federation-sdk": "0.6.3", + "@rocket.chat/federation-sdk": "0.7.0", "@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 a1f89fb5b7d31..9ba7654adab02 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1,4 +1,12 @@ -import { Authorization, type IFederationMatrixService, Room, ServiceClass, Settings } from '@rocket.chat/core-services'; +import { + Authorization, + type IFederationMatrixService, + Message, + MeteorService, + Room, + ServiceClass, + Settings, +} from '@rocket.chat/core-services'; import { isDeletedMessage, isMessageFromMatrixFederation, @@ -9,13 +17,20 @@ import { } from '@rocket.chat/core-typings'; import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated, ISubscription } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, FederationRequestError } from '@rocket.chat/federation-sdk'; -import type { EventID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk'; +import type { EventID, FileMessageType, PduForType, PresenceState } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; import { createOrUpdateFederatedUser } from './helpers/createOrUpdateFederatedUser'; import { extractDomainFromMatrixUserId } from './helpers/extractDomainFromMatrixUserId'; -import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; +import { getThreadMessageId } from './helpers/getThreadMessageId'; +import { handleMediaMessage } from './helpers/handleMediaMessage'; +import { + toExternalMessageFormat, + toExternalQuoteMessageFormat, + toInternalMessageFormat, + toInternalQuoteMessageFormat, +} from './helpers/message.parsers'; import { validateFederatedUsername } from './helpers/validateFederatedUsername'; import { MatrixMediaService } from './services/MatrixMediaService'; import { shortnameToUnicode } from './utils/emojiConverter'; @@ -1027,4 +1042,175 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS }), ); } + + async joinAppServiceRoom(roomAlias: string, user: IUser): Promise { + try { + if (isUserNativeFederated(user)) { + throw new Error('Federated users cannot join App Service rooms'); + } + + await federationSDK.joinAppServiceRoom(roomAlias, userIdSchema.parse(`@${user.username}:${this.serverName}`)); + + return true; + } catch (err) { + this.logger.error({ msg: 'Failed to join App Service room', err, username: user.username, roomAlias }); + + return false; + } + } + + async saveFederationMessage({ event, event_id: eventId }: { event: PduForType<'m.room.message'>; event_id: EventID }): Promise { + const { msgtype, body } = event.content; + // body is typed as required, but events from untrusted homeservers may omit it + const messageBody = String(body ?? ''); + + if (!messageBody && !msgtype) { + this.logger.debug('Received message event with empty body and no msgtype, skipping processing'); + return; + } + + // at this point we know for sure the user already exists + const user = await Users.findOneByUsername(event.sender); + if (!user) { + throw new Error(`User not found for sender: ${event.sender}`); + } + + const room = await Rooms.findOne({ 'federation.mrid': event.room_id }); + if (!room) { + throw new Error(`No mapped room found for room_id: ${event.room_id}`); + } + + const serverName = federationSDK.getConfig('serverName'); + + const relation = event.content['m.relates_to']; + + // SPEC: For example, an m.thread relationship type denotes that the event is part of a “thread” of messages and should be rendered as such. + const hasRelation = relation && 'rel_type' in relation; + + const isThreadMessage = hasRelation && relation.rel_type === 'm.thread'; + + const threadRootEventId = isThreadMessage && relation.event_id; + + // SPEC: Though rich replies form a relationship to another event, they do not use rel_type to create this relationship. + // Instead, a subkey named m.in_reply_to is used to describe the reply’s relationship, + const isRichReply = relation && !('rel_type' in relation) && 'm.in_reply_to' in relation; + + const quoteMessageEventId = isRichReply && relation['m.in_reply_to']?.event_id; + + const thread = threadRootEventId ? await getThreadMessageId(threadRootEventId) : undefined; + + const isEditedMessage = hasRelation && relation.rel_type === 'm.replace'; + if (isEditedMessage && relation.event_id && event.content['m.new_content']) { + this.logger.debug('Received edited message from Matrix, updating existing message'); + const originalMessage = await Messages.findOneByFederationId(relation.event_id); + if (!originalMessage) { + this.logger.error({ event_id: relation.event_id, msg: 'Original message not found for edit' }); + return; + } + if (originalMessage.federation?.eventId !== relation.event_id) { + return; + } + if (originalMessage.msg === event.content['m.new_content'].body) { + this.logger.debug('No changes in message content, skipping update'); + return; + } + + if (quoteMessageEventId) { + const messageToReplyToUrl = await MeteorService.getMessageURLToReplyTo(room.t as string, room._id, originalMessage._id); + const formatted = await toInternalQuoteMessageFormat({ + messageToReplyToUrl, + formattedMessage: event.content.formatted_body || '', + rawMessage: messageBody, + homeServerDomain: serverName, + senderExternalId: event.sender, + }); + await Message.updateMessage( + { + ...originalMessage, + msg: formatted, + }, + user, + originalMessage, + ); + return; + } + + const formatted = toInternalMessageFormat({ + rawMessage: event.content['m.new_content'].body, + formattedMessage: event.content.formatted_body || '', + homeServerDomain: serverName, + senderExternalId: event.sender, + }); + + await Message.updateMessage( + { + ...originalMessage, + msg: formatted, + }, + user, + originalMessage, + ); + return; + } + + // Media must be handled before quote replies: a rich reply is valid on any msgtype, + // and letting the quote path win would save just the filename and drop the attachment. + const isMediaMessage = Object.values(fileTypes).includes(msgtype as FileMessageType); + if (isMediaMessage && 'url' in event.content) { + const result = await handleMediaMessage( + event.content.url, + event.content.info, + msgtype, + messageBody, + user, + room, + event.room_id, + eventId, + thread, + ); + await Message.saveMessageFromFederation({ ...result, ts: new Date(event.origin_server_ts) }); + return; + } + + if (quoteMessageEventId) { + const originalMessage = await Messages.findOneByFederationId(quoteMessageEventId); + if (!originalMessage) { + this.logger.error({ quoteMessageEventId, msg: 'Original message not found for quote' }); + return; + } + const messageToReplyToUrl = await MeteorService.getMessageURLToReplyTo(room.t as string, room._id, originalMessage._id); + const formatted = await toInternalQuoteMessageFormat({ + messageToReplyToUrl, + formattedMessage: event.content.formatted_body || '', + rawMessage: messageBody, + homeServerDomain: serverName, + senderExternalId: event.sender, + }); + await Message.saveMessageFromFederation({ + fromId: user._id, + rid: room._id, + msg: formatted, + federation_event_id: eventId, + thread, + ts: new Date(event.origin_server_ts), + }); + return; + } + + const formatted = toInternalMessageFormat({ + rawMessage: messageBody, + formattedMessage: event.content.formatted_body || '', + homeServerDomain: serverName, + senderExternalId: event.sender, + }); + + await Message.saveMessageFromFederation({ + fromId: user._id, + rid: room._id, + msg: formatted, + federation_event_id: eventId, + thread, + ts: new Date(event.origin_server_ts), + }); + } } diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts new file mode 100644 index 0000000000000..ca7c1b18dc3ba --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts @@ -0,0 +1,114 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +import { logger } from '../../logger'; + +export type ClientRouter = Router<'/client', any>; + +// Logs an error and returns the matching Matrix 500 response. Use inside handler-local +// catch blocks that swallow the error. The same message is used for both the log and +// the response body, avoiding duplication. +export const internalError = (msg: string, err?: unknown, context?: Record) => { + logger.error({ msg, err, ...context }); + return { + statusCode: 500 as const, + body: { errcode: 'M_UNKNOWN', error: msg }, + }; +}; + +// The federation SDK throws an Error with name 'UnknownRoomError' (message `Room does not exist`) +// when a room isn't known to this homeserver. The class isn't exported for `instanceof`, so we match on +// `.name` — the same check the SDK uses internally. +export const isUnknownRoomError = (err: unknown): err is Error => err instanceof Error && err.name === 'UnknownRoomError'; + +// Matrix response for a room this homeserver doesn't know about. Mirrors Synapse: it must be a 4xx (never +// 5xx) so bridges like matrix-bifrost treat it as terminal and stop retrying, instead of hammering the +// endpoint every 100ms as if it were a transient server fault. +export const roomNotFound = () => + ({ + statusCode: 403 as const, + body: { + errcode: 'M_FORBIDDEN', + error: "You aren't a member of the room and weren't previously a member of the room.", + }, + }) as const; + +// Logs a warning and returns the matching Matrix 501 response. Use for endpoints/branches +// that are deliberately not implemented yet, so hits on those paths stay visible in the logs. +export const notImplemented = (msg: string, context?: Record) => { + logger.warn({ msg, ...context }); + return { + statusCode: 501 as const, + body: { errcode: 'M_UNRECOGNIZED', error: msg }, + }; +}; + +export const tags = ['Federation']; +export const license: ['federation'] = ['federation']; + +export const MATRIX_USER_ID_PATTERN = '^@[A-Za-z0-9_=\\/.+-]+:(.+)$'; +export const MATRIX_ROOM_ID_PATTERN = '^![A-Za-z0-9_=\\/.+-]+:(.+)$'; + +const MatrixErrorSchema = { + type: 'object', + properties: { + errcode: { type: 'string' }, + error: { type: 'string' }, + }, + required: ['errcode', 'error'], +}; + +export const isMatrixErrorProps = ajv.compile(MatrixErrorSchema); + +// Matches only the literal `{}` these endpoints return per spec. +const EmptyObjectResponseSchema = { + type: 'object', + additionalProperties: false, +}; + +export const isEmptyObjectResponseProps = ajv.compile(EmptyObjectResponseSchema); + +const ImpersonationQuerySchema = { + type: 'object', + properties: { + user_id: { + type: 'string', + pattern: MATRIX_USER_ID_PATTERN, + description: 'Matrix user ID to impersonate; must be in the AS user namespace', + }, + }, + required: [], +}; + +export const isImpersonationQueryProps = ajvQuery.compile<{ user_id?: string }>(ImpersonationQuerySchema); + +const RoomIdParamsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + }, + required: ['roomId'], +}; + +export const isRoomIdParamsProps = ajv.compile(RoomIdParamsSchema); + +const UserIdParamsSchema = { + type: 'object', + properties: { + userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + }, + required: ['userId'], +}; + +export const isUserIdParamsProps = ajv.compile(UserIdParamsSchema); + +const ProfileFieldParamsSchema = { + type: 'object', + properties: { + userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + field: { type: 'string' }, + }, + required: ['userId', 'field'], +}; + +export const isProfileFieldParamsProps = ajv.compile(ProfileFieldParamsSchema); diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts new file mode 100644 index 0000000000000..c208f55cc860f --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -0,0 +1,195 @@ +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { isMatrixErrorProps, license, tags } from './_shared'; +import { createOrUpdateFederatedUser } from '../../../helpers/createOrUpdateFederatedUser'; +import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../../helpers/parseXmppUserId'; +import { logger } from '../../logger'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const RegisterBodySchema = { + type: 'object', + properties: { + type: { type: 'string' }, + username: { type: 'string' }, + }, + required: ['type', 'username'], + additionalProperties: true, +}; + +const isRegisterBodyProps = ajv.compile(RegisterBodySchema); + +const RegisterResponseSchema = { + type: 'object', + properties: { + user_id: { type: 'string' }, + home_server: { type: 'string' }, + access_token: { type: 'string' }, + }, + required: ['user_id'], +}; + +const isRegisterResponseProps = ajv.compile(RegisterResponseSchema); + +const WhoamiResponseSchema = { + type: 'object', + properties: { + user_id: { type: 'string' }, + device_id: { type: 'string' }, + is_guest: { type: 'boolean' }, + }, + required: ['user_id'], + additionalProperties: true, +}; + +const isWhoamiResponseProps = ajv.compile(WhoamiResponseSchema); + +export const addAccountRoutes = (router: ClientRouter) => { + router + // POST /_matrix/client/v3/register + .post( + '/v3/register', + { + body: isRegisterBodyProps, + response: { + 200: isRegisterResponseProps, + 400: isMatrixErrorProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const body = await c.req.json(); + if (body.type !== 'm.login.application_service') { + return { + statusCode: 400, + body: { + errcode: 'M_FORBIDDEN', + error: 'AS registration requires auth.type=m.login.application_service', + }, + }; + } + + const serverName = federationSDK.getConfig('serverName'); + + // An application service may only register users that no *other* bridge exclusively claims. + // Registering within its own exclusive namespace is fine; another bridge's is M_EXCLUSIVE. + const appService = c.get('appService') as ReturnType; + + const isReservedByAnotherAppService = (candidate: string): boolean => { + const mxid = candidate.startsWith('@') ? candidate : `@${candidate}:${serverName}`; + const owner = federationSDK.isExclusiveNamespace('users', mxid); + return Boolean(owner && owner.registration._id !== appService?.registration._id); + }; + + const decoded = decodeXmppUserId(body.username); + + if (!isFullXmppUserId(decoded)) { + // The spec defines `username` as the desired localpart; normalize either form to the + // fully-qualified MXID, which is what gets stored and what `user_id` must carry. + const withSigil = body.username.startsWith('@') ? body.username : `@${body.username}`; + const userId = withSigil.includes(':') ? withSigil : `${withSigil}:${serverName}`; + + if (isReservedByAnotherAppService(userId)) { + return { + statusCode: 400, + body: { + errcode: 'M_EXCLUSIVE', + error: 'Username is in an exclusive namespace of another application service', + }, + }; + } + + await createOrUpdateFederatedUser({ + username: userId, + origin: serverName, + asId: appService?.registration._id, + }); + + return { + statusCode: 200, + body: { + user_id: userId, + }, + }; + } + + let decodedUsername; + try { + decodedUsername = parseXmppUserId(decoded); + } catch (error) { + logger.warn({ msg: 'Malformed XMPP user id during AS registration', username: body.username, err: error }); + return { + statusCode: 400, + body: { + errcode: 'M_INVALID_USERNAME', + error: 'Could not derive a username from the provided XMPP user id', + }, + }; + } + + if (!decodedUsername.resource) { + logger.warn({ msg: 'Could not derive resource from full XMPP user id during AS registration', username: body.username }); + return { + statusCode: 400, + body: { + errcode: 'M_INVALID_USERNAME', + error: 'Could not derive a username from the provided XMPP user id', + }, + }; + } + + const username = `@${decodedUsername.resource}:${serverName}`; + + if (isReservedByAnotherAppService(username)) { + return { + statusCode: 400, + body: { + errcode: 'M_EXCLUSIVE', + error: 'Username is in an exclusive namespace of another application service', + }, + }; + } + + await createOrUpdateFederatedUser({ + username, + origin: serverName, + }); + + return { + statusCode: 200, + body: { + user_id: username, + }, + }; + }, + ) + + // GET /_matrix/client/v3/account/whoami + .get( + '/v3/account/whoami', + { + response: { + 200: isWhoamiResponseProps, + 401: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const username = c.get('impersonatedUserId') as string; + return { + statusCode: 200, + body: { + user_id: username, + }, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts b/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts new file mode 100644 index 0000000000000..398956ed16b82 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts @@ -0,0 +1,60 @@ +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + notImplemented, + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + license, + tags, +} from './_shared'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const RoomAliasParamsSchema = { + type: 'object', + properties: { + roomAlias: { type: 'string' }, + }, + required: ['roomAlias'], +}; + +const isRoomAliasParamsProps = ajv.compile(RoomAliasParamsSchema); + +const DirectoryPutBodySchema = { + type: 'object', + properties: { + room_id: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + }, + required: ['room_id'], + additionalProperties: true, +}; + +const isDirectoryPutBodyProps = ajv.compile(DirectoryPutBodySchema); + +export const addDirectoryRoutes = (router: ClientRouter) => { + router + // PUT /_matrix/client/v3/directory/room/:roomAlias + .put( + '/v3/directory/room/:roomAlias', + { + params: isRoomAliasParamsProps, + query: isImpersonationQueryProps, + body: isDirectoryPutBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + // TODO(federation-sdk): createAlias(alias, roomId, sender) + return notImplemented('Room alias creation not yet implemented'); + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/index.ts b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts new file mode 100644 index 0000000000000..a75e59704f116 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts @@ -0,0 +1,29 @@ +import { Router } from '@rocket.chat/http-router'; + +import { addAccountRoutes } from './account'; +import { addDirectoryRoutes } from './directory'; +import { addClientMediaRoutes } from './media'; +import { addPresenceRoutes } from './presence'; +import { addProfileRoutes } from './profile'; +import { addRoomsLifecycleRoutes } from './rooms-lifecycle'; +import { addRoomsMessagingRoutes } from './rooms-messaging'; +import { addRoomsStateRoutes } from './rooms-state'; +import { addUserRoutes } from './user'; +import { addVersionsRoutes } from './versions'; + +export const getClientRoutes = () => { + const router = new Router('/client'); + + addVersionsRoutes(router); + addAccountRoutes(router); + addProfileRoutes(router); + addPresenceRoutes(router); + addDirectoryRoutes(router); + addRoomsLifecycleRoutes(router); + addRoomsStateRoutes(router); + addRoomsMessagingRoutes(router); + addUserRoutes(router); + addClientMediaRoutes(router); + + return router; +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/media.ts b/ee/packages/federation-matrix/src/api/_matrix/client/media.ts new file mode 100644 index 0000000000000..e465acaa6143f --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/media.ts @@ -0,0 +1,236 @@ +import { Readable } from 'node:stream'; + +import { Upload } from '@rocket.chat/core-services'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { internalError, isMatrixErrorProps, license, tags } from './_shared'; +import { MatrixMediaService } from '../../../services/MatrixMediaService'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const MediaParamsSchema = { + type: 'object', + properties: { + serverName: { type: 'string' }, + mediaId: { type: 'string' }, + }, + required: ['serverName', 'mediaId'], +}; + +const isMediaParamsProps = ajv.compile(MediaParamsSchema); + +const ThumbnailQuerySchema = { + type: 'object', + properties: { + width: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + height: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + method: { type: 'string', enum: ['crop', 'scale'] }, + timeout_ms: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + }, +}; + +const isThumbnailQueryProps = ajvQuery.compile<{ + width?: number | string; + height?: number | string; + method?: 'crop' | 'scale'; + timeout_ms?: number | string; +}>(ThumbnailQuerySchema); + +const BufferResponseSchema = { + type: 'object', + description: 'multipart/mixed response', + additionalProperties: true, +}; + +const isBufferResponseProps = ajv.compile(BufferResponseSchema); + +const ConfigResponseSchema = { + type: 'object', + properties: { + 'm.upload.size': { type: 'number' }, + }, + additionalProperties: true, +}; + +const isConfigResponseProps = ajv.compile(ConfigResponseSchema); + +// The resize uses sharp with fit 'contain', which upscales past the original size, +// so unbounded dimensions would let a client force arbitrarily large allocations. +const MAX_THUMBNAIL_DIMENSION = 2048; + +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'", + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', +}; + +// Builds an RFC 5987-compliant Content-Disposition header. Always emits the +// ASCII-safe `filename=` for legacy clients and additionally emits +// `filename*=UTF-8''…` when the name contains non-ASCII characters. +function contentDispositionHeader(disposition: 'inline' | 'attachment', fileName: string): string { + const asciiFallback = fileName.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '_'); + const isAscii = asciiFallback === fileName; + if (isAscii) { + return `${disposition}; filename="${asciiFallback}"`; + } + // RFC 5987 requires percent-encoding `*'()` too, which encodeURIComponent leaves raw; + // a stray `'` is especially bad since it's the delimiter of the charset'lang'value syntax. + const rfc5987Value = encodeURIComponent(fileName).replace(/[*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); + return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${rfc5987Value}`; +} + +// MSC3916 says authenticated media downloads should be multipart/mixed, but the +// matrix-bot-sdk used by appservice bridges (e.g. matrix-bifrost) doesn't parse +// that envelope — it just streams the response body straight through to the +// downstream client, which then sees raw multipart text. To stay compatible +// with those bridges, we serve raw bytes here, same as the legacy +// /_matrix/media/v3/download endpoint. +export const addClientMediaRoutes = (router: ClientRouter) => { + router + // GET /_matrix/client/v1/media/download/:serverName/:mediaId + .get( + '/v1/media/download/:serverName/:mediaId', + { + params: isMediaParamsProps, + response: { + 200: isBufferResponseProps, + 401: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + try { + const serverName = c.req.param('serverName') as string; + const mediaId = c.req.param('mediaId') as string; + + const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName); + if (!file) { + return { + statusCode: 404, + body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + }; + } + + const buffer = await MatrixMediaService.getLocalFileBuffer(file); + const mimeType = file.type || 'application/octet-stream'; + const fileName = file.name || mediaId; + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': mimeType, + 'content-length': String(buffer.length), + 'content-disposition': contentDispositionHeader('attachment', fileName), + }, + body: buffer, + }; + } catch (error) { + return internalError('Failed to download media', error); + } + }, + ) + + // GET /_matrix/client/v1/media/thumbnail/:serverName/:mediaId + .get( + '/v1/media/thumbnail/:serverName/:mediaId', + { + params: isMediaParamsProps, + query: isThumbnailQueryProps, + response: { + 200: isBufferResponseProps, + 400: isMatrixErrorProps, + 401: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + try { + const serverName = c.req.param('serverName') as string; + const mediaId = c.req.param('mediaId') as string; + const width = Number(c.req.query('width')); + const height = Number(c.req.query('height')); + + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return { + statusCode: 400, + body: { errcode: 'M_BAD_REQUEST', error: 'Invalid width or height' }, + }; + } + + if (width > MAX_THUMBNAIL_DIMENSION || height > MAX_THUMBNAIL_DIMENSION) { + return { + statusCode: 400, + body: { errcode: 'M_BAD_REQUEST', error: 'Requested dimensions exceed maximum allowed' }, + }; + } + + const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName); + if (!file) { + return { + statusCode: 404, + body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + }; + } + + if (!file.type?.startsWith('image/')) { + return { + statusCode: 400, + body: { errcode: 'M_BAD_REQUEST', error: 'Thumbnails are only supported for images' }, + }; + } + + // `method` is validated but not honored: thumbnails are always scaled (the spec + // default), never cropped. Spec-wise thumbnailing is best-effort, so serving a + // scaled image for a `crop` request is acceptable. + const stream = await Upload.streamUploadedFile({ file, imageResizeOpts: { width, height } }); + + const mimeType = file.type || 'image/jpeg'; + const fileName = file.name || mediaId; + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': mimeType, + 'content-disposition': contentDispositionHeader('inline', fileName), + }, + body: Readable.toWeb(stream), + }; + } catch (error) { + return internalError('Failed to generate media thumbnail', error); + } + }, + ) + + // GET /_matrix/client/v1/media/config + .get( + '/v1/media/config', + { + response: { + 200: isConfigResponseProps, + 401: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + return { + statusCode: 200, + body: { + 'm.upload.size': 50 * 1024 * 1024, + }, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/presence.ts b/ee/packages/federation-matrix/src/api/_matrix/client/presence.ts new file mode 100644 index 0000000000000..b5a57089ab55c --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/presence.ts @@ -0,0 +1,45 @@ +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { isMatrixErrorProps, isUserIdParamsProps, license, tags } from './_shared'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const PresenceResponseSchema = { + type: 'object', + properties: { + presence: { type: 'string', enum: ['online', 'offline', 'unavailable'] }, + last_active_ago: { type: 'number' }, + status_msg: { type: 'string' }, + currently_active: { type: 'boolean' }, + }, + required: ['presence'], + additionalProperties: true, +}; + +const isPresenceResponseProps = ajv.compile(PresenceResponseSchema); + +export const addPresenceRoutes = (router: ClientRouter) => { + router.get( + '/v3/presence/:userId/status', + { + params: isUserIdParamsProps, + response: { + 200: isPresenceResponseProps, + 401: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + // TODO(federation-sdk): expose presence service via federationSDK.getPresence(userId) + return { + statusCode: 200, + body: { + presence: 'offline', + }, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts new file mode 100644 index 0000000000000..bce40d61bf56a --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -0,0 +1,238 @@ +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Users } from '@rocket.chat/models'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + internalError, + notImplemented, + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + isProfileFieldParamsProps, + isUserIdParamsProps, + license, + tags, +} from './_shared'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const ProfileGetResponseSchema = { + type: 'object', + properties: { + displayname: { type: 'string', nullable: true }, + avatar_url: { type: 'string', nullable: true }, + }, + additionalProperties: true, +}; + +const isProfileGetResponseProps = ajv.compile(ProfileGetResponseSchema); + +const DisplaynameBodySchema = { + type: 'object', + properties: { + displayname: { type: 'string', nullable: true }, + }, + required: ['displayname'], +}; + +const isDisplaynameBodyProps = ajv.compile(DisplaynameBodySchema); + +const AvatarUrlBodySchema = { + type: 'object', + properties: { + avatar_url: { type: 'string', nullable: true }, + }, + required: ['avatar_url'], +}; + +const isAvatarUrlBodyProps = ajv.compile(AvatarUrlBodySchema); + +const ALLOWED_PROFILE_FIELDS = ['displayname', 'avatar_url']; + +export const addProfileRoutes = (router: ClientRouter) => { + router + // GET /_matrix/client/v3/profile/:userId + .get( + '/v3/profile/:userId', + { + params: isUserIdParamsProps, + response: { + 200: isProfileGetResponseProps, + 401: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const userId = c.req.param('userId'); + try { + // TODO maybe this can be a query to our models instead of going through the federation-sdk + const profile = await federationSDK.queryProfile(userId); + if (!profile) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'Profile not found', + }, + }; + } + return { + statusCode: 200, + body: { + displayname: profile.displayname, + ...(profile.avatar_url ? { avatar_url: profile.avatar_url } : {}), + }, + }; + } catch (error) { + return internalError('Failed to fetch profile', error, { userId }); + } + }, + ) + + // GET /_matrix/client/v3/profile/:userId/:field + .get( + '/v3/profile/:userId/:field', + { + params: isProfileFieldParamsProps, + response: { + 200: isProfileGetResponseProps, + 400: isMatrixErrorProps, + 401: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const userId = c.req.param('userId'); + const field = c.req.param('field'); + + if (!field || !ALLOWED_PROFILE_FIELDS.includes(field)) { + return { + statusCode: 400, + body: { errcode: 'M_INVALID_PARAM', error: 'Unknown profile field' }, + }; + } + + try { + // TODO maybe this can be a query to our models instead of going through the federation-sdk + const profile = await federationSDK.queryProfile(userId); + if (!profile) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'Profile not found', + }, + }; + } + return { + statusCode: 200, + body: { + [field]: profile[field as keyof typeof profile], + }, + }; + } catch (error) { + return internalError('Failed to fetch profile', error, { userId, field }); + } + }, + ) + + // PUT /_matrix/client/v3/profile/:userId/displayname + .put( + '/v3/profile/:userId/displayname', + { + params: isUserIdParamsProps, + query: isImpersonationQueryProps, + body: isDisplaynameBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const userId = c.req.param('userId'); + const username = c.get('impersonatedUserId') as string; + + // A bridge always targets the user it impersonates, so user_id mirrors the path + // param (both in packed form for XMPP puppets). The exception is the bot setting + // its own displayname, where user_id is omitted and impersonatedUserId holds the + // bot's MXID verbatim. + if (userId !== (c.req.query('user_id') ?? username)) { + return { + statusCode: 403, + body: { errcode: 'M_FORBIDDEN', error: 'Cannot set the displayname of another user' }, + }; + } + + const body = await c.req.json(); + + const user = await Users.findOneByUsername(username); + if (!user) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'User not found', + }, + }; + } + + await Users.setName(user._id, body.displayname); + + return { + statusCode: 200, + body: {}, + }; + }, + ) + + // PUT /_matrix/client/v3/profile/:userId/avatar_url + .put( + '/v3/profile/:userId/avatar_url', + { + params: isUserIdParamsProps, + query: isImpersonationQueryProps, + body: isAvatarUrlBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const userId = c.req.param('userId'); + const username = c.get('impersonatedUserId') as string; + + const user = await Users.findOneByUsername(username); + if (!user) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'User not found', + }, + }; + } + + // TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms + return notImplemented('Global profile update not yet implemented', { userId }); + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts new file mode 100644 index 0000000000000..228315014485c --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -0,0 +1,378 @@ +import { Room } from '@rocket.chat/core-services'; +import type { IRoomNativeFederated } from '@rocket.chat/core-typings'; +import type { RoomID, UserID } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Rooms, Users } from '@rocket.chat/models'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + MATRIX_USER_ID_PATTERN, + internalError, + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + isRoomIdParamsProps, + license, + notImplemented, + tags, +} from './_shared'; +import { getFederatedRoomName } from '../../../helpers/getFederatedRoomName'; +import { logger } from '../../logger'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const CreateRoomBodySchema = { + type: 'object', + properties: { + room_alias_name: { type: 'string' }, + name: { type: 'string' }, + topic: { type: 'string' }, + visibility: { type: 'string', enum: ['public', 'private'] }, + preset: { type: 'string', enum: ['private_chat', 'trusted_private_chat', 'public_chat'] }, + invite: { + type: 'array', + items: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + }, + is_direct: { type: 'boolean' }, + initial_state: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + content: { type: 'object' }, + }, + required: ['type', 'content'], + additionalProperties: true, + }, + }, + }, + additionalProperties: true, +}; + +const isCreateRoomBodyProps = ajv.compile(CreateRoomBodySchema); + +const CreateRoomResponseSchema = { + type: 'object', + properties: { + room_id: { type: 'string' }, + room_alias: { type: 'string' }, + }, + required: ['room_id'], +}; + +const isCreateRoomResponseProps = ajv.compile(CreateRoomResponseSchema); + +const JoinParamsSchema = { + type: 'object', + properties: { + roomIdOrAlias: { type: 'string' }, + }, + required: ['roomIdOrAlias'], +}; + +const isJoinParamsProps = ajv.compile(JoinParamsSchema); + +const JoinResponseSchema = { + type: 'object', + properties: { + room_id: { type: 'string' }, + }, + required: ['room_id'], +}; + +const isJoinResponseProps = ajv.compile(JoinResponseSchema); + +const RoomLeaveBodySchema = { + type: 'object', + properties: { + reason: { type: 'string' }, + }, + additionalProperties: true, +}; + +const isRoomLeaveBodyProps = ajv.compile(RoomLeaveBodySchema); + +const InviteBodySchema = { + type: 'object', + properties: { + user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + reason: { type: 'string' }, + }, + required: ['user_id'], + additionalProperties: true, +}; + +const isInviteBodyProps = ajv.compile(InviteBodySchema); + +const KickBodySchema = { + type: 'object', + properties: { + user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + reason: { type: 'string' }, + }, + required: ['user_id'], + additionalProperties: true, +}; + +const isKickBodyProps = ajv.compile(KickBodySchema); + +const RoomLeaveParamsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + }, + required: ['roomId'], +}; + +const isRoomLeaveParamsProps = ajv.compile(RoomLeaveParamsSchema); + +export const addRoomsLifecycleRoutes = (router: ClientRouter) => { + router + // POST /_matrix/client/v3/createRoom + .post( + '/v3/createRoom', + { + query: isImpersonationQueryProps, + body: isCreateRoomBodyProps, + response: { + 200: isCreateRoomResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + const serverName = federationSDK.getConfig('serverName'); + + const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } }); + if (!user) { + // Mirrors Synapse: 4xx so bridges treat it as terminal (register the user and retry) + // instead of hammering a 5xx as a transient fault. + return { + statusCode: 403, + body: { errcode: 'M_FORBIDDEN', error: 'Application service has not registered this user' }, + }; + } + + // The human-facing name supplied by the Matrix client, which may be empty or + // contain characters RC does not allow in a room slug. + const displayName = body.name || body.room_alias_name || ''; + + // get join room from initial_state (for now since this is what bifrost sends) + const joinRule = + body.initial_state?.find((e: any) => e.type === 'm.room.join_rules')?.content?.join_rule === 'public' ? 'public' : 'invite'; + + try { + const result = await federationSDK.createRoomV2({ + name: displayName, + alias: body.room_alias_name, + owner: senderUsername, + joinRule, + }); + + // TODO after creating the federated room we must create the room for rocket.chat as well + const room = await Rooms.findOne({ 'federation.mrid': result.room_id }); + if (!room) { + // Derive the RC name (slug) from the Matrix room id rather than the supplied + // name, which may be empty or contain characters RC rejects. Mirrors the invite + // flow in events/member.ts and keeps the human-facing name in `fname`. + const name = getFederatedRoomName(result.room_id); + + await Room.create(user._id, { + type: joinRule === 'public' ? 'c' : 'p', + name, + members: [senderUsername], + options: { + forceNew: true, // an invite means the room does not exist yet + creator: user._id, + }, + extraData: { + federated: true, + federation: { + version: 1, + mrid: result.room_id, + origin: serverName, + }, + fname: displayName || name, + }, + }); + } + + for (const invitee of (body.invite ?? []) as string[]) { + try { + await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderUsername, body.is_direct); + } catch (err) { + // The room already exists at this point; failing the whole request would make + // the caller believe creation failed and retry, duplicating the room. + logger.error({ msg: 'Failed to invite user to newly created room', invitee, roomId: result.room_id, err }); + } + } + + return { + statusCode: 200, + body: { + room_id: result.room_id, + }, + }; + } catch (error) { + return internalError('Failed to create room', error); + } + }, + ) + + // POST /_matrix/client/v3/join/:roomIdOrAlias + .post( + '/v3/join/:roomIdOrAlias', + { + params: isJoinParamsProps, + query: isImpersonationQueryProps, + response: { + 200: isJoinResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomIdOrAlias = c.req.param('roomIdOrAlias') as string; + + // TODO(federation-sdk): expose alias resolution so this endpoint can also accept room aliases + if (!roomIdOrAlias.startsWith('!')) { + return notImplemented('Joining a room by alias is not yet implemented', { roomIdOrAlias }); + } + + const roomId = roomIdOrAlias as RoomID; + + await federationSDK.joinUser(roomId, c.get('impersonatedUserId')); + + return { + statusCode: 200, + body: { + room_id: roomId, + }, + }; + }, + ) + + // POST /_matrix/client/v3/rooms/:roomId/leave + .post( + '/v3/rooms/:roomId/leave', + { + params: isRoomLeaveParamsProps, + query: isImpersonationQueryProps, + body: isRoomLeaveBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const senderUsername = c.get('impersonatedUserId') as UserID; + + try { + await federationSDK.leaveRoom(roomId, senderUsername); + return { + statusCode: 200, + body: {}, + }; + } catch (error: any) { + if (error?.message?.toLowerCase?.().includes('not found')) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'Room not found', + }, + }; + } + return internalError('Failed to leave room', error, { roomId, senderId: senderUsername }); + } + }, + ) + + // POST /_matrix/client/v3/rooms/:roomId/invite + .post( + '/v3/rooms/:roomId/invite', + { + params: isRoomIdParamsProps, + query: isImpersonationQueryProps, + body: isInviteBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + try { + await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderUsername); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return internalError('Failed to invite user', error, { roomId, senderUsername }); + } + }, + ) + + // POST /_matrix/client/v3/rooms/:roomId/kick + .post( + '/v3/rooms/:roomId/kick', + { + params: isRoomIdParamsProps, + query: isImpersonationQueryProps, + body: isKickBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + try { + await federationSDK.kickUser(roomId, body.user_id as UserID, senderUsername, body.reason); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return internalError('Failed to kick user', error, { roomId, senderUsername }); + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts new file mode 100644 index 0000000000000..d724fe859f16d --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -0,0 +1,416 @@ +import { api, FederationMatrix, Room } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { isUserNativeFederated } from '@rocket.chat/core-typings'; +import type { EventID, FileMessageContent, FileMessageType, PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Rooms, Users } from '@rocket.chat/models'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + MATRIX_USER_ID_PATTERN, + internalError, + notImplemented, + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + isRoomIdParamsProps, + license, + tags, +} from './_shared'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const SendEventParamsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + eventType: { type: 'string' }, + txnId: { type: 'string' }, + }, + required: ['roomId', 'eventType', 'txnId'], +}; + +const isSendEventParamsProps = ajv.compile(SendEventParamsSchema); + +const SendEventBodySchema = { + type: 'object', + additionalProperties: true, +}; + +const isSendEventBodyProps = ajv.compile(SendEventBodySchema); + +const SendEventResponseSchema = { + type: 'object', + properties: { + event_id: { type: 'string' }, + }, + required: ['event_id'], +}; + +const isSendEventResponseProps = ajv.compile(SendEventResponseSchema); + +const MessagesQuerySchema = { + type: 'object', + properties: { + user_id: { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' }, + dir: { type: 'string', enum: ['b', 'f'] }, + limit: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + filter: { type: 'string' }, + }, +}; + +const isMessagesQueryProps = ajvQuery.compile<{ + user_id?: string; + from?: string; + to?: string; + dir?: 'b' | 'f'; + limit?: number | string; + filter?: string; +}>(MessagesQuerySchema); + +const MessagesResponseSchema = { + type: 'object', + properties: { + chunk: { type: 'array', items: { type: 'object', additionalProperties: true } }, + start: { type: 'string' }, + end: { type: 'string' }, + }, + additionalProperties: true, +}; + +const isMessagesResponseProps = ajv.compile(MessagesResponseSchema); + +const TypingParamsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + }, + required: ['roomId', 'userId'], +}; + +const isTypingParamsProps = ajv.compile(TypingParamsSchema); + +const TypingBodySchema = { + type: 'object', + properties: { + typing: { type: 'boolean' }, + timeout: { type: 'number' }, + }, + required: ['typing'], + additionalProperties: true, +}; + +const isTypingBodyProps = ajv.compile(TypingBodySchema); + +const ReceiptParamsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + eventId: { type: 'string' }, + }, + required: ['roomId', 'eventId'], +}; + +const isReceiptParamsProps = ajv.compile(ReceiptParamsSchema); + +const ReceiptBodySchema = { + type: 'object', + additionalProperties: true, +}; + +const isReceiptBodyProps = ajv.compile(ReceiptBodySchema); + +export const addRoomsMessagingRoutes = (router: ClientRouter) => { + router + // PUT /_matrix/client/v3/rooms/:roomId/send/:eventType/:txnId + .put( + '/v3/rooms/:roomId/send/:eventType/:txnId', + { + params: isSendEventParamsProps, + query: isImpersonationQueryProps, + body: isSendEventBodyProps, + response: { + 200: isSendEventResponseProps, + 400: isMatrixErrorProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const eventType = c.req.param('eventType'); + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + if (eventType === 'org.matrix.bridge.ping') { + try { + const event = await federationSDK.sendCustomEvent(roomId, eventType, body, senderUsername); + + return { + statusCode: 200, + body: { + event_id: event.eventId, + }, + }; + } catch (error) { + return internalError('Failed to send ping event', error, { roomId, senderUsername }); + } + } + + if (eventType !== 'm.room.message') { + // TODO: support additional event types (m.reaction, m.room.redaction, etc.) + return notImplemented('Only m.room.message is supported in v1', { eventType }); + } + + if (typeof body.body !== 'string' || typeof body.msgtype !== 'string') { + return { + statusCode: 400, + body: { + errcode: 'M_BAD_JSON', + error: 'm.room.message requires string fields body and msgtype', + }, + }; + } + + const fileMsgtypes: FileMessageType[] = ['m.image', 'm.file', 'm.audio', 'm.video']; + const isFileMessage = fileMsgtypes.includes(body.msgtype); + + if (isFileMessage && typeof body.url !== 'string') { + return { + statusCode: 400, + body: { + errcode: 'M_BAD_JSON', + error: `${body.msgtype} requires a string url field`, + }, + }; + } + + // TODO: deduplicate by txnId to handle bridge retries + try { + if (isFileMessage) { + const fileContent: FileMessageContent = { + body: body.body, + msgtype: body.msgtype, + url: body.url, + info: body.info, + }; + const event = await federationSDK.sendFileMessage(roomId, fileContent, senderUsername); + + await FederationMatrix.saveFederationMessage({ + event: event.event as PduForType<'m.room.message'>, + event_id: event.eventId, + }); + + return { + statusCode: 200, + body: { + event_id: event.eventId, + }, + }; + } + + const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderUsername); + + await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, event_id: event.eventId }); + + return { + statusCode: 200, + body: { + event_id: event.eventId, + }, + }; + } catch (error) { + return internalError('Failed to send message', error, { roomId }); + } + }, + ) + + // GET /_matrix/client/v3/rooms/:roomId/messages + .get( + '/v3/rooms/:roomId/messages', + { + params: isRoomIdParamsProps, + query: isMessagesQueryProps, + response: { + 200: isMessagesResponseProps, + 401: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const fromParam = c.req.query('from'); + const limitParam = c.req.query('limit'); + const limit = limitParam ? Number(limitParam) || 10 : 10; + + try { + if (!fromParam || limit <= 0) { + return { + statusCode: 200, + body: { + chunk: [], + start: '', + end: '', + }, + }; + } + const result = await federationSDK.getBackfillEvents(roomId, [fromParam] as EventID[], limit); + return { + statusCode: 200, + body: { + chunk: result.pdus, + start: fromParam, + end: '', + }, + }; + } catch (error) { + return internalError('Failed to fetch messages', error, { roomId }); + } + }, + ) + + // PUT /_matrix/client/v3/rooms/:roomId/typing/:userId + .put( + '/v3/rooms/:roomId/typing/:userId', + { + params: isTypingParamsProps, + query: isImpersonationQueryProps, + body: isTypingBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 400: isMatrixErrorProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const username = c.get('impersonatedUserId'); + const body = await c.req.json(); + + if (!username) { + return { + statusCode: 400, + body: { + errcode: 'M_BAD_REQUEST', + error: 'Missing userId parameter', + }, + }; + } + + try { + const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } }); + if (!matrixRoom) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'Room not found', + }, + }; + } + + const user = await Users.findOneByUsername>(username, { + projection: { name: 1, username: 1, federated: 1, federation: 1 }, + }); + if (!user || !isUserNativeFederated(user)) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'User not found', + }, + }; + } + + void api.broadcast('user.activity', { + user: user.name || user.username, + isTyping: body.typing, + roomId: matrixRoom._id, + }); + + await federationSDK.sendTypingNotification(roomId, username, body.typing === true); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return internalError('Failed to send typing notification', error, { roomId, userId: username }); + } + }, + ) + + // POST /_matrix/client/v3/rooms/:roomId/receipt/m.read/:eventId + .post( + '/v3/rooms/:roomId/receipt/m.read/:eventId', + { + params: isReceiptParamsProps, + query: isImpersonationQueryProps, + body: isReceiptBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const senderUsername = c.get('impersonatedUserId') as string; + + try { + const matrixUser = await Users.findOneByUsername(senderUsername); + if (!matrixUser) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'User not found', + }, + }; + } + + const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId }); + if (!matrixRoom) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'Room not found', + }, + }; + } + + await Room.markAsRead(matrixRoom, matrixUser._id); + + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return internalError('Failed to send read receipt', error, { roomId, senderUsername }); + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts new file mode 100644 index 0000000000000..6329a9b68a00e --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts @@ -0,0 +1,348 @@ +import type { PersistentEventBase, RoomID, UserID } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + internalError, + isUnknownRoomError, + notImplemented, + roomNotFound, + isImpersonationQueryProps, + isMatrixErrorProps, + isRoomIdParamsProps, + license, + tags, +} from './_shared'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const JoinedMembersResponseSchema = { + type: 'object', + properties: { + joined: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + display_name: { type: 'string', nullable: true }, + avatar_url: { type: 'string', nullable: true }, + }, + additionalProperties: true, + }, + }, + }, + required: ['joined'], +}; + +const isJoinedMembersResponseProps = ajv.compile(JoinedMembersResponseSchema); + +const StateArrayResponseSchema = { + type: 'array', + items: { type: 'object', additionalProperties: true }, +}; + +const isStateArrayResponseProps = ajv.compile(StateArrayResponseSchema); + +const StateEventParamsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + eventType: { type: 'string' }, + stateKey: { type: 'string' }, + }, + required: ['roomId', 'eventType'], +}; + +const isStateEventParamsProps = ajv.compile(StateEventParamsSchema); + +const StateContentResponseSchema = { + type: 'object', + additionalProperties: true, +}; + +const isStateContentResponseProps = ajv.compile(StateContentResponseSchema); + +const PutStateBodySchema = { + type: 'object', + additionalProperties: true, +}; + +const isPutStateBodyProps = ajv.compile(PutStateBodySchema); + +const PutStateResponseSchema = { + type: 'object', + properties: { + event_id: { type: 'string' }, + }, + required: ['event_id'], +}; + +const isPutStateResponseProps = ajv.compile(PutStateResponseSchema); + +const getRoomStateEvent = async (roomId: RoomID, eventType: string, stateKey = '') => { + try { + const state = await federationSDK.getLatestRoomState(roomId); + + const key = `${eventType}:${stateKey}`; + + let pe: PersistentEventBase | undefined; + for (const [k, v] of state) { + if (k === key) { + pe = v; + break; + } + } + if (!pe) { + return { + statusCode: 404 as const, + body: { + errcode: 'M_NOT_FOUND', + error: 'State event not found', + }, + }; + } + return { + statusCode: 200 as const, + body: pe.getContent(), + }; + } catch (error) { + if (isUnknownRoomError(error)) { + return roomNotFound(); + } + return internalError('Failed to fetch state event', error); + } +}; + +const putRoomStateEvent = async ( + roomId: RoomID, + eventType: string, + senderUsername: UserID, + body: Record, + stateKey = '', +) => { + try { + // The supported event types are all empty-state-key events per spec; writing them in + // response to a keyed request would silently target the wrong state. + if (stateKey) { + return notImplemented('State events with a non-empty state key are not yet implemented', { roomId, eventType, stateKey }); + } + if (eventType === 'm.room.name' && typeof body.name === 'string') { + const event = await federationSDK.updateRoomName(roomId, body.name, senderUsername); + return { + statusCode: 200 as const, + body: { event_id: event.eventId }, + }; + } + if (eventType === 'm.room.topic' && typeof body.topic === 'string') { + const event = await federationSDK.setRoomTopic(roomId, senderUsername, body.topic); + + return { + statusCode: 200 as const, + body: { event_id: event.eventId }, + }; + } + + // TODO: extend SDK to send arbitrary state events + return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType, senderUsername }); + } catch (error) { + return internalError('Failed to send state event', error, { roomId, eventType, senderUsername }); + } +}; + +export const addRoomsStateRoutes = (router: ClientRouter) => { + router + // GET /_matrix/client/v3/rooms/:roomId/joined_members + .get( + '/v3/rooms/:roomId/joined_members', + { + params: isRoomIdParamsProps, + response: { + 200: isJoinedMembersResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + + try { + const state = await federationSDK.getLatestRoomState(roomId); + const joined: Record = {}; + for (const [key, pe] of state) { + if (!key.startsWith('m.room.member:')) continue; + const content = pe.getContent() as { membership?: string; displayname?: string; avatar_url?: string }; + if (content?.membership !== 'join') continue; + const userId = pe.stateKey; + if (!userId) continue; + joined[userId] = { + ...(content.displayname ? { display_name: content.displayname } : {}), + ...(content.avatar_url ? { avatar_url: content.avatar_url } : {}), + }; + } + return { + statusCode: 200, + body: { joined }, + }; + } catch (error) { + if (isUnknownRoomError(error)) { + return roomNotFound(); + } + return internalError('Failed to fetch joined members', error, { roomId }); + } + }, + ) + + // GET /_matrix/client/v3/rooms/:roomId/state + .get( + '/v3/rooms/:roomId/state', + { + params: isRoomIdParamsProps, + response: { + 200: isStateArrayResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + try { + const state = await federationSDK.getLatestRoomState(roomId); + const events: unknown[] = []; + for (const pe of state.values()) { + events.push(pe.event); + } + return { + statusCode: 200, + body: events, + }; + } catch (error) { + if (isUnknownRoomError(error)) { + return roomNotFound(); + } + return internalError('Failed to fetch room state', error, { roomId }); + } + }, + ) + + // GET /_matrix/client/v3/rooms/:roomId/state/:eventType/ + // The optional-param route below (`:stateKey?`) matches `/state/:eventType` and + // `/state/:eventType/:stateKey`, but NOT the trailing-slash form `/state/:eventType/` + // (an empty final segment). Bridges request that trailing-slash URL for the + // default (empty) state key, so this explicit route handles it with stateKey=''. + .get( + '/v3/rooms/:roomId/state/:eventType/', + { + params: isStateEventParamsProps, + response: { + 200: isStateContentResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const eventType = c.req.param('eventType') as string; + + return getRoomStateEvent(roomId, eventType); + }, + ) + + // GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey? + .get( + '/v3/rooms/:roomId/state/:eventType/:stateKey?', + { + params: isStateEventParamsProps, + response: { + 200: isStateContentResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const eventType = c.req.param('eventType') as string; + const stateKey = c.req.param('stateKey') ?? ''; + + return getRoomStateEvent(roomId, eventType, stateKey); + }, + ) + + // PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/ + // Same trailing-slash note as the GET above: the optional-param route (`:stateKey?`) + // doesn't match an empty final segment, so this explicit route handles it with stateKey=''. + .put( + '/v3/rooms/:roomId/state/:eventType/', + { + params: isStateEventParamsProps, + query: isImpersonationQueryProps, + body: isPutStateBodyProps, + response: { + 200: isPutStateResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const eventType = c.req.param('eventType') as string; + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + return putRoomStateEvent(roomId, eventType, senderUsername, body); + }, + ) + + // PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey? + // The state key is optional per spec: `PUT /state/:eventType` targets the empty state key. + .put( + '/v3/rooms/:roomId/state/:eventType/:stateKey?', + { + params: isStateEventParamsProps, + query: isImpersonationQueryProps, + body: isPutStateBodyProps, + response: { + 200: isPutStateResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const eventType = c.req.param('eventType') as string; + const stateKey = c.req.param('stateKey') ?? ''; + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + return putRoomStateEvent(roomId, eventType, senderUsername, body, stateKey); + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/user.ts b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts new file mode 100644 index 0000000000000..ebd1576df6bc8 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts @@ -0,0 +1,87 @@ +import type { RoomID } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Users } from '@rocket.chat/models'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + MATRIX_USER_ID_PATTERN, + internalError, + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + license, + tags, +} from './_shared'; +import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; + +const AccountDataDisplaynameParamsSchema = { + type: 'object', + properties: { + userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN }, + roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + }, + required: ['userId', 'roomId'], +}; + +const isAccountDataDisplaynameParamsProps = ajv.compile(AccountDataDisplaynameParamsSchema); + +const AccountDataDisplaynameBodySchema = { + type: 'object', + properties: { + displayname: { type: 'string', nullable: true }, + }, + additionalProperties: true, +}; + +const isAccountDataDisplaynameBodyProps = ajv.compile(AccountDataDisplaynameBodySchema); + +export const addUserRoutes = (router: ClientRouter) => { + router.put( + '/v3/user/:userId/rooms/:roomId/account_data/m.room.displayname', + { + params: isAccountDataDisplaynameParamsProps, + query: isImpersonationQueryProps, + body: isAccountDataDisplaynameBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const senderUsername = c.get('impersonatedUserId') as string; + const body = await c.req.json(); + + const user = await Users.findOneByUsername(senderUsername); + if (!user) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'User not found', + }, + }; + } + + try { + await federationSDK.updateUserProfile(roomId, senderUsername, { + displayname: body.displayname ?? undefined, + }); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return internalError('Failed to update per-room displayname', error, { roomId, senderUsername }); + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts b/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts new file mode 100644 index 0000000000000..9c1bfd20c9234 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts @@ -0,0 +1,38 @@ +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { license, tags } from './_shared'; + +const VersionsResponseSchema = { + type: 'object', + properties: { + versions: { + type: 'array', + items: { type: 'string' }, + }, + unstable_features: { + type: 'object', + additionalProperties: { type: 'boolean' }, + }, + }, + required: ['versions'], +}; + +const isVersionsResponseProps = ajv.compile(VersionsResponseSchema); + +export const addVersionsRoutes = (router: ClientRouter) => { + router.get( + '/versions', + { + response: { 200: isVersionsResponseProps }, + tags, + license, + }, + async () => ({ + body: { + versions: ['v1.4'], + }, + statusCode: 200, + }), + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index d27dbfc0cf57a..b63edfdeefc0a 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -1,10 +1,10 @@ import { FederationMatrix } from '@rocket.chat/core-services'; import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; -import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { logger } from '../logger'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; const EventBaseSchema = { @@ -130,8 +130,6 @@ const ProcessInviteResponseSchema = { const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); export const getMatrixInviteRoutes = () => { - const logger = new Logger('matrix-invite'); - return new Router('/federation').put( '/v2/invite/:roomId/:eventId', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts index 72e741f8df8b7..911d2be81e78d 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts @@ -1,8 +1,8 @@ import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; -import { Logger } from '@rocket.chat/logger'; import { ajv } from '@rocket.chat/rest-typings'; +import { logger } from '../logger'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; const isMakeLeaveParamsProps = ajv.compile({ @@ -56,8 +56,6 @@ const isMakeLeaveErrorResponseProps = ajv.compile({ }); export const getMatrixMakeLeaveRoutes = () => { - const logger = new Logger('matrix-make-leave'); - return new Router('/federation').get( '/v1/make_leave/:roomId/:userId', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts new file mode 100644 index 0000000000000..abe6285bca7dd --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts @@ -0,0 +1,178 @@ +import { Router } from '@rocket.chat/http-router'; +import { Users } from '@rocket.chat/models'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +import { MatrixMediaService } from '../../services/MatrixMediaService'; +import { isAppServiceAuthenticatedMiddleware } from '../middlewares/isAppServiceAuthenticated'; + +const MatrixErrorSchema = { + type: 'object', + properties: { + errcode: { type: 'string' }, + error: { type: 'string' }, + }, + required: ['errcode', 'error'], +}; + +const isMatrixErrorProps = ajv.compile(MatrixErrorSchema); + +const UploadResponseSchema = { + type: 'object', + properties: { + content_uri: { type: 'string' }, + }, + required: ['content_uri'], +}; + +const isUploadResponseProps = ajv.compile(UploadResponseSchema); + +const UploadQuerySchema = { + type: 'object', + properties: { + filename: { type: 'string' }, + user_id: { type: 'string' }, + access_token: { type: 'string' }, + }, +}; + +const isUploadQueryProps = ajvQuery.compile<{ + filename?: string; + user_id?: string; + access_token?: string; +}>(UploadQuerySchema); + +const ConfigResponseSchema = { + type: 'object', + properties: { + 'm.upload.size': { type: 'number' }, + }, + additionalProperties: true, +}; + +const isConfigResponseProps = ajv.compile(ConfigResponseSchema); + +const tags = ['Federation', 'Media']; +const license: ['federation'] = ['federation']; + +// Advertised to bridges as m.upload.size in the /r0/config route below. +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; + +export const getMatrixMediaBridgeRoutes = () => { + return ( + new Router('/media') + + // POST /_matrix/media/v3/upload + .post( + '/v3/upload', + { + query: isUploadQueryProps, + response: { + 200: isUploadResponseProps, + 400: isMatrixErrorProps, + 401: isMatrixErrorProps, + 413: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + try { + const senderUsername = c.get('impersonatedUserId') as string; + const fileName = c.req.query('filename') || `upload-${Date.now()}`; + const mimeType = c.req.header('content-type') || 'application/octet-stream'; + + const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } }); + if (!user) { + return { + statusCode: 401, + body: { + errcode: 'M_UNKNOWN_TOKEN', + error: 'Impersonated user not found', + }, + }; + } + + // Reject on the declared size before buffering; the byteLength check below + // still catches bodies without (or lying about) content-length. + const contentLength = Number(c.req.header('content-length')); + if (contentLength > MAX_UPLOAD_SIZE) { + return { + statusCode: 413, + body: { + errcode: 'M_TOO_LARGE', + error: 'Upload exceeds the maximum allowed size', + }, + }; + } + + const arrayBuffer = await c.req.raw.arrayBuffer(); + if (!arrayBuffer.byteLength) { + return { + statusCode: 400, + body: { + errcode: 'M_BAD_REQUEST', + error: 'Empty upload body', + }, + }; + } + + if (arrayBuffer.byteLength > MAX_UPLOAD_SIZE) { + return { + statusCode: 413, + body: { + errcode: 'M_TOO_LARGE', + error: 'Upload exceeds the maximum allowed size', + }, + }; + } + + const buffer = Buffer.from(arrayBuffer); + + const { mxcUri } = await MatrixMediaService.uploadFromAppService({ + buffer, + fileName, + mimeType, + userId: user._id, + }); + + return { + statusCode: 200, + body: { content_uri: mxcUri }, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to upload media', + }, + }; + } + }, + ) + + // GET /_matrix/media/r0/config (literal r0; matrix-bot-sdk hardcodes this path) + .get( + '/r0/config', + { + response: { + 200: isConfigResponseProps, + 401: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + return { + statusCode: 200, + body: { + 'm.upload.size': MAX_UPLOAD_SIZE, + }, + }; + }, + ) + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts index 7d12b743ed139..72e2c76c55528 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts @@ -1,8 +1,8 @@ import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; -import { Logger } from '@rocket.chat/logger'; import { ajv } from '@rocket.chat/rest-typings'; +import { logger } from '../logger'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; const isSendLeaveParamsProps = ajv.compile({ @@ -58,8 +58,6 @@ const isSendLeaveErrorResponseProps = ajv.compile({ }); export const getMatrixSendLeaveRoutes = () => { - const logger = new Logger('matrix-send-leave'); - return new Router('/federation').put( '/v2/send_leave/:roomId/:eventId', { @@ -94,7 +92,7 @@ export const getMatrixSendLeaveRoutes = () => { }; } - logger.error({ msg: 'Error making leave', err: error }); + logger.error({ msg: 'Error sending leave', err: error }); return { body: { diff --git a/ee/packages/federation-matrix/src/api/logger.ts b/ee/packages/federation-matrix/src/api/logger.ts new file mode 100644 index 0000000000000..95779e9dbd840 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('FederationMatrixAPI'); diff --git a/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts new file mode 100644 index 0000000000000..643cf70203e3c --- /dev/null +++ b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts @@ -0,0 +1,101 @@ +import { errCodes, federationSDK } from '@rocket.chat/federation-sdk'; +import type { Context } from 'hono'; +import { createMiddleware } from 'hono/factory'; + +import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../helpers/parseXmppUserId'; + +export const isAppServiceAuthenticatedMiddleware = () => + createMiddleware(async (c: Context, next) => { + try { + const authHeader = c.req.header('Authorization') || ''; + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + const token = bearerMatch?.[1] ?? c.req.query('access_token'); + + if (!token) { + return c.json( + { + errcode: 'M_MISSING_TOKEN', + error: 'Missing access token', + }, + 401, + ); + } + + const appService = federationSDK.getRegistrationByAsToken(token); + if (!appService) { + return c.json( + { + errcode: 'M_UNKNOWN_TOKEN', + error: 'Invalid application service token', + }, + 401, + ); + } + + c.set('appService', appService); + + const appUserId = `@${appService.registration.senderLocalpart}:${federationSDK.getConfig('serverName')}`; + const userId = c.req.query('user_id'); + + if (!userId) { + c.set('impersonatedUserId', appUserId); + return next(); + } + + if (userId === appUserId) { + c.set('impersonatedUserId', userId); + return next(); + } + + const inNamespace = federationSDK.isUserInAppServiceNamespace(userId, appService.registration._id); + if (!inNamespace) { + return c.json( + { + errcode: 'M_FORBIDDEN', + error: 'Application service cannot masquerade as this user', + }, + 403, + ); + } + + const serverName = federationSDK.getConfig('serverName'); + + const decoded = decodeXmppUserId(userId); + + // Not a packed XMPP JID: the registration flow stores these users under the + // full MXID as-is, so impersonate that. + if (!isFullXmppUserId(decoded)) { + c.set('impersonatedUserId', userId); + return next(); + } + + let decodedUsername; + try { + decodedUsername = parseXmppUserId(decoded); + } catch { + return c.json( + { + errcode: 'M_INVALID_USER_ID', + error: 'Invalid user id', + }, + 400, + ); + } + + if (!decodedUsername.resource) { + return c.json( + { + errcode: 'M_INVALID_USER_ID', + error: 'Invalid user id', + }, + 400, + ); + } + + c.set('impersonatedUserId', `${decodedUsername.resource}:${serverName}`); + + return next(); + } catch (error) { + return c.json(errCodes.M_UNKNOWN, 500); + } + }); diff --git a/ee/packages/federation-matrix/src/api/routes.ts b/ee/packages/federation-matrix/src/api/routes.ts index 986bc4db81b83..9d73eab0a95c0 100644 --- a/ee/packages/federation-matrix/src/api/routes.ts +++ b/ee/packages/federation-matrix/src/api/routes.ts @@ -1,10 +1,12 @@ import { Router } from '@rocket.chat/http-router'; import { getWellKnownRoutes } from './.well-known/server'; +import { getClientRoutes } from './_matrix/client'; import { getMatrixInviteRoutes } from './_matrix/invite'; import { getKeyServerRoutes } from './_matrix/key/server'; import { getMatrixMakeLeaveRoutes } from './_matrix/make-leave'; import { getMatrixMediaRoutes } from './_matrix/media'; +import { getMatrixMediaBridgeRoutes } from './_matrix/media-bridge'; import { getMatrixProfilesRoutes } from './_matrix/profiles'; import { getMatrixRoomsRoutes } from './_matrix/rooms'; import { getMatrixSendJoinRoutes } from './_matrix/send-join'; @@ -24,6 +26,8 @@ export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix .use(isLicenseEnabledMiddleware) .use(getKeyServerRoutes()) .use(getFederationVersionsRoutes(version)) + .use(getClientRoutes()) + .use(getMatrixMediaBridgeRoutes()) .use(isFederationDomainAllowedMiddleware) .use(getMatrixInviteRoutes()) .use(getMatrixProfilesRoutes()) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index a8db4f1aa4a7b..9802c2beeb328 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -9,6 +9,7 @@ import mem from 'mem'; import { createOrUpdateFederatedUser } from '../helpers/createOrUpdateFederatedUser'; import { extractDomainFromMatrixUserId } from '../helpers/extractDomainFromMatrixUserId'; +import { getFederatedRoomName } from '../helpers/getFederatedRoomName'; import { getUsernameServername } from '../helpers/getUsernameServername'; import { MatrixMediaService } from '../services/MatrixMediaService'; @@ -83,6 +84,15 @@ async function getOrCreateFederatedUser(userId: string): Promise { return user; } + const as = federationSDK.getAppServiceForUser(userId); + if (as) { + const user = await Users.findOneByUsername(userId); + if (!user) { + throw new Error('AppService user not found for creating user'); + } + return user; + } + if (isLocal) { throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`); } @@ -226,7 +236,7 @@ async function handleInvite({ roomName = senderId; roomFName = senderId; } else { - roomName = roomId.replace('!', '').replace(':', '_'); + roomName = getFederatedRoomName(roomId); roomFName = `${matrixRoomName}:${roomOriginDomain}`; } @@ -301,8 +311,15 @@ async function handleJoin({ // it means the join event was sent before the invite event, so we need to create the subscription and then accept the invite. // this will happen when for example the user is unbanned, so the leave event will remove the subscription and then we just // receive the join event without receiving the invite. - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id); - + let subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id); + if (!subscription) { + const subId = await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: joiningUser, + }); + subscription = subId ? await Subscriptions.findOneById(subId) : null; + } if (!subscription) { throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index ce573e983ca41..ed21775df54b2 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -1,266 +1,16 @@ -import { FederationMatrix, Message, MeteorService } from '@rocket.chat/core-services'; -import type { IUser, IRoom, FileAttachmentProps } from '@rocket.chat/core-typings'; -import { type FileMessageType, type MessageType, type FileMessageContent, type EventID, federationSDK } from '@rocket.chat/federation-sdk'; +import { FederationMatrix, Message } from '@rocket.chat/core-services'; +import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Users, Rooms, Messages } from '@rocket.chat/models'; -import { fileTypes } from '../FederationMatrix'; -import { toInternalMessageFormat, toInternalQuoteMessageFormat } from '../helpers/message.parsers'; -import { MatrixMediaService } from '../services/MatrixMediaService'; +import { getThreadMessageId } from '../helpers/getThreadMessageId'; const logger = new Logger('federation-matrix:message'); -async function getThreadMessageId(threadRootEventId: EventID): Promise<{ tmid: string; tshow: boolean } | undefined> { - const threadRootMessage = await Messages.findOneByFederationId(threadRootEventId); - if (!threadRootMessage) { - logger.warn({ msg: 'Thread root message not found for event', eventId: threadRootEventId }); - return; - } - - const shouldSetTshow = !threadRootMessage?.tcount; - return { tmid: threadRootMessage._id, tshow: shouldSetTshow }; -} - -async function handleMediaMessage( - url: string, - fileInfo: FileMessageContent['info'], - msgtype: MessageType, - messageBody: string, - user: IUser, - room: IRoom, - matrixRoomId: string, - eventId: EventID, - thread?: { tmid: string; tshow: boolean }, -): Promise<{ - fromId: string; - rid: string; - msg: string; - federation_event_id: string; - thread?: { tmid: string; tshow: boolean }; - attachments: [FileAttachmentProps]; -}> { - const mimeType = fileInfo?.mimetype; - const fileName = messageBody; - - const fileRefId = await MatrixMediaService.downloadAndStoreRemoteFile(url, matrixRoomId, { - name: messageBody, - size: fileInfo?.size || 0, - type: mimeType || 'application/octet-stream', - rid: room._id, - userId: user._id, - }); - - let fileExtension = ''; - if (fileName?.includes('.')) { - fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - } else if (mimeType?.includes('/')) { - fileExtension = mimeType.split('/')[1] || ''; - if (fileExtension === 'jpeg') { - fileExtension = 'jpg'; - } - } - - const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`; - - let attachment: FileAttachmentProps = { - title: fileName, - type: 'file', - title_link: fileUrl, - title_link_download: true, - description: '', - }; - - if (msgtype === 'm.image') { - attachment = { - ...attachment, - image_url: fileUrl, - image_type: mimeType, - image_size: fileInfo?.size || 0, - ...(fileInfo?.w && - fileInfo?.h && { - image_dimensions: { - width: fileInfo.w, - height: fileInfo.h, - }, - }), - }; - } else if (msgtype === 'm.video') { - attachment = { - ...attachment, - video_url: fileUrl, - video_type: mimeType, - video_size: fileInfo?.size || 0, - }; - } else if (msgtype === 'm.audio') { - attachment = { - ...attachment, - audio_url: fileUrl, - audio_type: mimeType, - audio_size: fileInfo?.size || 0, - }; - } - - return { - fromId: user._id, - rid: room._id, - msg: '', - federation_event_id: eventId, - thread, - attachments: [attachment], - }; -} - export function message() { - federationSDK.eventEmitterService.on('homeserver.matrix.message', async ({ event, event_id: eventId }) => { + federationSDK.eventEmitterService.on('homeserver.matrix.message', async (event) => { try { - const { msgtype, body } = event.content; - const messageBody = body.toString(); - - if (!messageBody && !msgtype) { - logger.debug('No message content found in event'); - return; - } - - // at this point we know for sure the user already exists - const user = await Users.findOneByUsername(event.sender); - if (!user) { - throw new Error(`User not found for sender: ${event.sender}`); - } - - const room = await Rooms.findOne({ 'federation.mrid': event.room_id }); - if (!room) { - throw new Error(`No mapped room found for room_id: ${event.room_id}`); - } - - const serverName = federationSDK.getConfig('serverName'); - - const relation = event.content['m.relates_to']; - - // SPEC: For example, an m.thread relationship type denotes that the event is part of a “thread” of messages and should be rendered as such. - const hasRelation = relation && 'rel_type' in relation; - - const isThreadMessage = hasRelation && relation.rel_type === 'm.thread'; - - const threadRootEventId = isThreadMessage && relation.event_id; - - // SPEC: Though rich replies form a relationship to another event, they do not use rel_type to create this relationship. - // Instead, a subkey named m.in_reply_to is used to describe the reply’s relationship, - const isRichReply = relation && !('rel_type' in relation) && 'm.in_reply_to' in relation; - - const quoteMessageEventId = isRichReply && relation['m.in_reply_to']?.event_id; - - const thread = threadRootEventId ? await getThreadMessageId(threadRootEventId) : undefined; - - const isEditedMessage = hasRelation && relation.rel_type === 'm.replace'; - if (isEditedMessage && relation.event_id && event.content['m.new_content']) { - logger.debug('Received edited message from Matrix, updating existing message'); - const originalMessage = await Messages.findOneByFederationId(relation.event_id); - if (!originalMessage) { - logger.error({ event_id: relation.event_id, msg: 'Original message not found for edit' }); - return; - } - if (originalMessage.federation?.eventId !== relation.event_id) { - return; - } - if (originalMessage.msg === event.content['m.new_content']?.body) { - logger.debug('No changes in message content, skipping update'); - return; - } - - if (quoteMessageEventId) { - const messageToReplyToUrl = await MeteorService.getMessageURLToReplyTo(room.t as string, room._id, originalMessage._id); - const formatted = await toInternalQuoteMessageFormat({ - messageToReplyToUrl, - formattedMessage: event.content.formatted_body || '', - rawMessage: messageBody, - homeServerDomain: serverName, - senderExternalId: event.sender, - }); - await Message.updateMessage( - { - ...originalMessage, - msg: formatted, - }, - user, - originalMessage, - ); - return; - } - - const formatted = toInternalMessageFormat({ - rawMessage: event.content['m.new_content'].body, - formattedMessage: event.content.formatted_body || '', - homeServerDomain: serverName, - senderExternalId: event.sender, - }); - - await Message.updateMessage( - { - ...originalMessage, - msg: formatted, - }, - user, - originalMessage, - ); - return; - } - - if (quoteMessageEventId) { - const originalMessage = await Messages.findOneByFederationId(quoteMessageEventId); - if (!originalMessage) { - logger.error({ quoteMessageEventId, msg: 'Original message not found for quote' }); - return; - } - const messageToReplyToUrl = await MeteorService.getMessageURLToReplyTo(room.t as string, room._id, originalMessage._id); - const formatted = await toInternalQuoteMessageFormat({ - messageToReplyToUrl, - formattedMessage: event.content.formatted_body || '', - rawMessage: messageBody, - homeServerDomain: serverName, - senderExternalId: event.sender, - }); - await Message.saveMessageFromFederation({ - fromId: user._id, - rid: room._id, - msg: formatted, - federation_event_id: eventId, - thread, - ts: new Date(event.origin_server_ts), - }); - return; - } - - const isMediaMessage = Object.values(fileTypes).includes(msgtype as FileMessageType); - if (isMediaMessage && 'url' in event.content) { - const result = await handleMediaMessage( - event.content.url, - event.content.info, - msgtype, - messageBody, - user, - room, - event.room_id, - eventId, - thread, - ); - await Message.saveMessageFromFederation({ ...result, ts: new Date(event.origin_server_ts) }); - } else { - const formatted = toInternalMessageFormat({ - rawMessage: messageBody, - formattedMessage: event.content.formatted_body || '', - homeServerDomain: serverName, - senderExternalId: event.sender, - }); - - await Message.saveMessageFromFederation({ - fromId: user._id, - rid: room._id, - msg: formatted, - federation_event_id: eventId, - thread, - ts: new Date(event.origin_server_ts), - }); - } + await FederationMatrix.saveFederationMessage(event); } catch (err) { logger.error({ msg: 'Error processing Matrix message', err }); } @@ -391,7 +141,7 @@ export function message() { } const messageEvent = await FederationMatrix.getEventById(redactedEventId); - if (!messageEvent || messageEvent.event.type !== 'm.room.message') { + if (messageEvent?.event.type !== 'm.room.message') { logger.debug({ msg: 'Event is not a message event', eventId: redactedEventId }); return; } diff --git a/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts b/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts index 2ed4f22c6c59d..a2bd2d7082d3c 100644 --- a/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts +++ b/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts @@ -8,8 +8,13 @@ import { Users } from '@rocket.chat/models'; * So we need to upsert the user with the federation object */ -export async function createOrUpdateFederatedUser(options: { username: string; name?: string; origin: string }): Promise { - const { username, name = username, origin } = options; +export async function createOrUpdateFederatedUser(options: { + username: string; + name?: string; + origin: string; + asId?: string; +}): Promise { + const { username, name = username, origin, asId } = options; // TODO: Have a specific method to handle this upsert const user = await Users.findOneAndUpdate( @@ -30,6 +35,7 @@ export async function createOrUpdateFederatedUser(options: { username: string; n version: 1, mui: username, origin, + ...(asId && { asId }), }, _updatedAt: new Date(), }, diff --git a/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.spec.ts b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.spec.ts new file mode 100644 index 0000000000000..bddd82947ce11 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.spec.ts @@ -0,0 +1,32 @@ +import { getFederatedRoomName } from './getFederatedRoomName'; + +describe('getFederatedRoomName', () => { + it('should strip the leading `!` sigil and replace the `:` separator with `_`', () => { + expect(getFederatedRoomName('!abcdef:matrix.org')).toBe('abcdef_matrix.org'); + }); + + it('should produce a slug-valid name (only [0-9a-zA-Z-_.])', () => { + expect(getFederatedRoomName('!abcdef:matrix.org')).toMatch(/^[0-9a-zA-Z-_.]+$/); + }); + + it('should handle server names with a port (multiple `:`)', () => { + expect(getFederatedRoomName('!abcdef:matrix.org:8448')).toBe('abcdef_matrix.org_8448'); + }); + + it('should replace slug-invalid characters in the opaque id', () => { + expect(getFederatedRoomName('!ab+cd/ef=:matrix.org')).toBe('ab_cd_ef__matrix.org'); + expect(getFederatedRoomName('!ab+cd/ef=:matrix.org')).toMatch(/^[0-9a-zA-Z-_.]+$/); + }); + + it('should only strip a `!` at the start, not elsewhere', () => { + expect(getFederatedRoomName('!ab!cd:matrix.org')).toBe('ab_cd_matrix.org'); + }); + + it('should be deterministic for the same room id', () => { + expect(getFederatedRoomName('!room:server.com')).toBe(getFederatedRoomName('!room:server.com')); + }); + + it('should derive distinct names for distinct room ids', () => { + expect(getFederatedRoomName('!a:server.com')).not.toBe(getFederatedRoomName('!b:server.com')); + }); +}); diff --git a/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.ts b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.ts new file mode 100644 index 0000000000000..6c78e6d8e2b80 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.ts @@ -0,0 +1,7 @@ +// Derives a valid Rocket.Chat room name (slug) from a Matrix room id. +// Matrix room ids look like `!opaqueId:server.domain`; we strip the leading `!` +// sigil and replace every char RC rejects in a slug (including the `:` separator, +// which may also appear in a server port) with `_`, producing a deterministic, +// slug-valid name. Matrix rooms may have no name (or a name with characters RC +// rejects), so the room id is the only always-present, addressable identifier. +export const getFederatedRoomName = (matrixRoomId: string): string => matrixRoomId.replace(/^!/, '').replace(/[^0-9a-zA-Z-_.]/g, '_'); diff --git a/ee/packages/federation-matrix/src/helpers/getThreadMessageId.ts b/ee/packages/federation-matrix/src/helpers/getThreadMessageId.ts new file mode 100644 index 0000000000000..a27b69f0443ce --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/getThreadMessageId.ts @@ -0,0 +1,17 @@ +import { type EventID } from '@rocket.chat/federation-sdk'; +import { Logger } from '@rocket.chat/logger'; +import { Messages } from '@rocket.chat/models'; + +// TODO replace by a reusable logger +const logger = new Logger('federation-matrix:message'); + +export async function getThreadMessageId(threadRootEventId: EventID): Promise<{ tmid: string; tshow: boolean } | undefined> { + const threadRootMessage = await Messages.findOneByFederationId(threadRootEventId); + if (!threadRootMessage) { + logger.warn({ msg: 'Thread root message not found for event', eventId: threadRootEventId }); + return; + } + + const shouldSetTshow = !threadRootMessage?.tcount; + return { tmid: threadRootMessage._id, tshow: shouldSetTshow }; +} diff --git a/ee/packages/federation-matrix/src/helpers/handleMediaMessage.ts b/ee/packages/federation-matrix/src/helpers/handleMediaMessage.ts new file mode 100644 index 0000000000000..08e7debce00ff --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/handleMediaMessage.ts @@ -0,0 +1,83 @@ +import type { IUser, IRoom, FileAttachmentProps } from '@rocket.chat/core-typings'; +import { type MessageType, type FileMessageContent, type EventID } from '@rocket.chat/federation-sdk'; + +import { MatrixMediaService } from '../services/MatrixMediaService'; + +export async function handleMediaMessage( + url: string, + fileInfo: FileMessageContent['info'], + msgtype: MessageType, + messageBody: string, + user: IUser, + room: IRoom, + matrixRoomId: string, + eventId: EventID, + thread?: { tmid: string; tshow: boolean }, +): Promise<{ + fromId: string; + rid: string; + msg: string; + federation_event_id: string; + thread?: { tmid: string; tshow: boolean }; + attachments: [FileAttachmentProps]; +}> { + const mimeType = fileInfo?.mimetype; + const fileName = messageBody; + + const fileRefId = await MatrixMediaService.downloadAndStoreRemoteFile(url, matrixRoomId, { + name: messageBody, + size: fileInfo?.size || 0, + type: mimeType || 'application/octet-stream', + rid: room._id, + userId: user._id, + }); + + const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`; + + let attachment: FileAttachmentProps = { + title: fileName, + type: 'file', + title_link: fileUrl, + title_link_download: true, + description: '', + }; + + if (msgtype === 'm.image') { + attachment = { + ...attachment, + image_url: fileUrl, + image_type: mimeType, + image_size: fileInfo?.size || 0, + ...(fileInfo?.w && + fileInfo?.h && { + image_dimensions: { + width: fileInfo.w, + height: fileInfo.h, + }, + }), + }; + } else if (msgtype === 'm.video') { + attachment = { + ...attachment, + video_url: fileUrl, + video_type: mimeType, + video_size: fileInfo?.size || 0, + }; + } else if (msgtype === 'm.audio') { + attachment = { + ...attachment, + audio_url: fileUrl, + audio_type: mimeType, + audio_size: fileInfo?.size || 0, + }; + } + + return { + fromId: user._id, + rid: room._id, + msg: '', + federation_event_id: eventId, + thread, + attachments: [attachment], + }; +} diff --git a/ee/packages/federation-matrix/src/helpers/isReservedByExclusiveBridge.spec.ts b/ee/packages/federation-matrix/src/helpers/isReservedByExclusiveBridge.spec.ts new file mode 100644 index 0000000000000..75561626f44fe --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/isReservedByExclusiveBridge.spec.ts @@ -0,0 +1,103 @@ +import { federationSDK } from '@rocket.chat/federation-sdk'; + +import { isReservedByExclusiveBridge } from './isReservedByExclusiveBridge'; + +jest.mock('@rocket.chat/federation-sdk', () => ({ + federationSDK: { + getConfig: jest.fn(), + isExclusiveNamespace: jest.fn(), + }, +})); + +const mockGetConfig = federationSDK.getConfig as jest.MockedFunction; +const mockIsExclusiveNamespace = federationSDK.isExclusiveNamespace as jest.MockedFunction; + +describe('isReservedByExclusiveBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetConfig.mockReturnValue('example.com'); + }); + + it('should return false when federation is not configured (no serverName)', () => { + mockGetConfig.mockReturnValue(''); + + expect(isReservedByExclusiveBridge('user', 'irc_bob')).toBe(false); + expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(false); + expect(mockIsExclusiveNamespace).not.toHaveBeenCalled(); + }); + + describe('user', () => { + it('should return false when the username matches no exclusive namespace', () => { + mockIsExclusiveNamespace.mockReturnValue(undefined); + + expect(isReservedByExclusiveBridge('user', 'alice')).toBe(false); + }); + + it('should check the `users` namespace with the full MXID', () => { + mockIsExclusiveNamespace.mockReturnValue(undefined); + + isReservedByExclusiveBridge('user', 'alice'); + + expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('users', '@alice:example.com'); + }); + + it('should return true when the username falls within an exclusive namespace', () => { + mockIsExclusiveNamespace.mockImplementation((_type, value) => + value === '@irc_bob:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined, + ); + + expect(isReservedByExclusiveBridge('user', 'irc_bob')).toBe(true); + }); + + it('should also match against the lowercased localpart', () => { + mockIsExclusiveNamespace.mockImplementation((_type, value) => + value === '@irc_bob:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined, + ); + + expect(isReservedByExclusiveBridge('user', 'IRC_bob')).toBe(true); + }); + }); + + describe('room', () => { + it('should check both the `aliases` and `rooms` namespaces with the room alias', () => { + mockIsExclusiveNamespace.mockReturnValue(undefined); + + isReservedByExclusiveBridge('room', 'general'); + + expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('aliases', '#general:example.com'); + expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('rooms', '#general:example.com'); + }); + + it('should return true when the room name falls within the exclusive `aliases` namespace', () => { + mockIsExclusiveNamespace.mockImplementation((type, value) => + type === 'aliases' && value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined, + ); + + expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(true); + }); + + it('should return true when the room name falls within the exclusive `rooms` namespace', () => { + mockIsExclusiveNamespace.mockImplementation((type, value) => + type === 'rooms' && value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined, + ); + + expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(true); + }); + + it('should not check the `users` namespace for a room', () => { + mockIsExclusiveNamespace.mockReturnValue(undefined); + + isReservedByExclusiveBridge('room', 'general'); + + expect(mockIsExclusiveNamespace).not.toHaveBeenCalledWith('users', expect.anything()); + }); + + it('should also match against the lowercased localpart', () => { + mockIsExclusiveNamespace.mockImplementation((_type, value) => + value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined, + ); + + expect(isReservedByExclusiveBridge('room', 'IRC_general')).toBe(true); + }); + }); +}); diff --git a/ee/packages/federation-matrix/src/helpers/isReservedByExclusiveBridge.ts b/ee/packages/federation-matrix/src/helpers/isReservedByExclusiveBridge.ts new file mode 100644 index 0000000000000..7cbac2041e962 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/isReservedByExclusiveBridge.ts @@ -0,0 +1,53 @@ +import { federationSDK } from '@rocket.chat/federation-sdk'; + +// The federation SDK does not re-export `NamespaceType`, so mirror the union it accepts. +type NamespaceType = 'users' | 'aliases' | 'rooms'; + +/** + * The kind of Rocket.Chat handle being validated. A `user` is a person's username; a `room` + * is a room or team name. Each maps to different Matrix appservice namespaces. + */ +export type ExclusiveBridgeHandleType = 'user' | 'room'; + +/** + * Matrix appservice registrations declare separate exclusive namespaces. A username maps to a + * user MXID (`@localpart:server`) in the `users` namespace; a room/team name maps to a room + * alias (`#localpart:server`) — in Rocket.Chat the room alias and the room itself are one + * concept, so both the `aliases` and `rooms` namespaces are checked. + */ +const NAMESPACES: Record = { + user: { sigil: '@', namespaces: ['users'] }, + room: { sigil: '#', namespaces: ['aliases', 'rooms'] }, +}; + +/** + * Returns `true` when the given local Rocket.Chat handle falls within a bridge's *exclusive* + * namespace, meaning only that bridge may own it and a regular user must not be allowed to + * register/rename into it. + * + * Inert (returns `false`) when federation is not configured: with no `serverName` and no loaded + * appservice registrations, `isExclusiveNamespace` has nothing to match. + */ +export function isReservedByExclusiveBridge(type: ExclusiveBridgeHandleType, name: string): boolean { + const serverName = federationSDK.getConfig('serverName'); + if (!serverName) { + return false; + } + + const { sigil, namespaces } = NAMESPACES[type]; + + // Matrix localparts are conventionally lowercase while Rocket.Chat handles may be mixed-case, + // so also test the lowercased localpart — erring towards reserving is the safe failure mode + // for an exclusive namespace. + const candidates = new Set([`${sigil}${name}:${serverName}`, `${sigil}${name.toLowerCase()}:${serverName}`]); + + for (const namespace of namespaces) { + for (const value of candidates) { + if (federationSDK.isExclusiveNamespace(namespace, value)) { + return true; + } + } + } + + return false; +} diff --git a/ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts new file mode 100644 index 0000000000000..4a3975a92ae7d --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts @@ -0,0 +1,83 @@ +import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from './parseXmppUserId'; + +describe('decodeXmppUserId', () => { + it('should decode the `=xx` escapes back to their characters', () => { + expect(decodeXmppUserId('prince=2fmychannel=40conference.xmpp.host')).toBe('prince/mychannel@conference.xmpp.host'); + }); + + it('should accept uppercase hex digits', () => { + expect(decodeXmppUserId('a=2Fb=40d')).toBe('a/b@d'); + }); + + it('should decode multi-byte UTF-8 characters', () => { + // "é" is U+00E9 -> UTF-8 bytes 0xc3 0xa9 + expect(decodeXmppUserId('caf=c3=a9=40xmpp.host')).toBe('café@xmpp.host'); + }); + + it('should leave a value without escapes untouched', () => { + expect(decodeXmppUserId('justaname')).toBe('justaname'); + }); +}); + +describe('isFullXmppUserId', () => { + it('should accept a value with both `@` and `/`', () => { + expect(isFullXmppUserId('prince/mychannel@conference.xmpp.host')).toBe(true); + }); + + it('should reject a bare JID with no resource', () => { + expect(isFullXmppUserId('alice@xmpp.host')).toBe(false); + }); + + it('should reject a value with no domain separator', () => { + expect(isFullXmppUserId('prince/mychannel')).toBe(false); + }); +}); + +describe('parseXmppUserId', () => { + it('should split a MUC occupant id into resource, local and domain', () => { + expect(parseXmppUserId('prince/mychannel@conference.xmpp.host')).toEqual({ + local: 'mychannel', + domain: 'conference.xmpp.host', + resource: 'prince', + jid: 'mychannel@conference.xmpp.host/prince', + }); + }); + + it('should parse a bare JID with no resource', () => { + expect(parseXmppUserId('alice@xmpp.host')).toEqual({ + local: 'alice', + domain: 'xmpp.host', + resource: undefined, + jid: 'alice@xmpp.host', + }); + }); + + it('should keep a / that belongs to the resource', () => { + expect(parseXmppUserId('a/b/mychannel@conference.xmpp.host')).toEqual({ + local: 'mychannel', + domain: 'conference.xmpp.host', + resource: 'a/b', + jid: 'mychannel@conference.xmpp.host/a/b', + }); + }); + + it('should throw when there is no domain separator', () => { + expect(() => parseXmppUserId('justaname')).toThrow('missing domain separator'); + }); + + it('should throw when the local part is empty', () => { + expect(() => parseXmppUserId('prince/@conference.xmpp.host')).toThrow('empty local or domain'); + }); + + it('should throw when the domain is empty', () => { + expect(() => parseXmppUserId('prince/mychannel@')).toThrow('empty local or domain'); + }); + + it('should throw when both local and domain are empty', () => { + expect(() => parseXmppUserId('@')).toThrow('empty local or domain'); + }); + + it('should throw when a resource separator is present but the resource is empty', () => { + expect(() => parseXmppUserId('/mychannel@conference.xmpp.host')).toThrow('empty resource'); + }); +}); diff --git a/ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts new file mode 100644 index 0000000000000..00d9ddc58fc6d --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts @@ -0,0 +1,102 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ParsedXmppUserId { + /** node / localpart of the JID, e.g. `mychannel` */ + local: string; + /** domain of the JID, e.g. `conference.xmpp.host` */ + domain: string; + /** optional resource — usually the user's nick in a MUC, e.g. `prince` */ + resource?: string; + /** canonical XMPP JID rebuilt as `local@domain[/resource]` */ + jid: string; +} + +/** + * Decode the `=xx` escapes of a Matrix localpart back to their characters. + * + * Characters outside the safe localpart set are encoded as `=` followed by the + * lowercase hex of each UTF-8 byte (https://spec.matrix.org/latest/appendices/#mapping-from-other-character-sets), + * e.g. `/` -> `=2f`, `@` -> `=40`. Multi-byte characters become several + * consecutive `=xx` sequences, so we collect the raw bytes and decode them + * together as UTF-8 rather than per-escape. + * + * @param value - escaped localpart, e.g. `prince=2fmychannel=40conference.xmpp.host` + * @returns the decoded value, e.g. `prince/mychannel@conference.xmpp.host` + */ +export const decodeXmppUserId = (value: string): string => { + const bytes: number[] = []; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + const hex = value.substring(i + 1, i + 3); + + if (char === '=' && /^[0-9a-fA-F]{2}$/.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 2; + continue; + } + + bytes.push(...Buffer.from(char, 'utf8')); + } + + return Buffer.from(bytes).toString('utf8'); +}; + +/** + * Whether a decoded value is a full XMPP MUC occupant id, i.e. it carries both a + * `@` (separating local from domain) and a `/` (separating the resource). Use + * this to guard {@link parseXmppUserId}, which assumes a domain separator is + * present and otherwise rejects the input. + * + * @param decoded - already-decoded value, e.g. `prince/mychannel@conference.xmpp.host` + */ +export const isFullXmppUserId = (decoded: string): boolean => decoded.includes('@') && decoded.includes('/'); + +/** + * Parse a decoded XMPP user identifier into its JID components. The input must + * already be decoded (see {@link decodeXmppUserId}); the value carried in a + * Matrix localpart is the part after the bridge prefix and before `:serverName`. + * + * matrix-bifrost packs an XMPP JID into the localpart as `/@` + * — resource first, since it's usually the more meaningful MUC nick. As neither + * `local` nor `domain` may contain `/` or `@`, we parse from the right so a `/` + * or `@` inside the resource is preserved. + * + * @param decoded - decoded user id, e.g. `prince/mychannel@conference.xmpp.host` + * @throws if the value has no `@` separating local from domain, if `local` or + * `domain` is empty, or if a resource separator (`/`) is present but the + * resource is empty + * + * @example + * parseXmppUserId('prince/mychannel@conference.xmpp.host'); + * // -> { local: 'mychannel', domain: 'conference.xmpp.host', resource: 'prince', + * // jid: 'mychannel@conference.xmpp.host/prince' } + */ +export const parseXmppUserId = (decoded: string): ParsedXmppUserId => { + const atIndex = decoded.lastIndexOf('@'); + if (atIndex === -1) { + throw new Error(`Invalid XMPP user id, missing domain separator: ${decoded}`); + } + + const domain = decoded.substring(atIndex + 1); + const beforeDomain = decoded.substring(0, atIndex); + + const slashIndex = beforeDomain.lastIndexOf('/'); + const resource = slashIndex === -1 ? undefined : beforeDomain.substring(0, slashIndex); + const local = slashIndex === -1 ? beforeDomain : beforeDomain.substring(slashIndex + 1); + + if (!local || !domain) { + throw new Error(`Invalid XMPP user id, empty local or domain: ${decoded}`); + } + + // A `/` with nothing before it is a malformed resource, not a bare JID. + if (slashIndex !== -1 && !resource) { + throw new Error(`Invalid XMPP user id, empty resource: ${decoded}`); + } + + return { + local, + domain, + resource, + jid: resource ? `${local}@${domain}/${resource}` : `${local}@${domain}`, + }; +}; diff --git a/ee/packages/federation-matrix/src/index.ts b/ee/packages/federation-matrix/src/index.ts index 88acc4bb5ece5..8fb17a6369d42 100644 --- a/ee/packages/federation-matrix/src/index.ts +++ b/ee/packages/federation-matrix/src/index.ts @@ -2,6 +2,8 @@ import 'reflect-metadata'; export { validateFederatedUsername } from './helpers/validateFederatedUsername'; +export { isReservedByExclusiveBridge } from './helpers/isReservedByExclusiveBridge'; + export { FederationMatrix } from './FederationMatrix'; export { generateEd25519RandomSecretKey } from '@rocket.chat/federation-sdk'; diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 723d3fd5c2b87..80bd55815235a 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; import { Upload } from '@rocket.chat/core-services'; import type { IUpload } from '@rocket.chat/core-typings'; @@ -7,15 +9,6 @@ import { Avatars, Uploads } from '@rocket.chat/models'; const logger = new Logger('federation-matrix:media-service'); -export interface IRemoteFileReference { - name: string; - size: number; - type: string; - mxcUri: string; - serverName: string; - mediaId: string; -} - export class MatrixMediaService { static generateMXCUri(fileId: string, serverName: string): string { return `mxc://${serverName}/${fileId}`; @@ -86,6 +79,42 @@ export class MatrixMediaService { } } + static async uploadFromAppService(params: { + buffer: Buffer; + fileName: string; + mimeType: string; + userId: string; + }): Promise<{ mediaId: string; mxcUri: string }> { + try { + const serverName = federationSDK.getConfig('serverName'); + const mediaId = crypto.randomUUID().replace(/-/g, ''); // TODO maybe change to @rocket.chat/random ? + const mxcUri = this.generateMXCUri(mediaId, serverName); + + await Upload.uploadFile({ + userId: params.userId, + buffer: params.buffer, + details: { + name: params.fileName, + size: params.buffer.length, + type: params.mimeType, + rid: '', + userId: params.userId, + }, + federation: { + mxcUri, + mrid: '', + serverName, + mediaId, + }, + }); + + return { mediaId, mxcUri }; + } catch (err) { + logger.error({ msg: 'Error uploading file from app service', err }); + throw err; + } + } + static async downloadAndStoreRemoteFile(mxcUri: string, matrixRoomId: string, metadata: IUploadDetails): Promise { try { const parts = this.parseMXCUri(mxcUri); @@ -96,6 +125,11 @@ export class MatrixMediaService { const uploadAlreadyExists = await Uploads.findByFederationMediaIdAndServerName(parts.mediaId, parts.serverName); if (uploadAlreadyExists) { + // App-service uploads are stored before any room is known (empty rid/mrid); + // backfill the association now that a message ties the file to a room. + if (!uploadAlreadyExists.rid && metadata.rid) { + await Uploads.setFederationRoomInfo(uploadAlreadyExists._id, metadata.rid, matrixRoomId); + } return uploadAlreadyExists._id; } diff --git a/ee/packages/federation-matrix/src/setup.ts b/ee/packages/federation-matrix/src/setup.ts index c1fb33b1911d8..6c118f299a8fa 100644 --- a/ee/packages/federation-matrix/src/setup.ts +++ b/ee/packages/federation-matrix/src/setup.ts @@ -32,7 +32,7 @@ function validateDomain(domain: string): boolean { return true; } -export function configureFederationMatrixSettings(settings: { +export async function configureFederationMatrixSettings(settings: { instanceId: string; domain: string; signingKey: string; @@ -43,6 +43,10 @@ export function configureFederationMatrixSettings(settings: { processEDUTyping: boolean; processEDUPresence: boolean; processEDUReceipt: boolean; + xmppEnabled: boolean; + xmppBridgeURL: string; + xmppBridgeHSToken: string; + xmppBridgeASToken: string; }) { const { instanceId, @@ -55,6 +59,10 @@ export function configureFederationMatrixSettings(settings: { processEDUTyping, processEDUPresence, processEDUReceipt, + xmppEnabled, + xmppBridgeURL, + xmppBridgeHSToken, + xmppBridgeASToken, } = settings; if (!validateDomain(serverName)) { @@ -99,6 +107,26 @@ export function configureFederationMatrixSettings(settings: { processReceipt: processEDUReceipt, }, }); + + if (xmppEnabled) { + await federationSDK.registerAppService({ + _id: 'xmpp', + url: xmppBridgeURL, + asToken: xmppBridgeASToken, + hsToken: xmppBridgeHSToken, + senderLocalpart: 'xmpp', + namespaces: { + users: [{ regex: '@_xmpp_.*', exclusive: true }], + aliases: [{ regex: '#_xmpp_.*', exclusive: true }], + rooms: [], + }, + protocols: ['xmpp'], + rateLimited: false, + receiveEphemeral: true, + }); + } else { + await federationSDK.unregisterAppService('xmpp'); + } } export async function setupFederationMatrix() { diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 47fd5c9d91a53..63d963980d295 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.6.3", + "@rocket.chat/federation-sdk": "0.7.0", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.48.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index d8bcb7165a891..ed89cb17ad03b 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, ISubscription, 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): Promise<{ room_id: string; event_id: string }>; @@ -34,4 +34,6 @@ export interface IFederationMatrixService { canUserAccessFederation(user: IUser): Promise; notifyRoomRead(params: { room: IRoomNativeFederated; userId: string; threadId?: string }): Promise; updateUserName(user: IUser): Promise; + joinAppServiceRoom(roomAlias: string, user: IUser): Promise; + saveFederationMessage(event: { event: PduForType<'m.room.message'>; event_id: EventID }): Promise; } diff --git a/packages/core-services/src/types/IUploadService.ts b/packages/core-services/src/types/IUploadService.ts index 4c3024c2765f7..9b3a1504fd2d4 100644 --- a/packages/core-services/src/types/IUploadService.ts +++ b/packages/core-services/src/types/IUploadService.ts @@ -7,6 +7,7 @@ export interface IUploadFileParams { userId: string; buffer: Buffer; details: IUploadDetails; + federation?: Required['federation']; } export interface ISendFileMessageParams { roomId: string; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 1166d1357199f..c52ad8841b8e6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2218,6 +2218,12 @@ "FEDERATION_Test_Setup": "Test setup", "FEDERATION_Test_Setup_Error": "Could not find your server using your setup, please review your settings.", "FEDERATION_Test_Setup_Success": "Your federation setup is working and other servers can find you!", + "Federation_XMPP_Bridge_URL": "Bridge URL", + "Federation_XMPP_Bridge_URL_Description": "The URL of the XMPP bridge that will be used to connect to the XMPP network. This should be a valid URL that points to the XMPP bridge service.", + "Federation_XMPP_Bridge_HS_Token": "Homeserver Token", + "Federation_XMPP_Bridge_HS_Token_Description": "The 'hs_token' used to authenticate the connection between the Rocket.Chat server and the XMPP bridge. This token should be kept secret and only shared with the XMPP bridge service.", + "Federation_XMPP_Bridge_AS_Token": "AppService Token", + "Federation_XMPP_Bridge_AS_Token_Description": "The 'as_token' used to authenticate the connection between the Rocket.Chat server and the XMPP bridge AppService. This token should be kept secret and only shared with the XMPP bridge service.", "Facebook": "Facebook", "Facebook_Page": "Facebook Page", "Failed": "Failed", @@ -2325,6 +2331,9 @@ "Federation_Service_Allow_List_Description": "Restrict federation to the given allow list of domains.", "Federation_Service_Validate_User_Domain": "Users email restrictions", "Federation_Service_Validate_User_Domain_Description": "Restrict access to verified email addresses that match your Federated Domain.", + "Federation_XMPP_Join_Channel_Required": "Please provide a channel to join. Usage: `/xmpp-join #channel`", + "Federation_XMPP_Join_Channel_Success": "You joined the XMPP channel.", + "Federation_XMPP_Join_Channel_Failed": "Could not join the XMPP channel. Please try again later.", "Field": "Field", "Field_removed": "Field removed", "Field_required": "Field required", diff --git a/packages/model-typings/src/models/IUploadsModel.ts b/packages/model-typings/src/models/IUploadsModel.ts index 6cd3729244c15..decda2d2680a3 100644 --- a/packages/model-typings/src/models/IUploadsModel.ts +++ b/packages/model-typings/src/models/IUploadsModel.ts @@ -17,5 +17,7 @@ export interface IUploadsModel extends IBaseUploadsModel { setFederationInfo(fileId: IUpload['_id'], info: Required['federation']): Promise; + setFederationRoomInfo(fileId: IUpload['_id'], rid: IRoom['_id'], mrid: string): Promise; + findAllByOriginalFileId(originalFileId: string, options?: FindOptions): FindCursor; } diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index b20c4589fe2bc..5d7695d6e9cab 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -26,6 +26,10 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { return this.updateOne({ _id: fileId }, { $set: { federation: info } }); } + setFederationRoomInfo(fileId: IUpload['_id'], rid: IRoom['_id'], mrid: string): Promise { + return this.updateOne({ _id: fileId }, { $set: { rid, 'federation.mrid': mrid } }); + } + findPaginatedWithoutThumbs(query: Filter = {}, options?: FindOptions): FindPaginated>> { return this.findPaginated( { diff --git a/yarn.lock b/yarn.lock index 2f3f99a8d4504..1e7efe7bd40d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8895,7 +8895,7 @@ __metadata: dependencies: "@rocket.chat/apps": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.6.3" + "@rocket.chat/federation-sdk": "npm:0.7.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.48.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9056,13 +9056,6 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/emitter@npm:^0.32.0": - version: 0.32.0 - resolution: "@rocket.chat/emitter@npm:0.32.0" - checksum: 10/cf423b52ab01c620e748e089e7645bd2b0b99a921d3c95c7fad1a7ffacbb44a0f2573431c437c9a735d4fb020247356a82eb2f0bba6366e6d55145374811092e - languageName: node - linkType: hard - "@rocket.chat/emitter@npm:^0.33.0": version: 0.33.0 resolution: "@rocket.chat/emitter@npm:0.33.0" @@ -9115,7 +9108,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.33.0" - "@rocket.chat/federation-sdk": "npm:0.6.3" + "@rocket.chat/federation-sdk": "npm:0.7.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9144,22 +9137,23 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.6.3": - version: 0.6.3 - resolution: "@rocket.chat/federation-sdk@npm:0.6.3" +"@rocket.chat/federation-sdk@npm:0.7.0": + version: 0.7.0 + resolution: "@rocket.chat/federation-sdk@npm:0.7.0" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" - "@rocket.chat/emitter": "npm:^0.32.0" + "@rocket.chat/emitter": "npm:^0.33.0" mongodb: "npm:^6.16.0" pino: "npm:^10.3.1" reflect-metadata: "npm:^0.2.2" tsyringe: "npm:^4.10.0" tweetnacl: "npm:^1.0.3" + yaml: "npm:^2.7.1" zod: "npm:~4.3.6" peerDependencies: typescript: ~5.9.2 - checksum: 10/71c8667f3d63e0b4ef0d82ee2b7c7c707494c286cfadf0c6be7e0feed7abc8817b0dfd44bb249861f8411c7747fdcfcde996a1c63d9c2396bba19d7ecb5689ac + checksum: 10/cebd162f6ba829456dac69040a750f33c7c25bb19c4646bf2036a49511c461971bbd8ad840d5ae5a7c48e3b87ca02f0beb8700a1f03e017018962a610bc13bed languageName: node linkType: hard @@ -9747,7 +9741,7 @@ __metadata: "@rocket.chat/emitter": "npm:^0.33.0" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.6.3" + "@rocket.chat/federation-sdk": "npm:0.7.0" "@rocket.chat/fuselage": "npm:^0.82.0" "@rocket.chat/fuselage-forms": "npm:~1.5.0" "@rocket.chat/fuselage-hooks": "npm:~0.43.0" @@ -37838,6 +37832,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.7.1": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10/9a95e8e08651c3d292ab6a5befeb5f57b76801caa097c75bb45c9a70ce19c1b11f57e87a6ef84a579ea070ed2c2c8ac541c88c0ae684d544d5f42c7e77d11b7b + languageName: node + linkType: hard + "yaml@npm:^2.8.3": version: 2.8.3 resolution: "yaml@npm:2.8.3"