From cae81034826ff7fcece21dca2f56b25e538f15e2 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 6 May 2026 11:26:52 -0300 Subject: [PATCH 01/34] add new routes required for federation bridges --- .../src/api/_matrix/client/_shared.ts | 80 +++++ .../src/api/_matrix/client/account.ts | 115 ++++++ .../src/api/_matrix/client/directory.ts | 159 +++++++++ .../src/api/_matrix/client/index.ts | 28 ++ .../src/api/_matrix/client/presence.ts | 44 +++ .../src/api/_matrix/client/profile.ts | 171 +++++++++ .../src/api/_matrix/client/rooms-lifecycle.ts | 327 +++++++++++++++++ .../src/api/_matrix/client/rooms-messaging.ts | 328 ++++++++++++++++++ .../src/api/_matrix/client/rooms-state.ts | 274 +++++++++++++++ .../src/api/_matrix/client/user.ts | 90 +++++ .../src/api/_matrix/client/versions.ts | 26 ++ .../src/api/_matrix/media-bridge.ts | 217 ++++++++++++ .../middlewares/isAppServiceAuthenticated.ts | 65 ++++ .../federation-matrix/src/api/routes.ts | 4 + 14 files changed, 1928 insertions(+) create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/account.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/directory.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/index.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/presence.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/profile.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/user.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/versions.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts create mode 100644 ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.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..08e25f9b835c5 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts @@ -0,0 +1,80 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; +import type { Context } from 'hono'; +import { createMiddleware } from 'hono/factory'; + +export type ClientRouter = Router<'/client', any>; + +// TODO: remove before merge — diagnostic catch-all logger for AS bridge integration +export const catchAllClient = () => + createMiddleware(async (c: Context, next) => { + try { + const { method } = c.req; + const url = new URL(c.req.url); + const path = url.pathname + url.search; + + console.log(`Received request: ${method} ${path}`, c.req.header()); + + return next(); + } catch (error) { + return c.json({ error: 'Internal Server Error' }, 500); + } + }); + +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); 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..74b4fb260e09b --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -0,0 +1,115 @@ +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 { 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 userId = `@${body.username}:${serverName}`; + + await createOrUpdateFederatedUser({ + username: userId, + name: userId, + origin: serverName, + }); + + return { + statusCode: 200, + body: { + user_id: userId, + }, + }; + }, + ) + + // GET /_matrix/client/v3/account/whoami + .get( + '/v3/account/whoami', + { + response: { + 200: isWhoamiResponseProps, + 401: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const userId = c.get('impersonatedUserId') as string; + return { + statusCode: 200, + body: { + user_id: userId, + }, + }; + }, + ); +}; 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..edd919d596371 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts @@ -0,0 +1,159 @@ +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + 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 { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: '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 { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: '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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to list public rooms', + }, + }; + } + }, + ); +}; 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..fef5b57c53452 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts @@ -0,0 +1,28 @@ +import { Router } from '@rocket.chat/http-router'; + +import { catchAllClient } from './_shared'; +import { addAccountRoutes } from './account'; +import { addDirectoryRoutes } from './directory'; +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').use(catchAllClient()); + + addVersionsRoutes(router); + addAccountRoutes(router); + addProfileRoutes(router); + addPresenceRoutes(router); + addDirectoryRoutes(router); + addRoomsLifecycleRoutes(router); + addRoomsStateRoutes(router); + addRoomsMessagingRoutes(router); + addUserRoutes(router); + + return router; +}; 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..974f6a0b9c021 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -0,0 +1,171 @@ +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, 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 { + 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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch profile', + }, + }; + } + }, + ) + + // 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, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const userId = c.req.param('userId'); + const senderId = c.get('impersonatedUserId') as string; + + if (userId !== senderId) { + return { + statusCode: 403, + body: { + errcode: 'M_FORBIDDEN', + error: "Cannot edit another user's profile", + }, + }; + } + + // TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'Global profile update not yet implemented', + }, + }; + }, + ) + + // 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 senderId = c.get('impersonatedUserId') as string; + + if (userId !== senderId) { + return { + statusCode: 403, + body: { + errcode: 'M_FORBIDDEN', + error: "Cannot edit another user's profile", + }, + }; + } + + // TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'Global profile update not yet implemented', + }, + }; + }, + ); +}; 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..02a19172ed85e --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -0,0 +1,327 @@ +import type { 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, + MATRIX_USER_ID_PATTERN, + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + isRoomIdParamsProps, + license, + tags, +} from './_shared'; +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' }, + }, + 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 senderId = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + const app = c.get('appService'); + + console.log('app ->', app); + console.log('senderId ->', senderId); + console.log('params ->', c.req.param()); + console.log('body ->', body); + + const joinRule = body.preset === 'public_chat' || body.visibility === 'public' ? 'public' : 'invite'; + + try { + const result = await federationSDK.createRoom(senderId, body.name ?? '', joinRule); + + for (const invitee of (body.invite ?? []) as string[]) { + await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderId, body.is_direct); + } + + // TODO: support body.room_alias_name once SDK exposes alias creation + return { + statusCode: 200, + body: { + room_id: result.room_id, + }, + }; + } catch (error) { + console.error('Failed to create room', error); + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to create room', + }, + }; + } + }, + ) + + // 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) => { + console.log('join ->', c.req.param('roomIdOrAlias'), c.req.query(), c.get('impersonatedUserId')); + + // TODO(federation-sdk): joinRoom(userId, roomIdOrAlias) — needs alias resolution + invite-less join + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'AS join not yet implemented', + }, + }; + }, + ) + + // 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 senderId = c.get('impersonatedUserId') as UserID; + + try { + await federationSDK.leaveRoom(roomId, senderId); + 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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to leave room', + }, + }; + } + }, + ) + + // 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 senderId = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + try { + await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderId); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to invite user', + }, + }; + } + }, + ) + + // 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 senderId = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + try { + await federationSDK.kickUser(roomId, body.user_id as UserID, senderId, body.reason); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to kick user', + }, + }; + } + }, + ); +}; 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..8a3bc6f46e464 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -0,0 +1,328 @@ +import type { RoomID, UserID } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + MATRIX_USER_ID_PATTERN, + 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 senderId = c.get('impersonatedUserId') as UserID; + const body = await c.req.json(); + + console.log('/v3/rooms/:roomId/send/:eventType/:txnId', { roomId, eventType, senderId, body }); + + if (eventType !== 'm.room.message') { + // TODO: support additional event types (m.reaction, m.room.redaction, etc.) + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'Only m.room.message is supported in v1', + }, + }; + } + + 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', + }, + }; + } + + // TODO: deduplicate by txnId to handle bridge retries + try { + const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderId); + return { + statusCode: 200, + body: { + event_id: event.eventId, + }, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to send message', + }, + }; + } + }, + ) + + // 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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch messages', + }, + }; + } + }, + ) + + // PUT /_matrix/client/v3/rooms/:roomId/typing/:userId + .put( + '/v3/rooms/:roomId/typing/:userId', + { + params: isTypingParamsProps, + query: isImpersonationQueryProps, + body: isTypingBodyProps, + response: { + 200: isEmptyObjectResponseProps, + 401: isMatrixErrorProps, + 403: isMatrixErrorProps, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const userId = c.req.param('userId'); + const body = await c.req.json(); + + try { + await federationSDK.sendTypingNotification(roomId, userId, body.typing === true); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to send typing notification', + }, + }; + } + }, + ) + + // 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, + 500: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async (c) => { + const roomId = c.req.param('roomId') as RoomID; + const eventId = c.req.param('eventId'); + const senderId = c.get('impersonatedUserId') as string; + + try { + await federationSDK.sendReadReceipt({ + roomId, + userId: senderId, + eventIds: [eventId] as never, + }); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to send read receipt', + }, + }; + } + }, + ); +}; 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..3eb0d9789c2f4 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts @@ -0,0 +1,274 @@ +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, 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); + +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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch joined members', + }, + }; + } + }, + ) + + // 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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch room state', + }, + }; + } + }, + ) + + // GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey + .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'); + const stateKey = c.req.param('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, + body: { + errcode: 'M_NOT_FOUND', + error: 'State event not found', + }, + }; + } + return { + statusCode: 200, + body: pe.getContent(), + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch state event', + }, + }; + } + }, + ) + + // 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 senderId = 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, senderId); + return { + statusCode: 200, + body: { event_id: event.eventId }, + }; + } + if (eventType === 'm.room.topic' && typeof body.topic === 'string') { + await federationSDK.setRoomTopic(roomId, senderId, body.topic); + return { + statusCode: 200, + body: { event_id: '' }, + }; + } + + // TODO: extend SDK to send arbitrary state events + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: `State event type ${eventType} not yet implemented`, + }, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to send state event', + }, + }; + } + }, + ); +}; 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..1122bdeb08b66 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts @@ -0,0 +1,90 @@ +import type { 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, + MATRIX_USER_ID_PATTERN, + 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 userId = c.req.param('userId') as UserID; + const senderId = c.get('impersonatedUserId') as string; + const body = await c.req.json(); + + if (userId !== senderId) { + return { + statusCode: 403, + body: { + errcode: 'M_FORBIDDEN', + error: "Cannot edit another user's per-room profile", + }, + }; + } + + try { + await federationSDK.updateUserProfile(roomId, userId, { + displayname: body.displayname ?? undefined, + }); + return { + statusCode: 200, + body: {}, + }; + } catch (error) { + return { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to update per-room displayname', + }, + }; + } + }, + ); +}; 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..05fbebf0030c7 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts @@ -0,0 +1,26 @@ +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: {}, + statusCode: 200, + }), + ); +}; 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..66c892eeecfb5 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts @@ -0,0 +1,217 @@ +import crypto from 'crypto'; + +import { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +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 DownloadParamsSchema = { + type: 'object', + properties: { + serverName: { type: 'string' }, + mediaId: { type: 'string' }, + }, + required: ['serverName', 'mediaId'], +}; + +const isDownloadParamsProps = ajv.compile(DownloadParamsSchema); + +const BufferResponseSchema = { + type: 'object', + description: 'Raw file buffer or multipart 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', +}; + +function createMultipartResponse( + buffer: Buffer, + mimeType: string, + fileName: string, + metadata: Record = {}, +): { body: Buffer; contentType: string } { + const boundary = crypto.randomBytes(16).toString('hex'); + const parts: string[] = []; + + parts.push(`--${boundary}`, 'Content-Type: application/json', '', JSON.stringify(metadata)); + parts.push(`--${boundary}`, `Content-Type: ${mimeType}`, `Content-Disposition: attachment; filename="${fileName}"`, ''); + + const headerBuffer = Buffer.from(`${parts.join('\r\n')}\r\n`); + const endBoundary = Buffer.from(`\r\n--${boundary}--\r\n`); + + return { + body: Buffer.concat([headerBuffer, buffer, endBoundary]), + contentType: `multipart/mixed; boundary=${boundary}`, + }; +} + +const tags = ['Federation', 'Media']; +const license: ['federation'] = ['federation']; + +export const getMatrixMediaBridgeRoutes = () => { + return ( + new Router('/media') + + // POST /_matrix/media/v3/upload + .post( + '/v3/upload', + { + response: { + 200: isUploadResponseProps, + 401: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + // TODO: integrate with Rocket.Chat upload pipeline (FileUpload + MatrixMediaService.generateMXCUri) + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'AS media upload not yet implemented', + }, + }; + }, + ) + + // GET /_matrix/media/v3/download/:serverName/:mediaId + .get( + '/v3/download/:serverName/:mediaId', + { + params: isDownloadParamsProps, + 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; + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': multipartResponse.contentType, + 'content-length': String(multipartResponse.body.length), + }, + body: multipartResponse.body, + }; + } catch (error) { + return { + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, + }; + } + }, + ) + + // GET /_matrix/media/v3/thumbnail/:serverName/:mediaId + .get( + '/v3/thumbnail/:serverName/:mediaId', + { + params: isDownloadParamsProps, + response: { + 200: isBufferResponseProps, + 401: isMatrixErrorProps, + 501: isMatrixErrorProps, + }, + tags, + license, + }, + isAppServiceAuthenticatedMiddleware(), + async () => { + return { + statusCode: 501, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'Media thumbnail not yet implemented', + }, + }; + }, + ) + + // 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/middlewares/isAppServiceAuthenticated.ts b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts new file mode 100644 index 0000000000000..580874bfab893 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts @@ -0,0 +1,65 @@ +import { errCodes, federationSDK } from '@rocket.chat/federation-sdk'; +import type { Context } from 'hono'; +import { createMiddleware } from 'hono/factory'; + +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, + ); + } + + c.set('impersonatedUserId', userId); + + 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()) From 88b10b0697b974d5e655e1515899c70ce416c5ff Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 8 May 2026 19:27:34 -0300 Subject: [PATCH 02/34] expose a way to save federation messages --- .../federation-matrix/src/FederationMatrix.ts | 173 +++++++++++- .../src/api/_matrix/client/rooms-messaging.ts | 6 +- .../src/api/_matrix/client/rooms-state.ts | 96 ++++--- .../federation-matrix/src/events/message.ts | 262 +----------------- .../src/helpers/getThreadMessageId.ts | 17 ++ .../src/helpers/handleMediaMessage.ts | 93 +++++++ .../src/types/IFederationMatrixService.ts | 3 +- 7 files changed, 355 insertions(+), 295 deletions(-) create mode 100644 ee/packages/federation-matrix/src/helpers/getThreadMessageId.ts create mode 100644 ee/packages/federation-matrix/src/helpers/handleMediaMessage.ts diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 50e545eb34849..f709407924ca0 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,156 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS }), ); } + + 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/rooms-messaging.ts b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts index 8a3bc6f46e464..56d3997940b5d 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -1,4 +1,5 @@ -import type { RoomID, UserID } from '@rocket.chat/federation-sdk'; +import { FederationMatrix } from '@rocket.chat/core-services'; +import type { PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; @@ -171,6 +172,9 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { // TODO: deduplicate by txnId to handle bridge retries try { const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderId); + + await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, event_id: event.eventId }); + return { statusCode: 200, body: { 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 index 3eb0d9789c2f4..5e35595d78f31 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts @@ -69,6 +69,43 @@ const PutStateResponseSchema = { 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 { + statusCode: 500 as const, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch state event', + }, + }; + } +}; + export const addRoomsStateRoutes = (router: ClientRouter) => { router // GET /_matrix/client/v3/rooms/:roomId/joined_members @@ -159,7 +196,7 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { // GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey .get( - '/v3/rooms/:roomId/state/:eventType/:stateKey', + '/v3/rooms/:roomId/state/:eventType/', { params: isStateEventParamsProps, response: { @@ -174,41 +211,32 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const eventType = c.req.param('eventType'); + 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') ?? ''; - 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, - body: { - errcode: 'M_NOT_FOUND', - error: 'State event not found', - }, - }; - } - return { - statusCode: 200, - body: pe.getContent(), - }; - } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch state event', - }, - }; - } + return getRoomStateEvent(roomId, eventType, stateKey); }, ) 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/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/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index d8bcb7165a891..8f2d09aa3d41e 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,5 @@ export interface IFederationMatrixService { canUserAccessFederation(user: IUser): Promise; notifyRoomRead(params: { room: IRoomNativeFederated; userId: string; threadId?: string }): Promise; updateUserName(user: IUser): Promise; + saveFederationMessage(event: { event: PduForType<'m.room.message'>; event_id: EventID }): Promise; } From 22cb49fbc5d7a878e677a341875802537636fa1d Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 8 May 2026 19:30:10 -0300 Subject: [PATCH 03/34] create room with alias --- .../src/api/_matrix/client/rooms-lifecycle.ts | 73 +++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) 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 index 02a19172ed85e..3db7275f120b1 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -1,5 +1,8 @@ +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'; @@ -28,6 +31,18 @@ const CreateRoomBodySchema = { 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, }; @@ -130,23 +145,54 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { const senderId = c.get('impersonatedUserId') as UserID; const body = await c.req.json(); - const app = c.get('appService'); + const serverName = federationSDK.getConfig('serverName'); + + const user = await Users.findOneByUsername(senderId, { projection: { _id: 1 } }); + if (!user) { + throw new Error('User not found for creating room'); + } - console.log('app ->', app); - console.log('senderId ->', senderId); - console.log('params ->', c.req.param()); - console.log('body ->', body); + const name = body.name || body.room_alias_name || ''; - const joinRule = body.preset === 'public_chat' || body.visibility === 'public' ? 'public' : 'invite'; + // 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' : 'private'; try { - const result = await federationSDK.createRoom(senderId, body.name ?? '', joinRule); + const result = await federationSDK.createRoomV2({ + name, + alias: body.room_alias_name, + owner: senderId, + 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) { + await Room.create(user._id, { + type: joinRule === 'public' ? 'c' : 'p', + name, + members: [senderId], + 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: name, + }, + }); + } for (const invitee of (body.invite ?? []) as string[]) { await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderId, body.is_direct); } - // TODO: support body.room_alias_name once SDK exposes alias creation return { statusCode: 200, body: { @@ -185,13 +231,14 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { async (c) => { console.log('join ->', c.req.param('roomIdOrAlias'), c.req.query(), c.get('impersonatedUserId')); + // TODO need to first invite and then join? + + await federationSDK.joinUser(c.req.param('roomIdOrAlias'), c.get('impersonatedUserId')); + // TODO(federation-sdk): joinRoom(userId, roomIdOrAlias) — needs alias resolution + invite-less join return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'AS join not yet implemented', - }, + statusCode: 200, + body: {}, }; }, ) From 833004fb09dad7cf6b0993f16b4654555b40a9ff Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 8 May 2026 19:32:12 -0300 Subject: [PATCH 04/34] fix bridge users not as local --- ee/packages/federation-matrix/src/events/member.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index a8db4f1aa4a7b..f1de0a2fc5dd9 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -83,7 +83,8 @@ async function getOrCreateFederatedUser(userId: string): Promise { return user; } - if (isLocal) { + // TODO improve the check for bridge users + if (isLocal && !/_xmpp_/.test(username)) { throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`); } From 96adb1e6b27beee9b7487d5d3a45e199b79fe459 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 8 May 2026 19:32:56 -0300 Subject: [PATCH 05/34] support join without invite for public rooms --- ee/packages/federation-matrix/src/events/member.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index f1de0a2fc5dd9..489119446b621 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -302,8 +302,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}`); } From d492d6531ee18ad15dd9183f514d77852d041ba4 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 8 May 2026 19:33:45 -0300 Subject: [PATCH 06/34] add xmpp slash command to join rooms --- .../startup/slashCommands/federation.ts | 10 ++++++++ apps/meteor/ee/server/startup/federation.ts | 23 +++++++++++++++++++ .../federation-matrix/src/FederationMatrix.ts | 17 ++++++++++++++ .../src/types/IFederationMatrixService.ts | 1 + 4 files changed, 51 insertions(+) diff --git a/apps/meteor/client/startup/slashCommands/federation.ts b/apps/meteor/client/startup/slashCommands/federation.ts index 25728ad4601a6..d3dd0b4cf0a87 100644 --- a/apps/meteor/client/startup/slashCommands/federation.ts +++ b/apps/meteor/client/startup/slashCommands/federation.ts @@ -18,3 +18,13 @@ slashCommands.add({ previewer, previewCallback, }); + +slashCommands.add({ + command: 'xmpp', + options: { + description: 'Join xmpp rooms', + params: '#channel', + // permission: 'archive-room', + }, + providesPreview: false, +}); diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index c258823c90b5e..fff058e4aa34a 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -1,10 +1,13 @@ import { api, FederationMatrix as FederationMatrixService } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { FederationMatrix, configureFederationMatrixSettings, setupFederationMatrix } from '@rocket.chat/federation-matrix'; import { InstanceStatus } from '@rocket.chat/instance-status'; import { License } from '@rocket.chat/license'; import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; +import { slashCommands } from '../../../app/utils/server/slashCommand'; import { StreamerCentral } from '../../../server/modules/streamer/streamer.module'; import { registerFederationRoutes } from '../api/federation'; @@ -78,4 +81,24 @@ export const startFederationService = async (): Promise => { } catch (err) { logger.error({ msg: 'Failed to setup federation-matrix:', err }); } + + slashCommands.add({ + command: 'xmpp', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'xmpp'>): Promise => { + console.log('Joining xmpp room', { params, message, userId }); + + const user = await Users.findOneById(userId); + if (!user) { + logger.error({ msg: 'User not found for joining xmpp room', userId }); + return; + } + + await FederationMatrixService.joinXMPPChatRoom(params.trim(), user); + }, + options: { + description: 'Join xmpp rooms', + params: '#channel', + // permission: 'archive-room', + }, + }); }; diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index f709407924ca0..da97862e962e3 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1043,6 +1043,23 @@ 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'); + } + + const result = await federationSDK.joinXMPPChatRoom(roomAlias, userIdSchema.parse(`@${user.username}:${this.serverName}`)); + + console.log(result); + + this.logger.info({ msg: 'User joined XMPP chat room successfully', username: user.username, roomAlias }); + } catch (err) { + this.logger.error({ msg: 'Failed to join XMPP chat room', err }); + throw err; + } + } + async saveFederationMessage({ event, event_id: eventId }: { event: PduForType<'m.room.message'>; event_id: EventID }): Promise { const { msgtype, body } = event.content; const messageBody = body.toString(); diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 8f2d09aa3d41e..506730db4d4be 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -34,5 +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; } From 1bc6a41ae4cf7380c4a1b9d4d3ec8af1e5b20688 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 18 May 2026 16:04:41 -0300 Subject: [PATCH 07/34] fix typing --- .../src/api/_matrix/client/rooms-messaging.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 index 56d3997940b5d..1a9ad35016cbe 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -1,6 +1,7 @@ -import { FederationMatrix } from '@rocket.chat/core-services'; +import { api, FederationMatrix } from '@rocket.chat/core-services'; import type { PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Rooms } from '@rocket.chat/models'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; @@ -255,6 +256,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { body: isTypingBodyProps, response: { 200: isEmptyObjectResponseProps, + 400: isMatrixErrorProps, 401: isMatrixErrorProps, 403: isMatrixErrorProps, 500: isMatrixErrorProps, @@ -268,7 +270,34 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { const userId = c.req.param('userId'); const body = await c.req.json(); + if (!userId) { + 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', + }, + }; + } + + void api.broadcast('user.activity', { + user: userId, + isTyping: body.typing, + roomId: matrixRoom._id, + }); + await federationSDK.sendTypingNotification(roomId, userId, body.typing === true); return { statusCode: 200, From db2c6f1d3fb46929437c839b4bf43252e4fa72d6 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 25 May 2026 14:34:40 -0300 Subject: [PATCH 08/34] save user displayname --- .../src/api/_matrix/client/account.ts | 2 ++ .../src/api/_matrix/client/profile.ts | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts index 74b4fb260e09b..995a95c227ab2 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -75,6 +75,8 @@ export const addAccountRoutes = (router: ClientRouter) => { const serverName = federationSDK.getConfig('serverName'); const userId = `@${body.username}:${serverName}`; + // TODO may need to parse name and username, currently they're saved as @_xmpp_prince=2fmychannel=40conference.xmpp.host:rc.host + await createOrUpdateFederatedUser({ username: userId, name: userId, diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts index 974f6a0b9c021..893e435820be0 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -1,4 +1,5 @@ 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'; @@ -56,6 +57,7 @@ export const addProfileRoutes = (router: ClientRouter) => { 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 { @@ -96,7 +98,7 @@ export const addProfileRoutes = (router: ClientRouter) => { 200: isEmptyObjectResponseProps, 401: isMatrixErrorProps, 403: isMatrixErrorProps, - 501: isMatrixErrorProps, + 404: isMatrixErrorProps, }, tags, license, @@ -106,6 +108,8 @@ export const addProfileRoutes = (router: ClientRouter) => { const userId = c.req.param('userId'); const senderId = c.get('impersonatedUserId') as string; + const body = await c.req.json(); + if (userId !== senderId) { return { statusCode: 403, @@ -116,13 +120,22 @@ export const addProfileRoutes = (router: ClientRouter) => { }; } - // TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms + const user = await Users.findOneByUsername(userId); + if (!user) { + return { + statusCode: 404, + body: { + errcode: 'M_NOT_FOUND', + error: 'User not found', + }, + }; + } + + await Users.setName(user._id, body.displayname); + return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'Global profile update not yet implemented', - }, + statusCode: 200, + body: {}, }; }, ) From 963cdd87acdb14bf427a2f2f27e1a87e83986843 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 25 May 2026 19:06:50 -0300 Subject: [PATCH 09/34] add file upload support --- .../app/file-upload/server/lib/FileUpload.ts | 12 +- apps/meteor/server/services/upload/service.ts | 4 +- .../src/api/_matrix/client/_shared.ts | 11 ++ .../src/api/_matrix/client/rooms-messaging.ts | 37 +++- .../src/api/_matrix/media-bridge.ts | 182 ++++++++++++++++-- .../src/services/MatrixMediaService.ts | 38 ++++ .../core-services/src/types/IUploadService.ts | 1 + 7 files changed, 256 insertions(+), 29 deletions(-) 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/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/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts index 08e25f9b835c5..cdb3b19439105 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts @@ -78,3 +78,14 @@ const UserIdParamsSchema = { }; 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/rooms-messaging.ts b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts index 1a9ad35016cbe..cceb190d9e193 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -1,5 +1,5 @@ import { api, FederationMatrix } from '@rocket.chat/core-services'; -import type { PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk'; +import type { FileMessageContent, FileMessageType, PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; import { Rooms } from '@rocket.chat/models'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; @@ -170,8 +170,43 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { }; } + 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, senderId); + + 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, senderId); await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, event_id: event.eventId }); diff --git a/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts index 66c892eeecfb5..2bd93fd900763 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts @@ -1,7 +1,9 @@ import crypto from 'crypto'; +import { Upload } from '@rocket.chat/core-services'; import { Router } from '@rocket.chat/http-router'; -import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { Users } from '@rocket.chat/models'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import { MatrixMediaService } from '../../services/MatrixMediaService'; import { isAppServiceAuthenticatedMiddleware } from '../middlewares/isAppServiceAuthenticated'; @@ -27,6 +29,49 @@ const UploadResponseSchema = { 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 ThumbnailParamsSchema = { + type: 'object', + properties: { + serverName: { type: 'string' }, + mediaId: { type: 'string' }, + }, + required: ['serverName', 'mediaId'], +}; + +const isThumbnailParamsProps = ajv.compile(ThumbnailParamsSchema); + +const ThumbnailQuerySchema = { + type: 'object', + properties: { + width: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + height: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + method: { type: 'string', enum: ['crop', 'scale'] }, + access_token: { type: 'string' }, + }, +}; + +const isThumbnailQueryProps = ajvQuery.compile<{ + width?: number | string; + height?: number | string; + method?: 'crop' | 'scale'; + access_token?: string; +}>(ThumbnailQuerySchema); + const DownloadParamsSchema = { type: 'object', properties: { @@ -95,24 +140,68 @@ export const getMatrixMediaBridgeRoutes = () => { .post( '/v3/upload', { + query: isUploadQueryProps, response: { 200: isUploadResponseProps, + 400: isMatrixErrorProps, 401: isMatrixErrorProps, - 501: isMatrixErrorProps, + 413: isMatrixErrorProps, + 500: isMatrixErrorProps, }, tags, license, }, isAppServiceAuthenticatedMiddleware(), - async () => { - // TODO: integrate with Rocket.Chat upload pipeline (FileUpload + MatrixMediaService.generateMXCUri) - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'AS media upload not yet implemented', - }, - }; + async (c) => { + try { + const senderId = 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(senderId, { 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', + }, + }; + } }, ) @@ -171,24 +260,75 @@ export const getMatrixMediaBridgeRoutes = () => { .get( '/v3/thumbnail/:serverName/:mediaId', { - params: isDownloadParamsProps, + params: isThumbnailParamsProps, + query: isThumbnailQueryProps, response: { 200: isBufferResponseProps, + 400: isMatrixErrorProps, 401: isMatrixErrorProps, - 501: isMatrixErrorProps, + 404: isMatrixErrorProps, + 500: isMatrixErrorProps, }, tags, license, }, isAppServiceAuthenticatedMiddleware(), - async () => { - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'Media thumbnail not yet implemented', - }, - }; + 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; + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': multipartResponse.contentType, + 'content-length': String(multipartResponse.body.length), + }, + body: multipartResponse.body, + }; + } catch (error) { + return { + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, + }; + } }, ) diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 723d3fd5c2b87..9aba8605105f2 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'; @@ -86,6 +88,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/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; From 8a00424585646da26ce0ef77b729d6a6b20dc29a Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 25 May 2026 20:42:46 -0300 Subject: [PATCH 10/34] send files --- .../src/api/_matrix/client/index.ts | 2 + .../src/api/_matrix/client/media.ts | 230 ++++++++++++++++++ .../src/api/_matrix/media-bridge.ts | 205 ---------------- 3 files changed, 232 insertions(+), 205 deletions(-) create mode 100644 ee/packages/federation-matrix/src/api/_matrix/client/media.ts diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/index.ts b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts index fef5b57c53452..efaba5e39c550 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/index.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts @@ -3,6 +3,7 @@ import { Router } from '@rocket.chat/http-router'; import { catchAllClient } from './_shared'; 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'; @@ -23,6 +24,7 @@ export const getClientRoutes = () => { 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..9da2eaae10e0b --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/client/media.ts @@ -0,0 +1,230 @@ +import { Upload } from '@rocket.chat/core-services'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +import type { ClientRouter } from './_shared'; +import { 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 { + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server 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 { + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server 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/media-bridge.ts b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts index 2bd93fd900763..c868fc9e7c8b7 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts @@ -1,6 +1,3 @@ -import crypto from 'crypto'; - -import { Upload } from '@rocket.chat/core-services'; import { Router } from '@rocket.chat/http-router'; import { Users } from '@rocket.chat/models'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; @@ -44,53 +41,6 @@ const isUploadQueryProps = ajvQuery.compile<{ access_token?: string; }>(UploadQuerySchema); -const ThumbnailParamsSchema = { - type: 'object', - properties: { - serverName: { type: 'string' }, - mediaId: { type: 'string' }, - }, - required: ['serverName', 'mediaId'], -}; - -const isThumbnailParamsProps = ajv.compile(ThumbnailParamsSchema); - -const ThumbnailQuerySchema = { - type: 'object', - properties: { - width: { oneOf: [{ type: 'number' }, { type: 'string' }] }, - height: { oneOf: [{ type: 'number' }, { type: 'string' }] }, - method: { type: 'string', enum: ['crop', 'scale'] }, - access_token: { type: 'string' }, - }, -}; - -const isThumbnailQueryProps = ajvQuery.compile<{ - width?: number | string; - height?: number | string; - method?: 'crop' | 'scale'; - access_token?: string; -}>(ThumbnailQuerySchema); - -const DownloadParamsSchema = { - type: 'object', - properties: { - serverName: { type: 'string' }, - mediaId: { type: 'string' }, - }, - required: ['serverName', 'mediaId'], -}; - -const isDownloadParamsProps = ajv.compile(DownloadParamsSchema); - -const BufferResponseSchema = { - type: 'object', - description: 'Raw file buffer or multipart response', - additionalProperties: true, -}; - -const isBufferResponseProps = ajv.compile(BufferResponseSchema); - const ConfigResponseSchema = { type: 'object', properties: { @@ -101,34 +51,6 @@ const ConfigResponseSchema = { 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', -}; - -function createMultipartResponse( - buffer: Buffer, - mimeType: string, - fileName: string, - metadata: Record = {}, -): { body: Buffer; contentType: string } { - const boundary = crypto.randomBytes(16).toString('hex'); - const parts: string[] = []; - - parts.push(`--${boundary}`, 'Content-Type: application/json', '', JSON.stringify(metadata)); - parts.push(`--${boundary}`, `Content-Type: ${mimeType}`, `Content-Disposition: attachment; filename="${fileName}"`, ''); - - const headerBuffer = Buffer.from(`${parts.join('\r\n')}\r\n`); - const endBoundary = Buffer.from(`\r\n--${boundary}--\r\n`); - - return { - body: Buffer.concat([headerBuffer, buffer, endBoundary]), - contentType: `multipart/mixed; boundary=${boundary}`, - }; -} - const tags = ['Federation', 'Media']; const license: ['federation'] = ['federation']; @@ -205,133 +127,6 @@ export const getMatrixMediaBridgeRoutes = () => { }, ) - // GET /_matrix/media/v3/download/:serverName/:mediaId - .get( - '/v3/download/:serverName/:mediaId', - { - params: isDownloadParamsProps, - 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; - const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); - - return { - statusCode: 200, - headers: { - ...SECURITY_HEADERS, - 'content-type': multipartResponse.contentType, - 'content-length': String(multipartResponse.body.length), - }, - body: multipartResponse.body, - }; - } catch (error) { - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; - } - }, - ) - - // GET /_matrix/media/v3/thumbnail/:serverName/:mediaId - .get( - '/v3/thumbnail/:serverName/:mediaId', - { - params: isThumbnailParamsProps, - 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; - const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); - - return { - statusCode: 200, - headers: { - ...SECURITY_HEADERS, - 'content-type': multipartResponse.contentType, - 'content-length': String(multipartResponse.body.length), - }, - body: multipartResponse.body, - }; - } catch (error) { - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; - } - }, - ) - // GET /_matrix/media/r0/config (literal r0; matrix-bot-sdk hardcodes this path) .get( '/r0/config', From 3dc6e0957f815d49cc3520f79d1f94b3599919e9 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 25 May 2026 20:44:31 -0300 Subject: [PATCH 11/34] add profile routes for specific fields --- .../src/api/_matrix/client/profile.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts index 893e435820be0..6fb1212e86e41 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -3,7 +3,15 @@ import { Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; -import { isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, isUserIdParamsProps, license, tags } from './_shared'; +import { + isEmptyObjectResponseProps, + isImpersonationQueryProps, + isMatrixErrorProps, + isProfileFieldParamsProps, + isUserIdParamsProps, + license, + tags, +} from './_shared'; import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; const ProfileGetResponseSchema = { @@ -87,6 +95,65 @@ export const addProfileRoutes = (router: ClientRouter) => { }, ) + // 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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch profile', + }, + }; + } + + 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 { + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to fetch profile', + }, + }; + } + }, + ) + // PUT /_matrix/client/v3/profile/:userId/displayname .put( '/v3/profile/:userId/displayname', From 9aabb2a8cbd4206cc1b710704b2097608aabf658 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 27 May 2026 17:26:33 -0300 Subject: [PATCH 12/34] add valid response to /client/versions --- .../federation-matrix/src/api/_matrix/client/versions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts b/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts index 05fbebf0030c7..279ff4d09bc42 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/versions.ts @@ -19,7 +19,9 @@ export const addVersionsRoutes = (router: ClientRouter) => { license, }, async () => ({ - body: {}, + body: { + versions: ['v1.4'], + }, statusCode: 200, }), ); From a9ba13ccd98c9c66a01d4c438b4b335493fdd47e Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 27 May 2026 17:34:17 -0300 Subject: [PATCH 13/34] use standard logger instance for Matrix APIs --- ee/packages/federation-matrix/src/api/_matrix/invite.ts | 4 +--- ee/packages/federation-matrix/src/api/_matrix/make-leave.ts | 4 +--- ee/packages/federation-matrix/src/api/_matrix/send-leave.ts | 6 ++---- ee/packages/federation-matrix/src/api/logger.ts | 3 +++ 4 files changed, 7 insertions(+), 10 deletions(-) create mode 100644 ee/packages/federation-matrix/src/api/logger.ts 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/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'); From 8fcc1049e1af2981f3f5b38b451e6689ed8c48df Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 27 May 2026 17:34:41 -0300 Subject: [PATCH 14/34] add read receipt capability --- .../src/api/_matrix/client/rooms-messaging.ts | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) 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 index cceb190d9e193..8162b79f4b57b 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -1,7 +1,7 @@ -import { api, FederationMatrix } from '@rocket.chat/core-services'; +import { api, FederationMatrix, Room } from '@rocket.chat/core-services'; import type { FileMessageContent, FileMessageType, PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; @@ -15,6 +15,7 @@ import { license, tags, } from './_shared'; +import { logger } from '../../logger'; import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; const SendEventParamsSchema = { @@ -147,8 +148,6 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { const senderId = c.get('impersonatedUserId') as UserID; const body = await c.req.json(); - console.log('/v3/rooms/:roomId/send/:eventType/:txnId', { roomId, eventType, senderId, body }); - if (eventType !== 'm.room.message') { // TODO: support additional event types (m.reaction, m.room.redaction, etc.) return { @@ -361,6 +360,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { 200: isEmptyObjectResponseProps, 401: isMatrixErrorProps, 403: isMatrixErrorProps, + 404: isMatrixErrorProps, 500: isMatrixErrorProps, }, tags, @@ -369,20 +369,39 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const eventId = c.req.param('eventId'); const senderId = c.get('impersonatedUserId') as string; try { - await federationSDK.sendReadReceipt({ - roomId, - userId: senderId, - eventIds: [eventId] as never, - }); + const matrixUser = await Users.findOneByUsername(senderId); + 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) { + } catch (err) { + logger.error({ msg: 'Failed to send read receipt', roomId, senderId, err }); return { statusCode: 500, body: { From 8c602bd93d54789038eee60eeb8be3c0b9ea024b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 1 Jun 2026 20:38:59 -0300 Subject: [PATCH 15/34] setup xmpp via settings --- apps/meteor/ee/server/startup/federation.ts | 26 ++++++++++---- .../server/settings/federation-service.ts | 35 +++++++++++++++++++ ee/packages/federation-matrix/src/setup.ts | 19 ++++++++-- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index fff058e4aa34a..ea8d18264de66 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -23,7 +23,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'), @@ -34,6 +34,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 }); @@ -58,6 +62,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', @@ -70,18 +84,16 @@ 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 => { diff --git a/apps/meteor/server/settings/federation-service.ts b/apps/meteor/server/settings/federation-service.ts index 94e773c1c6e64..86b2a2cd53960 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: 'string', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_Service_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_AS_Token', '', { + type: 'string', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_Service_Enabled', value: true }, + }); + }); }); }; 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, + }, + }), }); } From 8c623e434e870e7c2dff29687eda6247ac1e8d9c Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 1 Jun 2026 20:39:19 -0300 Subject: [PATCH 16/34] remove check hack --- ee/packages/federation-matrix/src/events/member.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 489119446b621..d12555fbfd2ee 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -83,8 +83,7 @@ async function getOrCreateFederatedUser(userId: string): Promise { return user; } - // TODO improve the check for bridge users - if (isLocal && !/_xmpp_/.test(username)) { + if (isLocal) { throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`); } From 6d45a5d3e70bc9a336ddab01059ca349f35904b6 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 1 Jun 2026 20:43:56 -0300 Subject: [PATCH 17/34] cleanup slashcommand --- apps/meteor/client/startup/slashCommands/federation.ts | 1 - apps/meteor/ee/server/startup/federation.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/meteor/client/startup/slashCommands/federation.ts b/apps/meteor/client/startup/slashCommands/federation.ts index d3dd0b4cf0a87..045b5eea02e73 100644 --- a/apps/meteor/client/startup/slashCommands/federation.ts +++ b/apps/meteor/client/startup/slashCommands/federation.ts @@ -24,7 +24,6 @@ slashCommands.add({ options: { description: 'Join xmpp rooms', params: '#channel', - // permission: 'archive-room', }, providesPreview: false, }); diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index ea8d18264de66..5965f4a50401f 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -96,9 +96,7 @@ export const startFederationService = async (): Promise => { slashCommands.add({ command: 'xmpp', - callback: async ({ params, message, userId }: SlashCommandCallbackParams<'xmpp'>): Promise => { - console.log('Joining xmpp room', { params, message, userId }); - + callback: async ({ params, message: _message, userId }: SlashCommandCallbackParams<'xmpp'>): Promise => { const user = await Users.findOneById(userId); if (!user) { logger.error({ msg: 'User not found for joining xmpp room', userId }); @@ -110,7 +108,6 @@ export const startFederationService = async (): Promise => { options: { description: 'Join xmpp rooms', params: '#channel', - // permission: 'archive-room', }, }); }; From 6c0a4bd2d16b0772c75af33ee4802055ba3f39cc Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 2 Jun 2026 18:35:09 -0300 Subject: [PATCH 18/34] chore: bump federation-sdk to version 0.7.0-beta.0 --- apps/meteor/package.json | 2 +- ee/packages/federation-matrix/package.json | 2 +- packages/core-services/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index f43fa09b3c04c..b1c088dda8fa0 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.0", "@rocket.chat/fuselage": "^0.79.1", "@rocket.chat/fuselage-forms": "^1.3.0", "@rocket.chat/fuselage-hooks": "^0.41.0", diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 5c780378e8ed9..f4515a70da876 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.0", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 21ec95f12155e..e43180090bb85 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.0", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.48.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 762d3c1a558e8..153bc4f8c914d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9087,7 +9087,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.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.48.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9300,7 +9300,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.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9329,9 +9329,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.0": + version: 0.7.0-beta.0 + resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.0" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" @@ -9344,7 +9344,7 @@ __metadata: zod: "npm:~4.3.6" peerDependencies: typescript: ~5.9.2 - checksum: 10/71c8667f3d63e0b4ef0d82ee2b7c7c707494c286cfadf0c6be7e0feed7abc8817b0dfd44bb249861f8411c7747fdcfcde996a1c63d9c2396bba19d7ecb5689ac + checksum: 10/b996da42970ee9af49ac7a432807ecc1d2fd7b2d47c7a260841458df6308ddee3779afd6d53fd8896a5f642be4a34b74e9811c336c0c961346d494902be33018 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.0" "@rocket.chat/fuselage": "npm:^0.79.1" "@rocket.chat/fuselage-forms": "npm:^1.3.0" "@rocket.chat/fuselage-hooks": "npm:^0.41.0" From 95cf3c3ddcf523a6d81087ba2f20da99c6a1939d Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 2 Jun 2026 19:38:56 -0300 Subject: [PATCH 19/34] chore: update federation-sdk to version 0.7.0-beta.1 --- apps/meteor/package.json | 2 +- ee/packages/federation-matrix/package.json | 2 +- packages/core-services/package.json | 2 +- yarn.lock | 24 +++++++++++++++------- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b1c088dda8fa0..be985da51fac8 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.7.0-beta.0", + "@rocket.chat/federation-sdk": "0.7.0-beta.1", "@rocket.chat/fuselage": "^0.79.1", "@rocket.chat/fuselage-forms": "^1.3.0", "@rocket.chat/fuselage-hooks": "^0.41.0", diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index f4515a70da876..b7f0cae137687 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.7.0-beta.0", + "@rocket.chat/federation-sdk": "0.7.0-beta.1", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/packages/core-services/package.json b/packages/core-services/package.json index e43180090bb85..2f36687115719 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.7.0-beta.0", + "@rocket.chat/federation-sdk": "0.7.0-beta.1", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.48.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 153bc4f8c914d..a78b886af38a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9087,7 +9087,7 @@ __metadata: dependencies: "@rocket.chat/apps": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.7.0-beta.0" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.1" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.48.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9300,7 +9300,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.7.0-beta.0" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.1" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9329,9 +9329,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.7.0-beta.0": - version: 0.7.0-beta.0 - resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.0" +"@rocket.chat/federation-sdk@npm:0.7.0-beta.1": + version: 0.7.0-beta.1 + resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.1" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" @@ -9341,10 +9341,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/b996da42970ee9af49ac7a432807ecc1d2fd7b2d47c7a260841458df6308ddee3779afd6d53fd8896a5f642be4a34b74e9811c336c0c961346d494902be33018 + checksum: 10/63026f98940b4b58e4b26408abc65e98105e244b09d80d9a0160ccd4328cb8eba82e542fbf9140cf206b4dbe80f26d0d432c6938308c775841c93d6cae27e282 languageName: node linkType: hard @@ -9931,7 +9932,7 @@ __metadata: "@rocket.chat/emitter": "npm:^0.32.0" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.7.0-beta.0" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.1" "@rocket.chat/fuselage": "npm:^0.79.1" "@rocket.chat/fuselage-forms": "npm:^1.3.0" "@rocket.chat/fuselage-hooks": "npm:^0.41.0" @@ -37699,6 +37700,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" From 033e5e6dbfa01f8c940d3098153299dc7bf24aa3 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 19:31:02 -0300 Subject: [PATCH 20/34] fix usernames from xmpp --- .../src/api/_matrix/client/account.ts | 37 +++++++- .../src/api/_matrix/client/rooms-messaging.ts | 2 +- .../middlewares/isAppServiceAuthenticated.ts | 19 +++- .../federation-matrix/src/events/member.ts | 9 ++ .../src/helpers/isXmppUserId.spec.ts | 45 +++++++++ .../src/helpers/isXmppUserId.ts | 36 ++++++++ .../src/helpers/parseXmppUserId.spec.ts | 67 ++++++++++++++ .../src/helpers/parseXmppUserId.ts | 91 +++++++++++++++++++ 8 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts create mode 100644 ee/packages/federation-matrix/src/helpers/isXmppUserId.ts create mode 100644 ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts create mode 100644 ee/packages/federation-matrix/src/helpers/parseXmppUserId.ts diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts index 995a95c227ab2..abb3bb9c3cd45 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -4,6 +4,7 @@ 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 { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; const RegisterBodySchema = { @@ -73,20 +74,46 @@ export const addAccountRoutes = (router: ClientRouter) => { } const serverName = federationSDK.getConfig('serverName'); - const userId = `@${body.username}:${serverName}`; - // TODO may need to parse name and username, currently they're saved as @_xmpp_prince=2fmychannel=40conference.xmpp.host:rc.host + 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) { + return { + statusCode: 400, + body: { + errcode: '', + error: '', + }, + }; + } + + const username = `@${decodedUsername.resource}:${serverName}`; await createOrUpdateFederatedUser({ - username: userId, - name: userId, + username, + // name: decodedUsername.resource, origin: serverName, }); return { statusCode: 200, body: { - user_id: userId, + user_id: username, }, }; }, 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 index 8162b79f4b57b..835bb0eafb2b8 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -301,7 +301,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const userId = c.req.param('userId'); + const userId = c.get('impersonatedUserId'); const body = await c.req.json(); if (!userId) { diff --git a/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts index 580874bfab893..afe737245ab56 100644 --- a/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts +++ b/ee/packages/federation-matrix/src/api/middlewares/isAppServiceAuthenticated.ts @@ -2,6 +2,8 @@ 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 { @@ -56,7 +58,22 @@ export const isAppServiceAuthenticatedMiddleware = () => ); } - c.set('impersonatedUserId', userId); + 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) { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index d12555fbfd2ee..2961f84b60679 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -83,6 +83,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}`); } diff --git a/ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts b/ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts new file mode 100644 index 0000000000000..b4e3ffdc2665d --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts @@ -0,0 +1,45 @@ +import { isXmppUserId } from './isXmppUserId'; + +describe('isXmppUserId', () => { + it('should accept a prefixed MUC occupant id', () => { + expect(isXmppUserId('_xmpp_prince=2fmychannel=40conference.xmpp.host')).toBe(true); + }); + + it('should accept a prefixed bare JID with no resource', () => { + expect(isXmppUserId('_xmpp_alice=40xmpp.host')).toBe(true); + }); + + it('should reject a value without the bridge prefix', () => { + expect(isXmppUserId('prince=2fmychannel=40conference.xmpp.host')).toBe(false); + }); + + it('should reject a normal username', () => { + expect(isXmppUserId('john.doe')).toBe(false); + }); + + it('should reject a prefixed value that is not a JID (no domain)', () => { + expect(isXmppUserId('_xmpp_justaname')).toBe(false); + }); + + it('should reject the bare prefix', () => { + expect(isXmppUserId('_xmpp_')).toBe(false); + }); + + it('should reject a prefixed value with an empty local part', () => { + expect(isXmppUserId('_xmpp_=40xmpp.host')).toBe(false); + }); + + it('should reject a prefixed value with an invalid domain', () => { + expect(isXmppUserId('_xmpp_alice=40not_a_domain')).toBe(false); + }); + + it('should honour a custom prefix', () => { + expect(isXmppUserId('_bifrost_alice=40xmpp.host', '_bifrost_')).toBe(true); + expect(isXmppUserId('_xmpp_alice=40xmpp.host', '_bifrost_')).toBe(false); + }); + + it('should validate a bare escaped JID when prefix is empty', () => { + expect(isXmppUserId('alice=40xmpp.host', '')).toBe(true); + expect(isXmppUserId('justaname', '')).toBe(false); + }); +}); diff --git a/ee/packages/federation-matrix/src/helpers/isXmppUserId.ts b/ee/packages/federation-matrix/src/helpers/isXmppUserId.ts new file mode 100644 index 0000000000000..f84df4d24c414 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/isXmppUserId.ts @@ -0,0 +1,36 @@ +import { decodeXmppUserId, parseXmppUserId } from './parseXmppUserId'; + +/** Default matrix-bifrost user prefix for the XMPP protocol on this deployment. */ +export const XMPP_USER_ID_PREFIX = '_xmpp_'; + +// Hostname grammar (RFC 1123 labels), same shape used by validateFederatedUsername. +const DOMAIN_REGEX = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; + +/** + * Check whether a Matrix localpart (e.g. `body.username` from an AS `/register` + * call) is a bridged XMPP user, so the caller can decide whether to run + * {@link parseXmppUserId} on it. + * + * A value qualifies when it carries the bridge prefix **and** the remainder + * decodes to a well-formed `local@domain` JID. The structural check guards + * against a normal user that merely happens to start with the prefix. + * + * @param userId - the localpart to test, e.g. `_xmpp_prince=2fmychannel=40conference.xmpp.host` + * @param prefix - bridge prefix to require; pass `''` to validate a bare escaped JID + * + * @example + * isXmppUserId('_xmpp_prince=2fmychannel=40conference.xmpp.host'); // true + * isXmppUserId('john.doe'); // false + */ +export const isXmppUserId = (userId: string, prefix: string = XMPP_USER_ID_PREFIX): boolean => { + if (!userId.startsWith(prefix)) { + return false; + } + + try { + const { local, domain } = parseXmppUserId(decodeXmppUserId(userId.substring(prefix.length))); + return local.length > 0 && DOMAIN_REGEX.test(domain); + } catch { + return false; + } +}; diff --git a/ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts b/ee/packages/federation-matrix/src/helpers/parseXmppUserId.spec.ts new file mode 100644 index 0000000000000..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}`, + }; +}; From e0ac308d07a1bd90d3eb6a6499384f16f5e1545f Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 19:56:22 -0300 Subject: [PATCH 21/34] bump federation-sdk to 0.7.0-beta.3 --- apps/meteor/package.json | 2 +- ee/packages/federation-matrix/package.json | 2 +- packages/core-services/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index be985da51fac8..002ae9fd58145 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.7.0-beta.1", + "@rocket.chat/federation-sdk": "0.7.0-beta.3", "@rocket.chat/fuselage": "^0.79.1", "@rocket.chat/fuselage-forms": "^1.3.0", "@rocket.chat/fuselage-hooks": "^0.41.0", diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index b7f0cae137687..ae350939afe6d 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.7.0-beta.1", + "@rocket.chat/federation-sdk": "0.7.0-beta.3", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 2f36687115719..f197e5d3b5cba 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.7.0-beta.1", + "@rocket.chat/federation-sdk": "0.7.0-beta.3", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.48.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/yarn.lock b/yarn.lock index a78b886af38a4..8c8514a34fe79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9087,7 +9087,7 @@ __metadata: dependencies: "@rocket.chat/apps": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.7.0-beta.1" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.3" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.48.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9300,7 +9300,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.7.0-beta.1" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.3" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9329,9 +9329,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.7.0-beta.1": - version: 0.7.0-beta.1 - resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.1" +"@rocket.chat/federation-sdk@npm:0.7.0-beta.3": + version: 0.7.0-beta.3 + resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.3" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" @@ -9345,7 +9345,7 @@ __metadata: zod: "npm:~4.3.6" peerDependencies: typescript: ~5.9.2 - checksum: 10/63026f98940b4b58e4b26408abc65e98105e244b09d80d9a0160ccd4328cb8eba82e542fbf9140cf206b4dbe80f26d0d432c6938308c775841c93d6cae27e282 + checksum: 10/6064f95b63c2e6e94d15ce6787f3193895edaa495e64dda026df6d869507b73d8b0851121b28f60e07df3857d89bfbb9d043403dd4742ed634e4ba04b8f1fd05 languageName: node linkType: hard @@ -9932,7 +9932,7 @@ __metadata: "@rocket.chat/emitter": "npm:^0.32.0" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.7.0-beta.1" + "@rocket.chat/federation-sdk": "npm:0.7.0-beta.3" "@rocket.chat/fuselage": "npm:^0.79.1" "@rocket.chat/fuselage-forms": "npm:^1.3.0" "@rocket.chat/fuselage-hooks": "npm:^0.41.0" From 3d4392ddd60d7759ec3a7271e65072d386b822f1 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 20:24:45 -0300 Subject: [PATCH 22/34] better error logging --- .../src/api/_matrix/client/_shared.ts | 23 +++++++++ .../src/api/_matrix/client/account.ts | 2 + .../src/api/_matrix/client/directory.ts | 26 ++-------- .../src/api/_matrix/client/media.ts | 12 ++--- .../src/api/_matrix/client/profile.ts | 34 +++---------- .../src/api/_matrix/client/rooms-lifecycle.ts | 34 ++----------- .../src/api/_matrix/client/rooms-messaging.ts | 46 +++-------------- .../src/api/_matrix/client/rooms-state.ts | 51 ++++++------------- .../src/api/_matrix/client/user.ts | 9 +--- 9 files changed, 69 insertions(+), 168 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts index cdb3b19439105..83b24b3320224 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts @@ -3,8 +3,31 @@ import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import type { Context } from 'hono'; import { createMiddleware } from 'hono/factory'; +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 }, + }; +}; + // TODO: remove before merge — diagnostic catch-all logger for AS bridge integration export const catchAllClient = () => createMiddleware(async (c: Context, next) => { diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts index abb3bb9c3cd45..e4e2bcbb1542e 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -5,6 +5,7 @@ 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 = { @@ -93,6 +94,7 @@ export const addAccountRoutes = (router: ClientRouter) => { 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: { diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts b/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts index edd919d596371..bb552e3015260 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/directory.ts @@ -4,6 +4,8 @@ import { ajv } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; import { MATRIX_ROOM_ID_PATTERN, + internalError, + notImplemented, isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, @@ -77,13 +79,7 @@ export const addDirectoryRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async () => { // TODO(federation-sdk): resolveAlias(roomAlias) → {roomId, servers} - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'Room alias resolution not yet implemented', - }, - }; + return notImplemented('Room alias resolution not yet implemented'); }, ) @@ -106,13 +102,7 @@ export const addDirectoryRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async () => { // TODO(federation-sdk): createAlias(alias, roomId, sender) - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'Room alias creation not yet implemented', - }, - }; + return notImplemented('Room alias creation not yet implemented'); }, ) @@ -146,13 +136,7 @@ export const addDirectoryRoutes = (router: ClientRouter) => { }, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to list public rooms', - }, - }; + return internalError('Failed to list public rooms', error); } }, ); diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/media.ts b/ee/packages/federation-matrix/src/api/_matrix/client/media.ts index 9da2eaae10e0b..8818e521dab55 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/media.ts @@ -2,7 +2,7 @@ import { Upload } from '@rocket.chat/core-services'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; -import { isMatrixErrorProps, license, tags } from './_shared'; +import { internalError, isMatrixErrorProps, license, tags } from './_shared'; import { MatrixMediaService } from '../../../services/MatrixMediaService'; import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; @@ -122,10 +122,7 @@ export const addClientMediaRoutes = (router: ClientRouter) => { body: buffer, }; } catch (error) { - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; + return internalError('Failed to download media', error); } }, ) @@ -198,10 +195,7 @@ export const addClientMediaRoutes = (router: ClientRouter) => { body: buffer, }; } catch (error) { - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; + return internalError('Failed to generate media thumbnail', error); } }, ) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts index 6fb1212e86e41..0b181ad43fb65 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -4,6 +4,8 @@ import { ajv } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; import { + internalError, + notImplemented, isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, @@ -84,13 +86,7 @@ export const addProfileRoutes = (router: ClientRouter) => { }, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch profile', - }, - }; + return internalError('Failed to fetch profile', error, { userId }); } }, ) @@ -115,13 +111,7 @@ export const addProfileRoutes = (router: ClientRouter) => { const field = c.req.param('field'); if (!field) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch profile', - }, - }; + return internalError('Failed to fetch profile', undefined, { userId, reason: 'missing field parameter' }); } try { @@ -143,13 +133,7 @@ export const addProfileRoutes = (router: ClientRouter) => { }, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch profile', - }, - }; + return internalError('Failed to fetch profile', error, { userId, field }); } }, ) @@ -239,13 +223,7 @@ export const addProfileRoutes = (router: ClientRouter) => { } // TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'Global profile update not yet implemented', - }, - }; + 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 index 3db7275f120b1..c350d443c5847 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -9,6 +9,7 @@ import type { ClientRouter } from './_shared'; import { MATRIX_ROOM_ID_PATTERN, MATRIX_USER_ID_PATTERN, + internalError, isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, @@ -200,14 +201,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { }, }; } catch (error) { - console.error('Failed to create room', error); - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to create room', - }, - }; + return internalError('Failed to create room', error); } }, ) @@ -281,13 +275,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { }, }; } - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to leave room', - }, - }; + return internalError('Failed to leave room', error, { roomId, senderId }); } }, ) @@ -321,13 +309,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { body: {}, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to invite user', - }, - }; + return internalError('Failed to invite user', error, { roomId, senderId }); } }, ) @@ -361,13 +343,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { body: {}, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to kick user', - }, - }; + return internalError('Failed to kick user', error, { roomId, senderId }); } }, ); 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 index 835bb0eafb2b8..987783916be04 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -8,6 +8,8 @@ import type { ClientRouter } from './_shared'; import { MATRIX_ROOM_ID_PATTERN, MATRIX_USER_ID_PATTERN, + internalError, + notImplemented, isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, @@ -15,7 +17,6 @@ import { license, tags, } from './_shared'; -import { logger } from '../../logger'; import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; const SendEventParamsSchema = { @@ -150,13 +151,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { if (eventType !== 'm.room.message') { // TODO: support additional event types (m.reaction, m.room.redaction, etc.) - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'Only m.room.message is supported in v1', - }, - }; + return notImplemented('Only m.room.message is supported in v1', { eventType }); } if (typeof body.body !== 'string' || typeof body.msgtype !== 'string') { @@ -217,13 +212,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { }, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to send message', - }, - }; + return internalError('Failed to send message', error, { roomId }); } }, ) @@ -270,13 +259,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { }, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch messages', - }, - }; + return internalError('Failed to fetch messages', error, { roomId }); } }, ) @@ -338,13 +321,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { body: {}, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to send typing notification', - }, - }; + return internalError('Failed to send typing notification', error, { roomId, userId }); } }, ) @@ -400,15 +377,8 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { statusCode: 200, body: {}, }; - } catch (err) { - logger.error({ msg: 'Failed to send read receipt', roomId, senderId, err }); - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to send read receipt', - }, - }; + } catch (error) { + return internalError('Failed to send read receipt', error, { roomId, senderId }); } }, ); 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 index 5e35595d78f31..2eb05593d7d14 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts @@ -3,7 +3,16 @@ import { federationSDK } from '@rocket.chat/federation-sdk'; import { ajv } from '@rocket.chat/rest-typings'; import type { ClientRouter } from './_shared'; -import { MATRIX_ROOM_ID_PATTERN, isImpersonationQueryProps, isMatrixErrorProps, isRoomIdParamsProps, license, tags } from './_shared'; +import { + MATRIX_ROOM_ID_PATTERN, + internalError, + notImplemented, + isImpersonationQueryProps, + isMatrixErrorProps, + isRoomIdParamsProps, + license, + tags, +} from './_shared'; import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; const JoinedMembersResponseSchema = { @@ -96,13 +105,7 @@ const getRoomStateEvent = async (roomId: RoomID, eventType: string, stateKey = ' body: pe.getContent(), }; } catch (error) { - return { - statusCode: 500 as const, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch state event', - }, - }; + return internalError('Failed to fetch state event', error); } }; @@ -144,13 +147,7 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { body: { joined }, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch joined members', - }, - }; + return internalError('Failed to fetch joined members', error, { roomId }); } }, ) @@ -183,13 +180,7 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { body: events, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to fetch room state', - }, - }; + return internalError('Failed to fetch room state', error, { roomId }); } }, ) @@ -281,21 +272,9 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { } // TODO: extend SDK to send arbitrary state events - return { - statusCode: 501, - body: { - errcode: 'M_UNRECOGNIZED', - error: `State event type ${eventType} not yet implemented`, - }, - }; + return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType }); } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to send state event', - }, - }; + return internalError('Failed to send state event', error); } }, ); diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/user.ts b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts index 1122bdeb08b66..60c95103a468d 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/user.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts @@ -6,6 +6,7 @@ import type { ClientRouter } from './_shared'; import { MATRIX_ROOM_ID_PATTERN, MATRIX_USER_ID_PATTERN, + internalError, isEmptyObjectResponseProps, isImpersonationQueryProps, isMatrixErrorProps, @@ -77,13 +78,7 @@ export const addUserRoutes = (router: ClientRouter) => { body: {}, }; } catch (error) { - return { - statusCode: 500, - body: { - errcode: 'M_UNKNOWN', - error: 'Failed to update per-room displayname', - }, - }; + return internalError('Failed to update per-room displayname', error, { roomId, userId }); } }, ); From a423a173c1e45a7842fc4f410872ca67b6e1d9b5 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 22:45:59 -0300 Subject: [PATCH 23/34] add error handling to xmpp slash command --- apps/meteor/ee/server/startup/federation.ts | 27 +++++++++++++++++-- .../federation-matrix/src/FederationMatrix.ts | 13 ++++----- .../src/types/IFederationMatrixService.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 3 +++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index 5965f4a50401f..341fdea11cdca 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -8,6 +8,7 @@ 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'; @@ -96,14 +97,36 @@ export const startFederationService = async (): Promise => { slashCommands.add({ command: 'xmpp', - callback: async ({ params, message: _message, userId }: SlashCommandCallbackParams<'xmpp'>): Promise => { + 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; } - await FederationMatrixService.joinXMPPChatRoom(params.trim(), user); + 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', diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index da97862e962e3..7a563357ae43c 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1043,20 +1043,21 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ); } - async joinXMPPChatRoom(roomAlias: string, user: IUser): Promise { + async joinXMPPChatRoom(roomAlias: string, user: IUser): Promise { try { if (isUserNativeFederated(user)) { throw new Error('Federated users cannot join XMPP chat rooms'); } - const result = await federationSDK.joinXMPPChatRoom(roomAlias, userIdSchema.parse(`@${user.username}:${this.serverName}`)); - - console.log(result); + 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 }); - throw err; + this.logger.error({ msg: 'Failed to join XMPP chat room', err, username: user.username, roomAlias }); + + return false; } } diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 506730db4d4be..f951f203bf99a 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -34,6 +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; + joinXMPPChatRoom(roomAlias: string, user: IUser): Promise; saveFederationMessage(event: { event: PduForType<'m.room.message'>; event_id: EventID }): Promise; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b29ec32dc1295..097e871c7c7c1 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2317,6 +2317,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", From 40217d5870019859f4751a543b6570d78a43d6bb Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Jun 2026 20:38:30 -0300 Subject: [PATCH 24/34] fix profile displayname set --- .../src/api/_matrix/client/profile.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts index 0b181ad43fb65..afe74a90593bb 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -156,21 +156,10 @@ export const addProfileRoutes = (router: ClientRouter) => { }, isAppServiceAuthenticatedMiddleware(), async (c) => { - const userId = c.req.param('userId'); - const senderId = c.get('impersonatedUserId') as string; + const userId = c.get('impersonatedUserId') as string; const body = await c.req.json(); - if (userId !== senderId) { - return { - statusCode: 403, - body: { - errcode: 'M_FORBIDDEN', - error: "Cannot edit another user's profile", - }, - }; - } - const user = await Users.findOneByUsername(userId); if (!user) { return { From 1903ff537e0b2bf7e4012c0654ff13ccb732f163 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Jun 2026 20:38:48 -0300 Subject: [PATCH 25/34] always show the name as typing --- .../src/api/_matrix/client/rooms-messaging.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 index 987783916be04..11ab6b458c4bb 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -1,4 +1,6 @@ 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'; @@ -309,8 +311,21 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { }; } + const user = await Users.findOneByUsername>(userId, { + 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: userId, + user: user.name || user.username, isTyping: body.typing, roomId: matrixRoom._id, }); From 9b9f05af654695fefecea60894bb3c732c51267a Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Jun 2026 20:56:52 -0300 Subject: [PATCH 26/34] change variable name to better reflect its actual value --- .../src/api/_matrix/client/account.ts | 4 +-- .../src/api/_matrix/client/profile.ts | 15 +++++----- .../src/api/_matrix/client/rooms-lifecycle.ts | 28 +++++++++---------- .../src/api/_matrix/client/rooms-messaging.ts | 22 +++++++-------- .../src/api/_matrix/client/rooms-state.ts | 10 +++---- .../src/api/_matrix/client/user.ts | 19 +++++++------ .../src/api/_matrix/media-bridge.ts | 4 +-- 7 files changed, 52 insertions(+), 50 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts index e4e2bcbb1542e..dbe34cc99ea5e 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -134,11 +134,11 @@ export const addAccountRoutes = (router: ClientRouter) => { }, isAppServiceAuthenticatedMiddleware(), async (c) => { - const userId = c.get('impersonatedUserId') as string; + const username = c.get('impersonatedUserId') as string; return { statusCode: 200, body: { - user_id: userId, + user_id: username, }, }; }, diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts index afe74a90593bb..2bded27303280 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/profile.ts @@ -156,11 +156,11 @@ export const addProfileRoutes = (router: ClientRouter) => { }, isAppServiceAuthenticatedMiddleware(), async (c) => { - const userId = c.get('impersonatedUserId') as string; + const username = c.get('impersonatedUserId') as string; const body = await c.req.json(); - const user = await Users.findOneByUsername(userId); + const user = await Users.findOneByUsername(username); if (!user) { return { statusCode: 404, @@ -199,14 +199,15 @@ export const addProfileRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const userId = c.req.param('userId'); - const senderId = c.get('impersonatedUserId') as string; + const username = c.get('impersonatedUserId') as string; - if (userId !== senderId) { + const user = await Users.findOneByUsername(username); + if (!user) { return { - statusCode: 403, + statusCode: 404, body: { - errcode: 'M_FORBIDDEN', - error: "Cannot edit another user's profile", + errcode: 'M_NOT_FOUND', + error: 'User not found', }, }; } 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 index c350d443c5847..3485090ff6f50 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -143,12 +143,12 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { }, isAppServiceAuthenticatedMiddleware(), async (c) => { - const senderId = c.get('impersonatedUserId') as UserID; + const senderUsername = c.get('impersonatedUserId') as UserID; const body = await c.req.json(); const serverName = federationSDK.getConfig('serverName'); - const user = await Users.findOneByUsername(senderId, { projection: { _id: 1 } }); + const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } }); if (!user) { throw new Error('User not found for creating room'); } @@ -163,7 +163,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { const result = await federationSDK.createRoomV2({ name, alias: body.room_alias_name, - owner: senderId, + owner: senderUsername, joinRule, }); @@ -173,7 +173,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { await Room.create(user._id, { type: joinRule === 'public' ? 'c' : 'p', name, - members: [senderId], + members: [senderUsername], options: { forceNew: true, // an invite means the room does not exist yet creator: user._id, @@ -191,7 +191,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { } for (const invitee of (body.invite ?? []) as string[]) { - await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderId, body.is_direct); + await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderUsername, body.is_direct); } return { @@ -257,10 +257,10 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const senderId = c.get('impersonatedUserId') as UserID; + const senderUsername = c.get('impersonatedUserId') as UserID; try { - await federationSDK.leaveRoom(roomId, senderId); + await federationSDK.leaveRoom(roomId, senderUsername); return { statusCode: 200, body: {}, @@ -275,7 +275,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { }, }; } - return internalError('Failed to leave room', error, { roomId, senderId }); + return internalError('Failed to leave room', error, { roomId, senderId: senderUsername }); } }, ) @@ -299,17 +299,17 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const senderId = c.get('impersonatedUserId') as UserID; + const senderUsername = c.get('impersonatedUserId') as UserID; const body = await c.req.json(); try { - await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderId); + await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderUsername); return { statusCode: 200, body: {}, }; } catch (error) { - return internalError('Failed to invite user', error, { roomId, senderId }); + return internalError('Failed to invite user', error, { roomId, senderUsername }); } }, ) @@ -333,17 +333,17 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const senderId = c.get('impersonatedUserId') as UserID; + const senderUsername = c.get('impersonatedUserId') as UserID; const body = await c.req.json(); try { - await federationSDK.kickUser(roomId, body.user_id as UserID, senderId, body.reason); + 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, senderId }); + 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 index 11ab6b458c4bb..c760df7dc2bbb 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -148,7 +148,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { async (c) => { const roomId = c.req.param('roomId') as RoomID; const eventType = c.req.param('eventType'); - const senderId = c.get('impersonatedUserId') as UserID; + const senderUsername = c.get('impersonatedUserId') as UserID; const body = await c.req.json(); if (eventType !== 'm.room.message') { @@ -188,7 +188,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { url: body.url, info: body.info, }; - const event = await federationSDK.sendFileMessage(roomId, fileContent, senderId); + const event = await federationSDK.sendFileMessage(roomId, fileContent, senderUsername); await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, @@ -203,7 +203,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { }; } - const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderId); + 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 }); @@ -286,10 +286,10 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const userId = c.get('impersonatedUserId'); + const username = c.get('impersonatedUserId'); const body = await c.req.json(); - if (!userId) { + if (!username) { return { statusCode: 400, body: { @@ -311,7 +311,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { }; } - const user = await Users.findOneByUsername>(userId, { + const user = await Users.findOneByUsername>(username, { projection: { name: 1, username: 1, federated: 1, federation: 1 }, }); if (!user || !isUserNativeFederated(user)) { @@ -330,13 +330,13 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { roomId: matrixRoom._id, }); - await federationSDK.sendTypingNotification(roomId, userId, body.typing === true); + await federationSDK.sendTypingNotification(roomId, username, body.typing === true); return { statusCode: 200, body: {}, }; } catch (error) { - return internalError('Failed to send typing notification', error, { roomId, userId }); + return internalError('Failed to send typing notification', error, { roomId, userId: username }); } }, ) @@ -361,10 +361,10 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const senderId = c.get('impersonatedUserId') as string; + const senderUsername = c.get('impersonatedUserId') as string; try { - const matrixUser = await Users.findOneByUsername(senderId); + const matrixUser = await Users.findOneByUsername(senderUsername); if (!matrixUser) { return { statusCode: 404, @@ -393,7 +393,7 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { body: {}, }; } catch (error) { - return internalError('Failed to send read receipt', error, { roomId, senderId }); + 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 index 2eb05593d7d14..bc632f81f8ea5 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-state.ts @@ -252,19 +252,19 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { async (c) => { const roomId = c.req.param('roomId') as RoomID; const eventType = c.req.param('eventType'); - const senderId = c.get('impersonatedUserId') as UserID; + 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, senderId); + 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, senderId, body.topic); + await federationSDK.setRoomTopic(roomId, senderUsername, body.topic); return { statusCode: 200, body: { event_id: '' }, @@ -272,9 +272,9 @@ export const addRoomsStateRoutes = (router: ClientRouter) => { } // TODO: extend SDK to send arbitrary state events - return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType }); + return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType, senderUsername }); } catch (error) { - return internalError('Failed to send state event', 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 index 60c95103a468d..dcbc910b4f6c3 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/user.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/user.ts @@ -1,5 +1,6 @@ -import type { RoomID, UserID } from '@rocket.chat/federation-sdk'; +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'; @@ -55,22 +56,22 @@ export const addUserRoutes = (router: ClientRouter) => { isAppServiceAuthenticatedMiddleware(), async (c) => { const roomId = c.req.param('roomId') as RoomID; - const userId = c.req.param('userId') as UserID; - const senderId = c.get('impersonatedUserId') as string; + const senderUsername = c.get('impersonatedUserId') as string; const body = await c.req.json(); - if (userId !== senderId) { + const user = await Users.findOneByUsername(senderUsername); + if (!user) { return { - statusCode: 403, + statusCode: 404, body: { - errcode: 'M_FORBIDDEN', - error: "Cannot edit another user's per-room profile", + errcode: 'M_NOT_FOUND', + error: 'User not found', }, }; } try { - await federationSDK.updateUserProfile(roomId, userId, { + await federationSDK.updateUserProfile(roomId, senderUsername, { displayname: body.displayname ?? undefined, }); return { @@ -78,7 +79,7 @@ export const addUserRoutes = (router: ClientRouter) => { body: {}, }; } catch (error) { - return internalError('Failed to update per-room displayname', error, { roomId, userId }); + return internalError('Failed to update per-room displayname', error, { roomId, senderUsername }); } }, ); diff --git a/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts index c868fc9e7c8b7..ff31b2de6f26f 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media-bridge.ts @@ -76,11 +76,11 @@ export const getMatrixMediaBridgeRoutes = () => { isAppServiceAuthenticatedMiddleware(), async (c) => { try { - const senderId = c.get('impersonatedUserId') as string; + 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(senderId, { projection: { _id: 1 } }); + const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } }); if (!user) { return { statusCode: 401, From ee84a7fab4eb9719545cd0e27dd8710ce6bf8d7d Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 25 Jun 2026 15:47:27 -0300 Subject: [PATCH 27/34] cleanup not used code --- .../src/api/_matrix/client/rooms-lifecycle.ts | 2 - .../src/helpers/isXmppUserId.spec.ts | 45 ------------------- .../src/helpers/isXmppUserId.ts | 36 --------------- .../src/services/MatrixMediaService.ts | 9 ---- 4 files changed, 92 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts delete mode 100644 ee/packages/federation-matrix/src/helpers/isXmppUserId.ts 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 index 3485090ff6f50..b3a218294af58 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -223,8 +223,6 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { }, isAppServiceAuthenticatedMiddleware(), async (c) => { - console.log('join ->', c.req.param('roomIdOrAlias'), c.req.query(), c.get('impersonatedUserId')); - // TODO need to first invite and then join? await federationSDK.joinUser(c.req.param('roomIdOrAlias'), c.get('impersonatedUserId')); diff --git a/ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts b/ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts deleted file mode 100644 index b4e3ffdc2665d..0000000000000 --- a/ee/packages/federation-matrix/src/helpers/isXmppUserId.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { isXmppUserId } from './isXmppUserId'; - -describe('isXmppUserId', () => { - it('should accept a prefixed MUC occupant id', () => { - expect(isXmppUserId('_xmpp_prince=2fmychannel=40conference.xmpp.host')).toBe(true); - }); - - it('should accept a prefixed bare JID with no resource', () => { - expect(isXmppUserId('_xmpp_alice=40xmpp.host')).toBe(true); - }); - - it('should reject a value without the bridge prefix', () => { - expect(isXmppUserId('prince=2fmychannel=40conference.xmpp.host')).toBe(false); - }); - - it('should reject a normal username', () => { - expect(isXmppUserId('john.doe')).toBe(false); - }); - - it('should reject a prefixed value that is not a JID (no domain)', () => { - expect(isXmppUserId('_xmpp_justaname')).toBe(false); - }); - - it('should reject the bare prefix', () => { - expect(isXmppUserId('_xmpp_')).toBe(false); - }); - - it('should reject a prefixed value with an empty local part', () => { - expect(isXmppUserId('_xmpp_=40xmpp.host')).toBe(false); - }); - - it('should reject a prefixed value with an invalid domain', () => { - expect(isXmppUserId('_xmpp_alice=40not_a_domain')).toBe(false); - }); - - it('should honour a custom prefix', () => { - expect(isXmppUserId('_bifrost_alice=40xmpp.host', '_bifrost_')).toBe(true); - expect(isXmppUserId('_xmpp_alice=40xmpp.host', '_bifrost_')).toBe(false); - }); - - it('should validate a bare escaped JID when prefix is empty', () => { - expect(isXmppUserId('alice=40xmpp.host', '')).toBe(true); - expect(isXmppUserId('justaname', '')).toBe(false); - }); -}); diff --git a/ee/packages/federation-matrix/src/helpers/isXmppUserId.ts b/ee/packages/federation-matrix/src/helpers/isXmppUserId.ts deleted file mode 100644 index f84df4d24c414..0000000000000 --- a/ee/packages/federation-matrix/src/helpers/isXmppUserId.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { decodeXmppUserId, parseXmppUserId } from './parseXmppUserId'; - -/** Default matrix-bifrost user prefix for the XMPP protocol on this deployment. */ -export const XMPP_USER_ID_PREFIX = '_xmpp_'; - -// Hostname grammar (RFC 1123 labels), same shape used by validateFederatedUsername. -const DOMAIN_REGEX = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; - -/** - * Check whether a Matrix localpart (e.g. `body.username` from an AS `/register` - * call) is a bridged XMPP user, so the caller can decide whether to run - * {@link parseXmppUserId} on it. - * - * A value qualifies when it carries the bridge prefix **and** the remainder - * decodes to a well-formed `local@domain` JID. The structural check guards - * against a normal user that merely happens to start with the prefix. - * - * @param userId - the localpart to test, e.g. `_xmpp_prince=2fmychannel=40conference.xmpp.host` - * @param prefix - bridge prefix to require; pass `''` to validate a bare escaped JID - * - * @example - * isXmppUserId('_xmpp_prince=2fmychannel=40conference.xmpp.host'); // true - * isXmppUserId('john.doe'); // false - */ -export const isXmppUserId = (userId: string, prefix: string = XMPP_USER_ID_PREFIX): boolean => { - if (!userId.startsWith(prefix)) { - return false; - } - - try { - const { local, domain } = parseXmppUserId(decodeXmppUserId(userId.substring(prefix.length))); - return local.length > 0 && DOMAIN_REGEX.test(domain); - } catch { - return false; - } -}; diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 9aba8605105f2..4d9691ffdd372 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -9,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}`; From 30e58ad24107e25fd4535e5fde9710bf1d3056e4 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 25 Jun 2026 15:47:41 -0300 Subject: [PATCH 28/34] remove catchall route --- .../src/api/_matrix/client/_shared.ts | 18 ------------------ .../src/api/_matrix/client/index.ts | 3 +-- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts index 83b24b3320224..05f3ebdb600aa 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts @@ -1,7 +1,5 @@ import type { Router } from '@rocket.chat/http-router'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; -import type { Context } from 'hono'; -import { createMiddleware } from 'hono/factory'; import { logger } from '../../logger'; @@ -28,22 +26,6 @@ export const notImplemented = (msg: string, context?: Record) = }; }; -// TODO: remove before merge — diagnostic catch-all logger for AS bridge integration -export const catchAllClient = () => - createMiddleware(async (c: Context, next) => { - try { - const { method } = c.req; - const url = new URL(c.req.url); - const path = url.pathname + url.search; - - console.log(`Received request: ${method} ${path}`, c.req.header()); - - return next(); - } catch (error) { - return c.json({ error: 'Internal Server Error' }, 500); - } - }); - export const tags = ['Federation']; export const license: ['federation'] = ['federation']; diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/index.ts b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts index efaba5e39c550..a75e59704f116 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/index.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/index.ts @@ -1,6 +1,5 @@ import { Router } from '@rocket.chat/http-router'; -import { catchAllClient } from './_shared'; import { addAccountRoutes } from './account'; import { addDirectoryRoutes } from './directory'; import { addClientMediaRoutes } from './media'; @@ -13,7 +12,7 @@ import { addUserRoutes } from './user'; import { addVersionsRoutes } from './versions'; export const getClientRoutes = () => { - const router = new Router('/client').use(catchAllClient()); + const router = new Router('/client'); addVersionsRoutes(router); addAccountRoutes(router); From a621ad105f53cfa11d1073f33a913d91ebc779b0 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 25 Jun 2026 15:48:03 -0300 Subject: [PATCH 29/34] better error return --- .../federation-matrix/src/api/_matrix/client/account.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts index dbe34cc99ea5e..28b4b8e926139 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/account.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/account.ts @@ -98,8 +98,8 @@ export const addAccountRoutes = (router: ClientRouter) => { return { statusCode: 400, body: { - errcode: '', - error: '', + errcode: 'M_INVALID_USERNAME', + error: 'Could not derive a username from the provided XMPP user id', }, }; } @@ -108,7 +108,6 @@ export const addAccountRoutes = (router: ClientRouter) => { await createOrUpdateFederatedUser({ username, - // name: decodedUsername.resource, origin: serverName, }); From d611e6f2282896b0a2cb8c70c32fcdf003207a07 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 25 Jun 2026 20:13:13 -0300 Subject: [PATCH 30/34] add support to admin/bridge room --- .../src/api/_matrix/client/rooms-lifecycle.ts | 19 ++++++++++++------- .../federation-matrix/src/events/member.ts | 3 ++- .../src/helpers/getFederatedRoomName.spec.ts | 19 +++++++++++++++++++ .../src/helpers/getFederatedRoomName.ts | 6 ++++++ 4 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 ee/packages/federation-matrix/src/helpers/getFederatedRoomName.spec.ts create mode 100644 ee/packages/federation-matrix/src/helpers/getFederatedRoomName.ts 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 index b3a218294af58..7a366b5cd9671 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-lifecycle.ts @@ -17,6 +17,7 @@ import { license, tags, } from './_shared'; +import { getFederatedRoomName } from '../../../helpers/getFederatedRoomName'; import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated'; const CreateRoomBodySchema = { @@ -153,15 +154,17 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { throw new Error('User not found for creating room'); } - const name = body.name || body.room_alias_name || ''; + // 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' : 'private'; + 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, + name: displayName, alias: body.room_alias_name, owner: senderUsername, joinRule, @@ -170,6 +173,11 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { // 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, @@ -185,7 +193,7 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { mrid: result.room_id, origin: serverName, }, - fname: name, + fname: displayName || name, }, }); } @@ -223,11 +231,8 @@ export const addRoomsLifecycleRoutes = (router: ClientRouter) => { }, isAppServiceAuthenticatedMiddleware(), async (c) => { - // TODO need to first invite and then join? - await federationSDK.joinUser(c.req.param('roomIdOrAlias'), c.get('impersonatedUserId')); - // TODO(federation-sdk): joinRoom(userId, roomIdOrAlias) — needs alias resolution + invite-less join return { statusCode: 200, body: {}, diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 2961f84b60679..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'; @@ -235,7 +236,7 @@ async function handleInvite({ roomName = senderId; roomFName = senderId; } else { - roomName = roomId.replace('!', '').replace(':', '_'); + roomName = getFederatedRoomName(roomId); roomFName = `${matrixRoomName}:${roomOriginDomain}`; } 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(':', '_'); From aebff5189ce4bb68f0707f04964bbfa10f20f087 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 25 Jun 2026 20:13:35 -0300 Subject: [PATCH 31/34] update settings' descriptions --- apps/meteor/server/settings/federation-service.ts | 4 ++-- packages/i18n/src/locales/en.i18n.json | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/meteor/server/settings/federation-service.ts b/apps/meteor/server/settings/federation-service.ts index 86b2a2cd53960..4af5477ad13b2 100644 --- a/apps/meteor/server/settings/federation-service.ts +++ b/apps/meteor/server/settings/federation-service.ts @@ -136,7 +136,7 @@ export const createFederationServiceSettings = async (): Promise => { }); await this.add('Federation_XMPP_Bridge_HS_Token', '', { - type: 'string', + type: 'password', enterprise: true, modules: ['federation'], invalidValue: '', @@ -144,7 +144,7 @@ export const createFederationServiceSettings = async (): Promise => { }); await this.add('Federation_XMPP_Bridge_AS_Token', '', { - type: 'string', + type: 'password', enterprise: true, modules: ['federation'], invalidValue: '', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 097e871c7c7c1..32a3df937e187 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", @@ -7285,4 +7291,4 @@ "Avatar_preview_updated": "Avatar preview updated", "Select_message_from_user": "Select message from {{username}}", "Select_message_from_user_with_preview": "Select message from {{username}}: {{message}}" -} \ No newline at end of file +} From 925f9afdcfbcc74c6106e62fea5c2f2870185447 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 26 Jun 2026 17:56:56 -0300 Subject: [PATCH 32/34] add support to ping events --- .../src/api/_matrix/client/rooms-messaging.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index c760df7dc2bbb..a8c51c1c36a16 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/client/rooms-messaging.ts @@ -151,6 +151,17 @@ export const addRoomsMessagingRoutes = (router: ClientRouter) => { 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 }); From a5402340b3c383d7591543ae71006a51a571062a Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 26 Jun 2026 18:01:48 -0300 Subject: [PATCH 33/34] bump federation-sdk to 0.7.0-beta.4 --- apps/meteor/package.json | 2 +- ee/packages/federation-matrix/package.json | 2 +- packages/core-services/package.json | 2 +- yarn.lock | 34 +++++----------------- 4 files changed, 11 insertions(+), 29 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 002ae9fd58145..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.7.0-beta.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/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index ae350939afe6d..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.7.0-beta.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/packages/core-services/package.json b/packages/core-services/package.json index f197e5d3b5cba..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.7.0-beta.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/yarn.lock b/yarn.lock index 8c8514a34fe79..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.7.0-beta.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.7.0-beta.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.7.0-beta.3": - version: 0.7.0-beta.3 - resolution: "@rocket.chat/federation-sdk@npm:0.7.0-beta.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" @@ -9345,7 +9344,7 @@ __metadata: zod: "npm:~4.3.6" peerDependencies: typescript: ~5.9.2 - checksum: 10/6064f95b63c2e6e94d15ce6787f3193895edaa495e64dda026df6d869507b73d8b0851121b28f60e07df3857d89bfbb9d043403dd4742ed634e4ba04b8f1fd05 + checksum: 10/ce59b495003812375d5eef0ca5701044599697f0f8bb809027829f97026cac66aeb0c17bf16c78037543c98704680ace7b7232b85467d144e72de01ffcdd8d70 languageName: node linkType: hard @@ -9932,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.7.0-beta.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" @@ -10650,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" @@ -11428,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" From daf39dd27ac9575a96702c24fc1b6ddc253861f8 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 26 Jun 2026 18:31:19 -0300 Subject: [PATCH 34/34] fix i18n --- packages/i18n/src/locales/en.i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 32a3df937e187..2e3cc22a0c5c2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7291,4 +7291,4 @@ "Avatar_preview_updated": "Avatar preview updated", "Select_message_from_user": "Select message from {{username}}", "Select_message_from_user_with_preview": "Select message from {{username}}: {{message}}" -} +} \ No newline at end of file