diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index adc8ec70dd5e2..fab067bcd35fc 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -139,20 +139,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'; @@ -161,7 +163,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/client/startup/slashCommands/federation.ts b/apps/meteor/client/startup/slashCommands/federation.ts index 25728ad4601a6..045b5eea02e73 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', + options: { + description: 'Join xmpp rooms', + params: '#channel', + }, + providesPreview: false, +}); diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index c258823c90b5e..341fdea11cdca 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -1,10 +1,14 @@ 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 { settings } from '../../../app/settings/server'; +import { slashCommands } from '../../../app/utils/server/slashCommand'; +import { i18n } from '../../../server/lib/i18n'; import { StreamerCentral } from '../../../server/modules/streamer/streamer.module'; 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', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'xmpp'>): 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.joinXMPPChatRoom(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 f43fa09b3c04c..d8392519bad7b 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -106,7 +106,7 @@ "@rocket.chat/emitter": "^0.32.0", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.6.3", + "@rocket.chat/federation-sdk": "0.7.0-beta.4", "@rocket.chat/fuselage": "^0.79.1", "@rocket.chat/fuselage-forms": "^1.3.0", "@rocket.chat/fuselage-hooks": "^0.41.0", diff --git a/apps/meteor/server/services/upload/service.ts b/apps/meteor/server/services/upload/service.ts index f19b4bb57b72b..ce0357ebe4373 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 94e773c1c6e64..4af5477ad13b2 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_Service_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_AS_Token', '', { + type: 'password', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_Service_Enabled', value: true }, + }); + }); }); }; diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 5c780378e8ed9..9417737ad0e21 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.32.0", - "@rocket.chat/federation-sdk": "0.6.3", + "@rocket.chat/federation-sdk": "0.7.0-beta.4", "@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 50e545eb34849..7a563357ae43c 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,14 +17,21 @@ 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 emojione from 'emojione'; 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'; @@ -1027,4 +1042,174 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS }), ); } + + async joinXMPPChatRoom(roomAlias: string, user: IUser): Promise { + try { + if (isUserNativeFederated(user)) { + throw new Error('Federated users cannot join XMPP chat rooms'); + } + + await federationSDK.joinXMPPChatRoom(roomAlias, userIdSchema.parse(`@${user.username}:${this.serverName}`)); + + this.logger.info({ msg: 'User joined XMPP chat room successfully', username: user.username, roomAlias }); + + return true; + } catch (err) { + this.logger.error({ msg: 'Failed to join XMPP chat 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; + const messageBody = body.toString(); + + 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; + } + + 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 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; + } + + 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..05f3ebdb600aa --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts @@ -0,0 +1,96 @@ +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 }, + }; +}; + +// 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); + +const EmptyObjectResponseSchema = { + type: 'object', + additionalProperties: true, +}; + +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..28b4b8e926139 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -0,0 +1,145 @@ +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' }, + }, +}; + +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'); + + const decoded = decodeXmppUserId(body.username); + + if (!isFullXmppUserId(decoded)) { + await createOrUpdateFederatedUser({ + username: body.username, + origin: serverName, + }); + + return { + statusCode: 200, + body: { + user_id: body.username, + }, + }; + } + + const decodedUsername = parseXmppUserId(decoded); + 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}`; + + 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..bb552e3015260 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts @@ -0,0 +1,143 @@ +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, + 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 DirectoryResponseSchema = { + type: 'object', + properties: { + room_id: { type: 'string' }, + servers: { type: 'array', items: { type: 'string' } }, + }, + required: ['room_id'], +}; + +const isDirectoryResponseProps = ajv.compile(DirectoryResponseSchema); + +const DirectoryPutBodySchema = { + type: 'object', + properties: { + room_id: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN }, + }, + required: ['room_id'], + additionalProperties: true, +}; + +const isDirectoryPutBodyProps = ajv.compile(DirectoryPutBodySchema); + +const PublicRoomsResponseSchema = { + type: 'object', + properties: { + chunk: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + total_room_count_estimate: { type: 'number' }, + }, + required: ['chunk'], +}; + +const isPublicRoomsResponseProps = ajv.compile(PublicRoomsResponseSchema); + +export const addDirectoryRoutes = (router: ClientRouter) => { + router + // GET /_matrix/client/v3/directory/room/:roomAlias + .get( + '/v3/directory/room/:roomAlias', + { + params: isRoomAliasParamsProps, + response: { + 200: isDirectoryResponseProps, + 401: isMatrixErrorProps, + 404: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + // TODO(federation-sdk): resolveAlias(roomAlias) → {roomId, servers} + return notImplemented('Room alias resolution not yet implemented'); + }, + ) + + // 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'); + }, + ) + + // GET /_matrix/client/v3/publicRooms + .get( + '/v3/publicRooms', + { + response: { + 200: isPublicRoomsResponseProps, + 401: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + try { + const rooms = await federationSDK.getAllPublicRoomIdsAndNames(); + return { + statusCode: 200, + body: { + chunk: rooms.map((r) => ({ + room_id: r.room_id, + name: r.name, + num_joined_members: 0, + world_readable: false, + guest_can_join: false, + })), + total_room_count_estimate: rooms.length, + }, + }; + } catch (error) { + return internalError('Failed to list public rooms', error); + } + }, + ); +}; 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..8818e521dab55 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/media.ts @@ -0,0 +1,224 @@ +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); + +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}"`; + } + return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; +} + +// 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') ?? ''; + const mediaId = c.req.param('mediaId') ?? ''; + + 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') ?? ''; + const mediaId = c.req.param('mediaId') ?? ''; + 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' }, + }; + } + + 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' }, + }; + } + + const stream = await Upload.streamUploadedFile({ file, imageResizeOpts: { width, height } }); + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + const buffer = Buffer.concat(chunks); + + const mimeType = file.type || 'image/jpeg'; + const fileName = file.name || mediaId; + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': mimeType, + 'content-length': String(buffer.length), + 'content-disposition': contentDispositionHeader('inline', fileName), + }, + body: buffer, + }; + } 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..5f7d12810d2e3 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/presence.ts @@ -0,0 +1,44 @@ +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' }, + last_active_ago: { type: 'number' }, + status_msg: { type: 'string' }, + currently_active: { type: 'boolean' }, + }, + 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..2bded27303280 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -0,0 +1,219 @@ +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); + +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, + 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) { + return internalError('Failed to fetch profile', undefined, { userId, reason: 'missing field parameter' }); + } + + 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 username = c.get('impersonatedUserId') as string; + + 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, + 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..7a366b5cd9671 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -0,0 +1,353 @@ +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, + tags, +} from './_shared'; +import { getFederatedRoomName } from '../../../helpers/getFederatedRoomName'; +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' }, + }, +}; + +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) { + throw new Error('User not found for creating room'); + } + + // 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[]) { + await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderUsername, body.is_direct); + } + + 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) => { + await federationSDK.joinUser(c.req.param('roomIdOrAlias'), c.get('impersonatedUserId')); + + return { + statusCode: 200, + body: {}, + }; + }, + ) + + // 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..a8c51c1c36a16 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -0,0 +1,411 @@ +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 { 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') { + const event = await federationSDK.sendCustomEvent(roomId, eventType, body, senderUsername); + + return { + statusCode: 200, + body: { + event_id: event.eventId, + }, + }; + } + + 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) { + return { + statusCode: 200, + body: { + chunk: [], + start: '', + end: '', + }, + }; + } + const result = await federationSDK.getBackfillEvents(roomId, [fromParam] as never, 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, + 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..bc632f81f8ea5 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts @@ -0,0 +1,281 @@ +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, + notImplemented, + 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) { + return internalError('Failed to fetch state event', error); + } +}; + +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, + 404: 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) { + 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, + 404: 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) { + return internalError('Failed to fetch room state', error, { roomId }); + } + }, + ) + + // GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey + .get( + '/v3/rooms/:roomId/state/:eventType/', + { + params: isStateEventParamsProps, + response: { + 200: isStateContentResponseProps, + 401: 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( + '/v3/rooms/:roomId/state/:eventType/:stateKey?', + { + params: isStateEventParamsProps, + response: { + 200: isStateContentResponseProps, + 401: 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/:stateKey + .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'); + const senderUsername = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + try { + if (eventType === 'm.room.name' && typeof body.name === 'string') { + const event = await federationSDK.updateRoomName(roomId, body.name, senderUsername); + return { + statusCode: 200, + body: { event_id: event.eventId }, + }; + } + if (eventType === 'm.room.topic' && typeof body.topic === 'string') { + await federationSDK.setRoomTopic(roomId, senderUsername, body.topic); + return { + statusCode: 200, + body: { event_id: '' }, + }; + } + + // 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 }); + } + }, + ); +}; 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..dcbc910b4f6c3 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts @@ -0,0 +1,86 @@ +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, + 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..279ff4d09bc42 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts @@ -0,0 +1,28 @@ +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { license, tags } from './_shared'; + +const VersionsResponseSchema = { + type: 'object', + properties: {}, +}; + +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..ff31b2de6f26f --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts @@ -0,0 +1,152 @@ +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']; + +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', + }, + }; + } + + const arrayBuffer = await c.req.raw.arrayBuffer(); + if (!arrayBuffer.byteLength) { + return { + statusCode: 400, + body: { + errcode: 'M_BAD_REQUEST', + error: 'Empty upload body', + }, + }; + } + + 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': 50 * 1024 * 1024, + }, + }; + }, + ) + ); +}; 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..afe737245ab56 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts @@ -0,0 +1,82 @@ +import { errCodes, federationSDK } from '@rocket.chat/federation-sdk'; +import type { Context } from 'hono'; +import { createMiddleware } from 'hono/factory'; + +import { decodeXmppUserId, 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); + + const decodedUsername = parseXmppUserId(decoded); + 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/getFederatedRoomName.spec.ts b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.spec.ts new file mode 100644 index 0000000000000..68078f5083592 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.spec.ts @@ -0,0 +1,19 @@ +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 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..bd8f730a5c8c6 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/getFederatedRoomName.ts @@ -0,0 +1,6 @@ +// 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 turn the `:` separator into `_`, producing a deterministic, unique, +// 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(':', '_'); 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..b385fcbbae428 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/handleMediaMessage.ts @@ -0,0 +1,93 @@ +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, + }); + + 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], + }; +} 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..ae3610045f06c --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts @@ -0,0 +1,67 @@ +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'); + }); +}); 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..c8ba56ddfe873 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts @@ -0,0 +1,91 @@ +// 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 + * + * @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); + + return { + local, + domain, + resource, + jid: resource ? `${local}@${domain}/${resource}` : `${local}@${domain}`, + }; +}; diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 723d3fd5c2b87..4d9691ffdd372 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); diff --git a/ee/packages/federation-matrix/src/setup.ts b/ee/packages/federation-matrix/src/setup.ts index c1fb33b1911d8..77a574e48caeb 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,13 +59,17 @@ export function configureFederationMatrixSettings(settings: { processEDUTyping, processEDUPresence, processEDUReceipt, + xmppEnabled, + xmppBridgeURL, + xmppBridgeHSToken, + xmppBridgeASToken, } = settings; if (!validateDomain(serverName)) { throw new Error('Invalid Federation domain'); } - federationSDK.setConfig({ + await federationSDK.setConfig({ instanceId, serverName, keyRefreshInterval: Number.parseInt(process.env.MATRIX_KEY_REFRESH_INTERVAL || '60', 10), @@ -98,6 +106,13 @@ export function configureFederationMatrixSettings(settings: { processPresence: processEDUPresence, processReceipt: processEDUReceipt, }, + ...(xmppEnabled && { + xmpp: { + bridgeURL: xmppBridgeURL, + hsToken: xmppBridgeHSToken, + asToken: xmppBridgeASToken, + }, + }), }); } diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 21ec95f12155e..838260fcc0e59 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-beta.4", "@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..f951f203bf99a 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; + joinXMPPChatRoom(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 b29ec32dc1295..2e3cc22a0c5c2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2211,6 +2211,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", @@ -2317,6 +2323,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 #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/yarn.lock b/yarn.lock index 762d3c1a558e8..08595fa73ef7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8999,7 +8999,6 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@seald-io/nedb": "npm:^4.1.2" "@types/adm-zip": "npm:^0.5.7" "@types/debug": "npm:^4.1.12" "@types/lodash.clonedeep": "npm:^4.5.9" @@ -9087,7 +9086,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-beta.4" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.48.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9300,7 +9299,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.32.0" - "@rocket.chat/federation-sdk": "npm:0.6.3" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.4" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9329,9 +9328,9 @@ __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-beta.4": + version: 0.7.0-beta.4 + resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.4" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" @@ -9341,10 +9340,11 @@ __metadata: 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/ce59b495003812375d5eef0ca5701044599697f0f8bb809027829f97026cac66aeb0c17bf16c78037543c98704680ace7b7232b85467d144e72de01ffcdd8d70 languageName: node linkType: hard @@ -9931,7 +9931,7 @@ __metadata: "@rocket.chat/emitter": "npm:^0.32.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-beta.4" "@rocket.chat/fuselage": "npm:^0.79.1" "@rocket.chat/fuselage-forms": "npm:^1.3.0" "@rocket.chat/fuselage-hooks": "npm:^0.41.0" @@ -10649,6 +10649,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/logger": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" "@types/node": "npm:~22.19.17" @@ -11427,24 +11428,6 @@ __metadata: languageName: node linkType: hard -"@seald-io/binary-search-tree@npm:^1.0.3": - version: 1.0.3 - resolution: "@seald-io/binary-search-tree@npm:1.0.3" - checksum: 10/0eecd682f56b93557e0cbe4a5b55f48e31f217cae350a5000d397b3ea17a67da62e48dba665f1a9e28345a0d1eb92d287511c1af1dc9e32725157fd181ce7f19 - languageName: node - linkType: hard - -"@seald-io/nedb@npm:^4.1.2": - version: 4.1.2 - resolution: "@seald-io/nedb@npm:4.1.2" - dependencies: - "@seald-io/binary-search-tree": "npm:^1.0.3" - localforage: "npm:^1.10.0" - util: "npm:^0.12.5" - checksum: 10/9d78476bb2af52b18fb781e385a48dd3d4e8f6c40846e47ec499c6ff9e0c7db2f597fd4fcb1bf0e2f6d6096a8c6153fb84106109de01cfcfa583eb77f2bc1e74 - languageName: node - linkType: hard - "@selderee/plugin-htmlparser2@npm:~0.12.0": version: 0.12.0 resolution: "@selderee/plugin-htmlparser2@npm:0.12.0" @@ -37699,6 +37682,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"