diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index 7138e187d404d..74f3ee477bcb8 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -11,7 +11,6 @@ import { metricsMiddleware } from './middlewares/metrics'; import { remoteAddressMiddleware } from './middlewares/remoteAddressMiddleware'; import { tracerSpanMiddleware } from './middlewares/tracer'; import { type APIActionHandler, RocketChatAPIRouter } from './router'; -import { isRunningMs } from '../../../server/lib/isRunningMs'; import { metrics } from '../../metrics/server'; import { settings } from '../../settings/server'; @@ -107,14 +106,6 @@ settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => }); export const startRestAPI = () => { - // Register federation routes at root level if enabled and not running in MS mode - if (settings.get('Federation_Service_Enabled') && !isRunningMs()) { - (WebApp.rawConnectHandlers as unknown as ReturnType) - .use(API._matrix.router) - .use(API.wellKnown.router) - .use(API.matrixInternal.router); - } - // Register main API routes under /api prefix (WebApp.rawConnectHandlers as unknown as ReturnType).use( API.api diff --git a/apps/meteor/ee/server/api/federation.ts b/apps/meteor/ee/server/api/federation.ts index 8a9ca6803669e..278282ed0ec1b 100644 --- a/apps/meteor/ee/server/api/federation.ts +++ b/apps/meteor/ee/server/api/federation.ts @@ -1,18 +1,10 @@ import type { IFederationMatrixService } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; +import type express from 'express'; +import { WebApp } from 'meteor/webapp'; -import { API } from '../../../app/api/server'; import { isRunningMs } from '../../../server/lib/isRunningMs'; -interface IExtendedContext { - urlParams?: Record; - queryParams?: Record; - bodyParams?: Record; - request?: Request; - _statusCode?: number; - _headers?: Record; -} - const logger = new Logger('FederationRoutes'); export async function registerFederationRoutes(federationService: IFederationMatrixService): Promise { @@ -22,118 +14,9 @@ export async function registerFederationRoutes(federationService: IFederationMat try { const routes = federationService.getAllRoutes(); + (WebApp.rawConnectHandlers as unknown as ReturnType).use(routes.matrix.router).use(routes.wellKnown.router); - for (const route of routes) { - const method = route.method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch'; - - let router: any; - if (route.path.startsWith('/_matrix')) { - router = API._matrix; - } else if (route.path.startsWith('/.well-known')) { - router = API.wellKnown; - } else if (route.path.startsWith('/internal')) { - router = API.matrixInternal; - } else { - logger.error(`Unknown route prefix for path: ${route.path}`); - continue; - } - - if (method === 'patch') { - if (typeof (router as any).method === 'function') { - const routePath = route.path.replace(/^\/_matrix|^\/\.well-known|^\/internal/, ''); - (router as any).method('PATCH', routePath || '/', { response: {} }, async function (this: IExtendedContext) { - try { - const context = { - params: this.urlParams || {}, - query: this.queryParams || {}, - body: this.bodyParams || {}, - headers: this.request?.headers ? Object.fromEntries(this.request.headers.entries()) : {}, - setStatus: (code: number) => { - this._statusCode = code; - }, - setHeader: (key: string, value: string) => { - if (!this._headers) { - this._headers = {}; - } - this._headers[key] = value; - }, - }; - - const response = await route.handler(context); - - const result: any = { - statusCode: this._statusCode || 200, - body: response, - }; - - if (this._headers) { - result.headers = this._headers; - } - - return result; - } catch (error) { - logger.error(`Error handling route: ${route.path}`, error); - return { - statusCode: 500, - body: { error: 'Internal server error' }, - }; - } - }); - continue; - } else { - logger.error(`Cannot register PATCH method for route ${route.path} - method() function not available`); - continue; - } - } - - if (typeof (router as any)[method] !== 'function') { - logger.error(`Method ${method} not found on router for path: ${route.path}`); - continue; - } - - const routePath = route.path.replace(/^\/_matrix|^\/\.well-known|^\/internal/, ''); - - (router as any)[method](routePath || '/', { response: {} }, async function (this: IExtendedContext) { - try { - const context = { - params: this.urlParams || {}, - query: this.queryParams || {}, - body: this.bodyParams || {}, - headers: this.request?.headers ? Object.fromEntries(this.request.headers.entries()) : {}, - setStatus: (code: number) => { - this._statusCode = code; - }, - setHeader: (key: string, value: string) => { - if (!this._headers) { - this._headers = {}; - } - this._headers[key] = value; - }, - }; - - const response = await route.handler(context); - - const result: any = { - statusCode: this._statusCode || 200, - body: response, - }; - - if (this._headers) { - result.headers = this._headers; - } - - return result; - } catch (error) { - logger.error(`Error handling route: ${route.path}`, error); - return { - statusCode: 500, - body: { error: 'Internal server error' }, - }; - } - }); - } - - logger.log('[Federation] Registered', routes.length, 'federation routes'); + logger.log('[Federation] Registered federation routes'); } catch (error) { logger.error('[Federation] Failed to register routes:', error); throw error; diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index 180dcd4cf464c..46e69b868dff9 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -1,37 +1,12 @@ import 'reflect-metadata'; import { serve } from '@hono/node-server'; import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; -// import type { RouteDefinition, RouteContext } from '@hs/federation-sdk'; import { registerServiceModels } from '@rocket.chat/models'; import { startBroker } from '@rocket.chat/network-broker'; import { Hono } from 'hono'; import { config } from './config'; -// export function handleFederationRoutesRegistration(app: Hono, homeserverRoutes: RouteDefinition[]): Hono { -// // console.info(`Registering ${homeserverRoutes.length} homeserver routes`); -// // for (const route of homeserverRoutes) { -// // const method = route.method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'; -// // app[method](route.path, async (c) => { -// // try { -// // const context = { -// // req: c.req, -// // res: c.res, -// // params: c.req.param(), -// // query: c.req.query(), -// // body: await c.req.json().catch(() => ({})), -// // }; -// // const result = await route.handler(context as unknown as RouteContext); -// // return c.json(result); -// // } catch (error) { -// // console.error(`Error handling route ${method.toUpperCase()} ${route.path}:`, error); -// // return c.json({ error: 'Internal server error' }, 500); -// // } -// // }); -// // } -// // return app; -// } - function handleHealthCheck(app: Hono) { app.get('/health', async (c) => { try { @@ -56,7 +31,11 @@ function handleHealthCheck(app: Hono) { api.registerService(federationMatrix); const app = new Hono(); - // handleFederationRoutesRegistration(app, federationMatrix.getAllRoutes()); + const { matrix, wellKnown } = federationMatrix.getAllRoutes(); + + app.mount('/_matrix', matrix.getHonoRouter().fetch); + app.mount('/.well-known', wellKnown.getHonoRouter().fetch); + handleHealthCheck(app); serve({ diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index f7be2f5e6c2ee..89327fe46a086 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -7,9 +7,7 @@ "@babel/core": "~7.26.0", "@babel/preset-env": "~7.26.0", "@babel/preset-typescript": "~7.26.0", - "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/rest-typings": "workspace:^", "@types/node": "~22.14.0", "babel-jest": "~30.0.0", "eslint": "~8.45.0", @@ -38,8 +36,10 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", + "@rocket.chat/http-router": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/network-broker": "workspace:^", + "@rocket.chat/rest-typings": "workspace:^", "mongodb": "6.10.0", "pino": "8.21.0", "reflect-metadata": "^0.2.2" diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 7aacaf32ac30b..17ebf9fece781 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -8,6 +8,7 @@ import { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; import { MatrixBridgedUser, MatrixBridgedRoom, Users } from '@rocket.chat/models'; +import { getAllMatrixRoutes } from './api/api'; import { registerEvents } from './events'; import { setup } from './setupContainers'; @@ -62,7 +63,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } getAllRoutes() { - return []; + return getAllMatrixRoutes(); } async createRoom(room: IRoom, owner: IUser, members: string[]): Promise { diff --git a/ee/packages/federation-matrix/src/api/.well-known/server.ts b/ee/packages/federation-matrix/src/api/.well-known/server.ts new file mode 100644 index 0000000000000..1deef06292e87 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/.well-known/server.ts @@ -0,0 +1,43 @@ +import type { Router } from "@rocket.chat/http-router"; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { getAllServicesFromFederationSDK } from '../../setupContainers'; +import { createHash } from 'node:crypto'; + +const WellKnownServerResponseSchema = { + type: 'object', + properties: { + 'm.server': { + type: 'string', + description: 'Matrix server address with port' + } + }, + required: ['m.server'] +}; + +const isWellKnownServerResponseProps = ajv.compile(WellKnownServerResponseSchema); + +export const getWellKnownRoutes = (router: Router<'/.well-known'>) => { + const { wellKnown } = getAllServicesFromFederationSDK(); + + return router.get('/matrix/server', { + response: { + 200: isWellKnownServerResponseProps + }, + tags: ['Well-Known'], + license: ['federation'] + }, async (c) => { + const responseData = wellKnown.getWellKnownHostData(); + + const etag = createHash('md5') + .update(JSON.stringify(responseData)) + .digest('hex'); + + c.header('ETag', etag); + c.header('Content-Type', 'application/json'); + + return { + body: responseData, + statusCode: 200, + }; + }); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts new file mode 100644 index 0000000000000..df4438bebd91a --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -0,0 +1,156 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { getAllServicesFromFederationSDK } from '../../setupContainers'; + +const EventBaseSchema = { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Event type', + }, + content: { + type: 'object', + description: 'Event content', + }, + sender: { + type: 'string', + }, + room_id: { + type: 'string', + }, + origin_server_ts: { + type: 'number', + }, + depth: { + type: 'number', + }, + prev_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Previous events in the room', + }, + auth_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Authorization events', + }, + origin: { + type: 'string', + description: 'Origin server', + }, + hashes: { + type: 'object', + nullable: true, + }, + signatures: { + type: 'object', + nullable: true, + }, + unsigned: { + type: 'object', + description: 'Unsigned data', + nullable: true, + }, + }, + required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events', 'origin'], +}; + +const MembershipEventContentSchema = { + type: 'object', + properties: { + membership: { + type: 'string', + }, + displayname: { + type: 'string', + nullable: true, + }, + avatar_url: { + type: 'string', + nullable: true, + }, + }, + required: ['membership'], +}; + +const RoomMemberEventSchema = { + type: 'object', + allOf: [ + EventBaseSchema, + { + type: 'object', + properties: { + type: { + type: 'string', + const: 'm.room.member', + }, + content: MembershipEventContentSchema, + state_key: { + type: 'string', + }, + }, + required: ['type', 'content', 'state_key'], + }, + ], +}; + +const isProcessInviteBodyProps = ajv.compile(RoomMemberEventSchema); + +const ProcessInviteParamsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + eventId: { + type: 'string', + }, + }, + required: ['roomId', 'eventId'], +}; + +const isProcessInviteParamsProps = ajv.compile(ProcessInviteParamsSchema); + +const ProcessInviteResponseSchema = { + type: 'object', + properties: { + event: RoomMemberEventSchema, + }, + required: ['event'], +}; + +const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); + +export const getMatrixInviteRoutes = (router: Router<'/_matrix'>) => { + const { invite } = getAllServicesFromFederationSDK(); + + return router.put( + '/federation/v2/invite/:roomId/:eventId', + { + body: isProcessInviteBodyProps, + params: isProcessInviteParamsProps, + response: { + 200: isProcessInviteResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { roomId, eventId } = c.req.param(); + const body = await c.req.json(); + + const response = await invite.processInvite(body, roomId, eventId); + + return { + body: response, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/key/server.ts b/ee/packages/federation-matrix/src/api/_matrix/key/server.ts new file mode 100644 index 0000000000000..af5c76de2e951 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/key/server.ts @@ -0,0 +1,57 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { getAllServicesFromFederationSDK } from '../../../setupContainers'; + +const ServerKeyResponseSchema = { + type: 'object', + properties: { + old_verify_keys: { + type: 'object', + description: 'Old verification keys', + }, + server_name: { + type: 'string', + description: 'Matrix server name', + }, + signatures: { + type: 'object', + description: 'Server signatures', + }, + valid_until_ts: { + type: 'number', + minimum: 0, + description: 'Unix timestamp in milliseconds', + }, + verify_keys: { + type: 'object', + description: 'Current verification keys', + }, + }, + required: ['old_verify_keys', 'server_name', 'signatures', 'valid_until_ts', 'verify_keys'], +}; + +const isServerKeyResponseProps = ajv.compile(ServerKeyResponseSchema); + +export const getKeyServerRoutes = (router: Router<'/_matrix'>) => { + const { server } = getAllServicesFromFederationSDK(); + + return router.get( + '/key/v2/server', + { + response: { + 200: isServerKeyResponseProps, + }, + tags: ['Key'], + license: ['federation'], + }, + async () => { + const response = await server.getSignedServerKey(); + + return { + body: response, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts new file mode 100644 index 0000000000000..400d412c59185 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts @@ -0,0 +1,484 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { getAllServicesFromFederationSDK } from '../../setupContainers'; + +const UsernameSchema = { + type: 'string', + pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', + description: 'Matrix user ID in format @user:server.com', +}; + +const RoomIdSchema = { + type: 'string', + pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$', + description: 'Matrix room ID in format !room:server.com', +}; + +const TimestampSchema = { + type: 'number', + minimum: 0, + description: 'Unix timestamp in milliseconds', +}; + +const ServerNameSchema = { + type: 'string', + description: 'Matrix server name', +}; + +const QueryProfileQuerySchema = { + type: 'object', + properties: { + user_id: UsernameSchema, + }, + required: ['user_id'], + additionalProperties: false, +}; + +const isQueryProfileQueryProps = ajv.compile(QueryProfileQuerySchema); + +const QueryProfileResponseSchema = { + type: 'object', + properties: { + displayname: { + type: 'string', + description: 'User display name', + nullable: true, + }, + avatar_url: { + type: 'string', + description: 'User avatar URL', + nullable: true, + }, + }, +}; + +const isQueryProfileResponseProps = ajv.compile(QueryProfileResponseSchema); + +const QueryKeysBodySchema = { + type: 'object', + properties: { + device_keys: { + type: 'object', + description: 'Device keys to query', + }, + }, + required: ['device_keys'], +}; + +const isQueryKeysBodyProps = ajv.compile(QueryKeysBodySchema); + +const QueryKeysResponseSchema = { + type: 'object', + properties: { + device_keys: { + type: 'object', + description: 'Device keys for the requested users', + }, + }, + required: ['device_keys'], +}; + +const isQueryKeysResponseProps = ajv.compile(QueryKeysResponseSchema); + +const GetDevicesParamsSchema = { + type: 'object', + properties: { + userId: UsernameSchema, + }, + required: ['userId'], + additionalProperties: false, +}; + +const isGetDevicesParamsProps = ajv.compile(GetDevicesParamsSchema); + +const GetDevicesResponseSchema = { + type: 'object', + properties: { + user_id: UsernameSchema, + stream_id: { + type: 'number', + description: 'Device list stream ID', + }, + devices: { + type: 'array', + items: { + type: 'object', + properties: { + device_id: { + type: 'string', + description: 'Device ID', + }, + display_name: { + type: 'string', + description: 'Device display name', + nullable: true, + }, + last_seen_ip: { + type: 'string', + description: 'Last seen IP address', + nullable: true, + }, + last_seen_ts: { + ...TimestampSchema, + nullable: true, + }, + }, + required: ['device_id'], + }, + description: 'List of devices for the user', + }, + }, + required: ['user_id', 'stream_id', 'devices'], +}; + +const isGetDevicesResponseProps = ajv.compile(GetDevicesResponseSchema); + +const MakeJoinParamsSchema = { + type: 'object', + properties: { + roomId: RoomIdSchema, + userId: UsernameSchema, + }, + required: ['roomId', 'userId'], +}; + +const isMakeJoinParamsProps = ajv.compile(MakeJoinParamsSchema); + +const MakeJoinQuerySchema = { + type: 'object', + properties: { + ver: { + type: 'array', + items: { + type: 'string', + }, + minItems: 0, + description: 'Supported room versions', + }, + }, +}; + +const isMakeJoinQueryProps = ajv.compile(MakeJoinQuerySchema); + +const MakeJoinResponseSchema = { + type: 'object', + properties: { + room_version: { + type: 'string', + description: 'Room version', + }, + event: { + type: 'object', + properties: { + content: { + type: 'object', + properties: { + membership: { + type: 'string', + const: 'join', + }, + join_authorised_via_users_server: { + type: 'string', + nullable: true, + }, + }, + required: ['membership'], + }, + room_id: RoomIdSchema, + sender: UsernameSchema, + state_key: UsernameSchema, + type: { + type: 'string', + const: 'm.room.member', + }, + origin_server_ts: TimestampSchema, + origin: ServerNameSchema, + depth: { + type: 'number', + description: 'Depth of the event in the DAG', + nullable: true, + }, + prev_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Previous events in the room', + nullable: true, + }, + auth_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Authorization events', + nullable: true, + }, + hashes: { + type: 'object', + properties: { + sha256: { + type: 'string', + description: 'SHA256 hash of the event', + }, + }, + required: ['sha256'], + nullable: true, + }, + signatures: { + type: 'object', + description: 'Event signatures by server and key ID', + nullable: true, + }, + unsigned: { + type: 'object', + description: 'Unsigned data', + nullable: true, + }, + }, + required: ['content', 'room_id', 'sender', 'state_key', 'type', 'origin_server_ts', 'origin'], + }, + }, + required: ['room_version', 'event'], +}; + +const isMakeJoinResponseProps = ajv.compile(MakeJoinResponseSchema); + +const GetMissingEventsParamsSchema = { + type: 'object', + properties: { + roomId: RoomIdSchema, + }, + required: ['roomId'], +}; + +const isGetMissingEventsParamsProps = ajv.compile(GetMissingEventsParamsSchema); + +const GetMissingEventsBodySchema = { + type: 'object', + properties: { + earliest_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Earliest events', + }, + latest_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Latest events', + }, + limit: { + type: 'number', + minimum: 1, + maximum: 100, + description: 'Maximum number of events to return', + }, + }, + required: ['earliest_events', 'latest_events', 'limit'], +}; + +const isGetMissingEventsBodyProps = ajv.compile(GetMissingEventsBodySchema); + +const GetMissingEventsResponseSchema = { + type: 'object', + properties: { + events: { + type: 'array', + items: { + type: 'object', + }, + description: 'Missing events', + }, + }, + required: ['events'], +}; + +const isGetMissingEventsResponseProps = ajv.compile(GetMissingEventsResponseSchema); + +const EventAuthParamsSchema = { + type: 'object', + properties: { + roomId: RoomIdSchema, + eventId: { + type: 'string', + description: 'Event ID', + }, + }, + required: ['roomId', 'eventId'], +}; + +const isEventAuthParamsProps = ajv.compile(EventAuthParamsSchema); + +const EventAuthResponseSchema = { + type: 'object', + properties: { + auth_chain: { + type: 'array', + items: { + type: 'object', + }, + description: 'Authorization chain for the event', + }, + }, + required: ['auth_chain'], +}; + +const isEventAuthResponseProps = ajv.compile(EventAuthResponseSchema); + +export const getMatrixProfilesRoutes = (router: Router<'/_matrix'>) => { + const { profile } = getAllServicesFromFederationSDK(); + + return router + .get( + '/federation/v1/query/profile', + { + query: isQueryProfileQueryProps, + response: { + 200: isQueryProfileResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { user_id: userId } = c.req.query(); + + const response = await profile.queryProfile(userId); + + return { + body: response, + statusCode: 200, + }; + }, + ) + .post( + '/federation/v1/user/keys/query', + { + body: isQueryKeysBodyProps, + response: { + 200: isQueryKeysResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const response = await profile.queryKeys(body.device_keys); + + return { + body: response, + statusCode: 200, + }; + }, + ) + .get( + '/federation/v1/user/devices/:userId', + { + params: isGetDevicesParamsProps, + response: { + 200: isGetDevicesResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { userId } = c.req.param(); + + const response = await profile.getDevices(userId); + + return { + body: response, + statusCode: 200, + }; + }, + ) + .get( + '/federation/v1/make_join/:roomId/:userId', + { + params: isMakeJoinParamsProps, + query: isMakeJoinQueryProps, + response: { + 200: isMakeJoinResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { roomId, userId } = c.req.param(); + const url = new URL(c.req.url); + const verParams = url.searchParams.getAll('ver'); + + const response = await profile.makeJoin(roomId, userId, verParams.length > 0 ? verParams : ['1']); + + return { + body: { + room_version: response.room_version, + event: { + ...response.event, + content: { + ...response.event.content, + membership: 'join', + join_authorised_via_users_server: response.event.content.join_authorised_via_users_server, + }, + room_id: response.event.room_id, + sender: response.event.sender, + state_key: response.event.state_key, + type: 'm.room.member', + origin_server_ts: response.event.origin_server_ts, + origin: response.event.origin, + }, + }, + statusCode: 200, + }; + }, + ) + .post( + '/federation/v1/get_missing_events/:roomId', + { + params: isGetMissingEventsParamsProps, + body: isGetMissingEventsBodyProps, + response: { + 200: isGetMissingEventsResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { roomId } = c.req.param(); + const body = await c.req.json(); + + const response = await profile.getMissingEvents(roomId, body.earliest_events, body.latest_events, body.limit); + + return { + body: response, + statusCode: 200, + }; + }, + ) + .get( + '/federation/v1/event_auth/:roomId/:eventId', + { + params: isEventAuthParamsProps, + response: { + 200: isEventAuthResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { roomId, eventId } = c.req.param(); + + const response = await profile.eventAuth(roomId, eventId); + + return { + body: response, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts new file mode 100644 index 0000000000000..6670a08108d76 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts @@ -0,0 +1,207 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { getAllServicesFromFederationSDK } from '../../setupContainers'; + +const PublicRoomsQuerySchema = { + type: 'object', + properties: { + include_all_networks: { + type: 'boolean', + description: 'Include all networks (ignored)', + }, + limit: { + type: 'number', + description: 'Maximum number of rooms to return', + }, + }, + required: ['include_all_networks', 'limit'], +}; + +const isPublicRoomsQueryProps = ajv.compile(PublicRoomsQuerySchema); + +const RoomObjectSchema = { + type: 'object', + properties: { + avatar_url: { + type: 'string', + description: 'Room avatar URL', + }, + canonical_alias: { + type: 'string', + description: 'Room canonical alias', + nullable: true, + }, + guest_can_join: { + type: 'boolean', + description: 'Whether guests can join the room', + }, + join_rule: { + type: 'string', + description: 'Room join rule', + }, + name: { + type: 'string', + description: 'Room name', + }, + num_joined_members: { + type: 'number', + description: 'Number of joined members', + nullable: true, + }, + room_id: { + type: 'string', + description: 'Room ID', + }, + room_type: { + type: 'string', + description: 'Room type', + nullable: true, + }, + topic: { + type: 'string', + description: 'Room topic', + nullable: true, + }, + world_readable: { + type: 'boolean', + description: 'Whether the room is world readable', + }, + }, + required: ['avatar_url', 'guest_can_join', 'join_rule', 'name', 'room_id', 'world_readable'], +}; + +const PublicRoomsResponseSchema = { + type: 'object', + properties: { + chunk: { + type: 'array', + items: RoomObjectSchema, + description: 'Array of public rooms', + }, + }, + required: ['chunk'], +}; + +const isPublicRoomsResponseProps = ajv.compile(PublicRoomsResponseSchema); + +const PublicRoomsPostBodySchema = { + type: 'object', + properties: { + include_all_networks: { + type: 'string', + description: 'Include all networks (ignored)', + nullable: true, + }, + limit: { + type: 'number', + description: 'Maximum number of rooms to return', + nullable: true, + }, + filter: { + type: 'object', + properties: { + generic_search_term: { + type: 'string', + description: 'Generic search term for filtering rooms', + nullable: true, + }, + room_types: { + type: 'array', + items: { + type: ['string', 'null'], + }, + description: 'Array of room types to filter by', + nullable: true, + }, + }, + }, + }, + required: ['filter'], +}; + +const isPublicRoomsPostBodyProps = ajv.compile(PublicRoomsPostBodySchema); + +export const getMatrixRoomsRoutes = (router: Router<'/_matrix'>) => { + const { state } = getAllServicesFromFederationSDK(); + + return router + .get( + '/federation/v1/publicRooms', + { + query: isPublicRoomsQueryProps, + response: { + 200: isPublicRoomsResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async () => { + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce required endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; + + const publicRooms = await state.getAllPublicRoomIdsAndNames(); + + return { + body: { + chunk: publicRooms.map((room) => ({ + ...defaultObj, + ...room, + })), + }, + statusCode: 200, + }; + }, + ) + .post( + '/federation/v1/publicRooms', + { + body: isPublicRoomsPostBodyProps, + response: { + 200: isPublicRoomsResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce required endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; + + const { filter } = body; + + const publicRooms = await state.getAllPublicRoomIdsAndNames(); + + return { + body: { + chunk: publicRooms + .filter((r) => { + if (filter.generic_search_term) { + return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase()); + } + + if (filter.room_types) { + // TODO: implement room_types filtering + } + + return true; + }) + .map((room) => ({ + ...defaultObj, + ...room, + })), + }, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-join.ts b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts new file mode 100644 index 0000000000000..9c5d38f77ccb4 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts @@ -0,0 +1,245 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { getAllServicesFromFederationSDK } from '../../setupContainers'; + +const UsernameSchema = { + type: 'string', + pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', + description: 'Matrix user ID in format @user:server.com', +}; + +const RoomIdSchema = { + type: 'string', + pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$', + description: 'Matrix room ID in format !room:server.com', +}; + +const EventIdSchema = { + type: 'string', + pattern: '^\\$[A-Za-z0-9_=\\/.+-]+(:(.+))?$', + description: 'Matrix event ID in format $event', +}; + +const TimestampSchema = { + type: 'number', + minimum: 0, + description: 'Unix timestamp in milliseconds', +}; + +const DepthSchema = { + type: 'number', + minimum: 0, + description: 'Event depth', +}; + +const ServerNameSchema = { + type: 'string', + description: 'Matrix server name', +}; + +const SendJoinParamsSchema = { + type: 'object', + properties: { + roomId: RoomIdSchema, + stateKey: EventIdSchema, + }, + required: ['roomId', 'stateKey'], +}; + +const isSendJoinParamsProps = ajv.compile(SendJoinParamsSchema); + +const EventHashSchema = { + type: 'object', + properties: { + sha256: { + type: 'string', + description: 'SHA256 hash of the event', + }, + }, + required: ['sha256'], +}; + +const EventSignatureSchema = { + type: 'object', + description: 'Event signatures by server and key ID', +}; + +const MembershipEventContentSchema = { + type: 'object', + properties: { + membership: { + type: 'string', + enum: ['join', 'leave', 'invite', 'ban', 'knock'], + description: 'Membership state', + }, + displayname: { + type: 'string', + nullable: true, + }, + avatar_url: { + type: 'string', + nullable: true, + }, + join_authorised_via_users_server: { + type: 'string', + nullable: true, + }, + is_direct: { + type: 'boolean', + nullable: true, + }, + reason: { + type: 'string', + description: 'Reason for membership change', + nullable: true, + }, + }, + required: ['membership'], +}; + +const EventBaseSchema = { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Event type', + }, + content: { + type: 'object', + description: 'Event content', + }, + sender: UsernameSchema, + room_id: RoomIdSchema, + origin_server_ts: TimestampSchema, + depth: DepthSchema, + prev_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Previous events in the room', + }, + auth_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Authorization events', + }, + origin: { + type: 'string', + description: 'Origin server', + }, + hashes: { + ...EventHashSchema, + nullable: true, + }, + signatures: { + ...EventSignatureSchema, + nullable: true, + }, + unsigned: { + type: 'object', + description: 'Unsigned data', + nullable: true, + }, + }, + required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events', 'origin'], +}; + +const SendJoinEventSchema = { + type: 'object', + allOf: [ + EventBaseSchema, + { + type: 'object', + properties: { + type: { + type: 'string', + const: 'm.room.member', + }, + content: { + type: 'object', + allOf: [ + MembershipEventContentSchema, + { + type: 'object', + properties: { + membership: { + type: 'string', + const: 'join', + }, + }, + required: ['membership'], + }, + ], + }, + state_key: UsernameSchema, + }, + required: ['type', 'content', 'state_key'], + }, + ], +}; + +const isSendJoinEventProps = ajv.compile(SendJoinEventSchema); + +const SendJoinResponseSchema = { + type: 'object', + properties: { + event: { + type: 'object', + description: 'The processed join event', + }, + state: { + type: 'array', + items: { + type: 'object', + }, + description: 'Current state events in the room', + }, + auth_chain: { + type: 'array', + items: { + type: 'object', + }, + description: 'Authorization chain for the event', + }, + members_omitted: { + type: 'boolean', + description: 'Whether member events were omitted', + }, + origin: ServerNameSchema, + }, + required: ['event', 'state', 'auth_chain', 'members_omitted', 'origin'], +}; + +const isSendJoinResponseProps = ajv.compile(SendJoinResponseSchema); + +export const getMatrixSendJoinRoutes = (router: Router<'/_matrix'>) => { + const { sendJoin } = getAllServicesFromFederationSDK(); + + return router.put( + '/federation/v2/send_join/:roomId/:stateKey', + { + params: isSendJoinParamsProps, + body: isSendJoinEventProps, + response: { + 200: isSendJoinResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const { roomId, stateKey } = c.req.param(); + const body = await c.req.json(); + + const response = await sendJoin.sendJoin(roomId, stateKey, body); + + return { + body: response, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts new file mode 100644 index 0000000000000..93d06bcf6ccf0 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts @@ -0,0 +1,199 @@ +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { getAllServicesFromFederationSDK } from '../../setupContainers'; + +const SendTransactionParamsSchema = { + type: 'object', + properties: { + txnId: { + type: 'string', + description: 'Transaction ID', + }, + }, + required: ['txnId'], +}; + +const isSendTransactionParamsProps = ajv.compile(SendTransactionParamsSchema); + +const EventHashSchema = { + type: 'object', + properties: { + sha256: { + type: 'string', + description: 'SHA256 hash of the event', + }, + }, + required: ['sha256'], +}; + +const EventSignatureSchema = { + type: 'object', + description: 'Event signatures by server and key ID', +}; + +const EventBaseSchema = { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Event type', + }, + content: { + type: 'object', + description: 'Event content', + }, + sender: { + type: 'string', + pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', + description: 'Matrix user ID in format @user:server.com', + }, + room_id: { + type: 'string', + pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$', + description: 'Matrix room ID in format !room:server.com', + }, + origin_server_ts: { + type: 'number', + minimum: 0, + description: 'Unix timestamp in milliseconds', + }, + depth: { + type: 'number', + minimum: 0, + description: 'Event depth', + }, + prev_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Previous events in the room', + }, + auth_events: { + type: 'array', + items: { + type: 'string', + }, + description: 'Authorization events', + }, + origin: { + type: 'string', + description: 'Origin server', + }, + hashes: { + ...EventHashSchema, + nullable: true, + }, + signatures: { + ...EventSignatureSchema, + nullable: true, + }, + unsigned: { + type: 'object', + description: 'Unsigned data', + nullable: true, + }, + }, + required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events', 'origin'], +}; + +const SendTransactionBodySchema = { + type: 'object', + properties: { + pdus: { + type: 'array', + items: EventBaseSchema, + description: 'Persistent data units (PDUs) to process', + default: [], + }, + edus: { + type: 'array', + items: { + type: 'object', + additionalProperties: true, + }, + description: 'Ephemeral data units (EDUs)', + default: [], + nullable: true, + }, + }, + required: ['pdus'], +}; + +const isSendTransactionBodyProps = ajv.compile(SendTransactionBodySchema); + +const SendTransactionResponseSchema = { + type: 'object', + properties: { + pdus: { + type: 'object', + description: 'Processing results for each PDU', + }, + edus: { + type: 'object', + description: 'Processing results for each EDU', + }, + }, + required: ['pdus', 'edus'], +}; + +const isSendTransactionResponseProps = ajv.compile(SendTransactionResponseSchema); + +const ErrorResponseSchema = { + type: 'object', + properties: { + error: { + type: 'string', + }, + details: { + type: 'object', + }, + }, + required: ['error', 'details'], +}; + +const isErrorResponseProps = ajv.compile(ErrorResponseSchema); + +export const getMatrixTransactionsRoutes = (router: Router<'/_matrix'>) => { + const { event } = getAllServicesFromFederationSDK(); + + return router.put( + '/federation/v1/send/:txnId', + { + params: isSendTransactionParamsProps, + body: isSendTransactionBodyProps, + response: { + 200: isSendTransactionResponseProps, + 400: isErrorResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const { pdus = [] } = body; + + if (pdus.length === 0) { + return { + body: { + pdus: {}, + edus: {}, + }, + statusCode: 200, + }; + } + + await event.processIncomingPDUs(pdus); + + return { + body: { + pdus: {}, + edus: {}, + }, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/versions.ts b/ee/packages/federation-matrix/src/api/_matrix/versions.ts new file mode 100644 index 0000000000000..cb039856352bb --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/versions.ts @@ -0,0 +1,56 @@ +import { ConfigService } from '@hs/federation-sdk'; +import type { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +const GetVersionsResponseSchema = { + type: 'object', + properties: { + server: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Server software name', + }, + version: { + type: 'string', + description: 'Server software version', + }, + }, + required: ['name', 'version'], + }, + }, + required: ['server'], +}; + +const isGetVersionsResponseProps = ajv.compile(GetVersionsResponseSchema); + +export const getFederationVersionsRoutes = (router: Router<'/_matrix'>) => { + const configService = new ConfigService(); + + return router.get( + '/federation/v1/version', + { + response: { + 200: isGetVersionsResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async () => { + const config = configService.getServerConfig(); + + const response = { + server: { + name: config.name, + version: config.version, + }, + }; + + return { + body: response, + statusCode: 200, + }; + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/api.ts b/ee/packages/federation-matrix/src/api/api.ts new file mode 100644 index 0000000000000..e61a602a8a4c1 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/api.ts @@ -0,0 +1,31 @@ +import { Router } from '@rocket.chat/http-router'; + +import { getWellKnownRoutes } from './.well-known/server'; +import { getMatrixInviteRoutes } from './_matrix/invite'; +import { getKeyServerRoutes } from './_matrix/key/server'; +import { getMatrixProfilesRoutes } from './_matrix/profiles'; +import { getMatrixRoomsRoutes } from './_matrix/rooms'; +import { getMatrixSendJoinRoutes } from './_matrix/send-join'; +import { getMatrixTransactionsRoutes } from './_matrix/transactions'; +import { getFederationVersionsRoutes } from './_matrix/versions'; + +const matrix = new Router('/_matrix'); +const wellKnown = new Router('/.well-known'); + +export const getAllMatrixRoutes = () => { + matrix + .use(getMatrixInviteRoutes(matrix)) + .use(getMatrixProfilesRoutes(matrix)) + .use(getMatrixRoomsRoutes(matrix)) + .use(getMatrixSendJoinRoutes(matrix)) + .use(getMatrixTransactionsRoutes(matrix)) + .use(getFederationVersionsRoutes(matrix)) + .use(getKeyServerRoutes(matrix)); + + wellKnown.use(getWellKnownRoutes(wellKnown)); + + return { + matrix, + wellKnown, + }; +}; diff --git a/ee/packages/federation-matrix/src/events/invite.ts b/ee/packages/federation-matrix/src/events/invite.ts index 38281bc60d1c5..e1592be26bc4d 100644 --- a/ee/packages/federation-matrix/src/events/invite.ts +++ b/ee/packages/federation-matrix/src/events/invite.ts @@ -1,8 +1,7 @@ - +import type { HomeserverEventSignatures } from '@hs/federation-sdk'; import { Room } from '@rocket.chat/core-services'; import { UserStatus } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; -import type { HomeserverEventSignatures } from '@hs/federation-sdk'; import { MatrixBridgedRoom, MatrixBridgedUser, Users } from '@rocket.chat/models'; export function invite(emitter: Emitter) { diff --git a/ee/packages/federation-matrix/src/events/ping.ts b/ee/packages/federation-matrix/src/events/ping.ts index d2d116374cb8a..04972b23fb544 100644 --- a/ee/packages/federation-matrix/src/events/ping.ts +++ b/ee/packages/federation-matrix/src/events/ping.ts @@ -1,5 +1,5 @@ -import type { Emitter } from '@rocket.chat/emitter'; import type { HomeserverEventSignatures } from '@hs/federation-sdk'; +import type { Emitter } from '@rocket.chat/emitter'; export const ping = async (emitter: Emitter) => { emitter.on('homeserver.ping', async (data) => { diff --git a/ee/packages/federation-matrix/src/setupContainers.ts b/ee/packages/federation-matrix/src/setupContainers.ts index 7e24cbe92c989..290f3da9d2db1 100644 --- a/ee/packages/federation-matrix/src/setupContainers.ts +++ b/ee/packages/federation-matrix/src/setupContainers.ts @@ -1,8 +1,8 @@ import 'reflect-metadata'; import { toUnpaddedBase64 } from '@hs/core'; -import { ConfigService, createFederationContainer } from '@hs/federation-sdk'; -import type { DependencyContainer, FederationContainerOptions, HomeserverEventSignatures } from '@hs/federation-sdk'; +import { ConfigService, createFederationContainer, getAllServices } from '@hs/federation-sdk'; +import type { DependencyContainer, FederationContainerOptions, HomeserverEventSignatures, HomeserverServices } from '@hs/federation-sdk'; import { Emitter } from '@rocket.chat/emitter'; let container: DependencyContainer | undefined; @@ -32,9 +32,10 @@ export async function setup( return container; } -export function getContainer(): DependencyContainer { +export function getAllServicesFromFederationSDK(): HomeserverServices { if (!container) { throw new Error('Federation container is not initialized. Call setup() first.'); } - return container; + + return getAllServices(container); } diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 569df0e453763..e2621c53ae418 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.43.0", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 54d2920c41d51..02bd33f95eebf 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,4 +1,5 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { Router } from '@rocket.chat/http-router'; export interface IRouteContext { params: any; @@ -11,10 +12,9 @@ export interface IRouteContext { export interface IFederationMatrixService { getAllRoutes(): { - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - path: string; - handler: (ctx: IRouteContext) => Promise; - }[]; + matrix: Router<'/_matrix'>; + wellKnown: Router<'/.well-known'>; + }; createRoom(room: IRoom, owner: IUser, members: string[]): Promise; sendMessage(message: IMessage, room: IRoom, user: IUser): Promise; } diff --git a/packages/http-router/src/Router.ts b/packages/http-router/src/Router.ts index 012ccf34e8914..2b7d923785b2c 100644 --- a/packages/http-router/src/Router.ts +++ b/packages/http-router/src/Router.ts @@ -371,6 +371,14 @@ export class Router< ); return router; } + + getHonoRouter(): Hono<{ + Variables: { + remoteAddress: string; + }; + }> { + return this.innerRouter; + } } type Prettify = { diff --git a/yarn.lock b/yarn.lock index 2190d816ff0a1..1bacca961c348 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8866,6 +8866,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/message-parser": "workspace:^" @@ -9053,11 +9054,11 @@ __metadata: "@babel/preset-env": "npm:~7.26.0" "@babel/preset-typescript": "npm:~7.26.0" "@hs/federation-sdk": "workspace:^" - "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/http-router": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/network-broker": "workspace:^" "@rocket.chat/rest-typings": "workspace:^"