From 699b989f9b6bd3c9ce5a08dcd43e9b4ed92af33c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 1 Sep 2025 08:22:02 -0300 Subject: [PATCH 01/33] feat: adds files support --- .../file-upload/server/config/MatrixRemote.ts | 15 + .../server/config/_configUploadStorage.ts | 1 + .../app/file-upload/server/lib/FileUpload.ts | 6 + .../server/services/messages/service.ts | 9 + .../federation-matrix/src/FederationMatrix.ts | 507 +++++++++++++++--- .../src/api/_matrix/media.ts | 349 ++++++++++++ .../federation-matrix/src/events/message.ts | 384 +++++++++---- .../src/services/MatrixMediaService.ts | 273 ++++++++++ .../src/types/IUploadWithFederation.ts | 15 + .../src/types/IFederationMatrixService.ts | 5 +- .../src/types/IMessageService.ts | 6 + packages/core-typings/src/IUpload.ts | 6 + 12 files changed, 1381 insertions(+), 195 deletions(-) create mode 100644 apps/meteor/app/file-upload/server/config/MatrixRemote.ts create mode 100644 ee/packages/federation-matrix/src/api/_matrix/media.ts create mode 100644 ee/packages/federation-matrix/src/services/MatrixMediaService.ts create mode 100644 ee/packages/federation-matrix/src/types/IUploadWithFederation.ts diff --git a/apps/meteor/app/file-upload/server/config/MatrixRemote.ts b/apps/meteor/app/file-upload/server/config/MatrixRemote.ts new file mode 100644 index 0000000000000..79c896788791e --- /dev/null +++ b/apps/meteor/app/file-upload/server/config/MatrixRemote.ts @@ -0,0 +1,15 @@ +import { FederationMatrix } from '@rocket.chat/core-services'; +import { Uploads } from '@rocket.chat/models'; + +import { FileUploadClass } from '../lib/FileUpload'; + +const MatrixRemoteHandler = new FileUploadClass({ + name: 'MatrixRemote:Uploads', + model: Uploads, + + async get(file, req, res) { + await FederationMatrix.downloadRemoteFile(file, req, res); + }, +}); + +export { MatrixRemoteHandler }; diff --git a/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts b/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts index 55cc1afbbab16..26c643bfd49c1 100644 --- a/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts +++ b/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts @@ -7,6 +7,7 @@ import './AmazonS3'; import './FileSystem'; import './GoogleStorage'; import './GridFS'; +import './MatrixRemote'; import './Webdav'; const configStore = _.debounce(() => { diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 73f67fb473744..769cfbd0c1a21 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -440,6 +440,12 @@ export const FileUpload = { return true; } + // Allow MatrixRemote files to bypass normal access control + // They are handled by federation-specific logic in the onRead method + if (file?.store === 'MatrixRemote:Uploads') { + return true; + } + const { query } = URL.parse(url, true); // eslint-disable-next-line @typescript-eslint/naming-convention let { rc_uid, rc_token, rc_rid, rc_room_type } = query as Record; diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index 29767fe0f32a5..e046b905e4676 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -91,12 +91,18 @@ export class MessageService extends ServiceClassInternal implements IMessageServ msg, federation_event_id, tmid, + file, + files, + attachments, }: { fromId: string; rid: string; msg: string; federation_event_id: string; tmid?: string; + file?: IMessage['file']; + files?: IMessage['files']; + attachments?: IMessage['attachments']; }): Promise { const threadParams = tmid ? { tmid, tshow: true } : {}; return executeSendMessage(fromId, { @@ -104,6 +110,9 @@ export class MessageService extends ServiceClassInternal implements IMessageServ msg, ...threadParams, federation: { eventId: federation_event_id }, + file, + files, + attachments, }); } diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 546182a666b36..0488c0f6fd208 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -16,6 +16,7 @@ import emojione from 'emojione'; import { getWellKnownRoutes } from './api/.well-known/server'; import { getMatrixInviteRoutes } from './api/_matrix/invite'; import { getKeyServerRoutes } from './api/_matrix/key/server'; +import { getMatrixMediaRoutes } from './api/_matrix/media'; import { getMatrixProfilesRoutes } from './api/_matrix/profiles'; import { getMatrixRoomsRoutes } from './api/_matrix/rooms'; import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; @@ -26,10 +27,18 @@ import { isLicenseEnabledMiddleware } from './api/middlewares/isLicenseEnabled'; import { registerEvents } from './events'; import { saveExternalUserIdForLocalUser } from './helpers/identifiers'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; +import { MatrixMediaService } from './services/MatrixMediaService'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; + // Media related constants + private static readonly FETCH_TIMEOUT = 30000; + + private static readonly CACHE_MAX_AGE = 86400; + + private static readonly USER_AGENT = 'RocketChat-Federation/1.0'; + private eventHandler: Emitter; private homeserverServices: HomeserverServices; @@ -170,7 +179,8 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS .use(getMatrixSendJoinRoutes(this.homeserverServices)) .use(getMatrixTransactionsRoutes(this.homeserverServices)) .use(getKeyServerRoutes(this.homeserverServices)) - .use(getFederationVersionsRoutes(this.homeserverServices)); + .use(getFederationVersionsRoutes(this.homeserverServices)) + .use(getMatrixMediaRoutes(this.homeserverServices)); wellKnown.use(isFederationEnabledMiddleware).use(isLicenseEnabledMiddleware).use(getWellKnownRoutes(this.homeserverServices)); @@ -395,6 +405,211 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw error; } } + private determineFileMessageType(fileType?: string): 'm.image' | 'm.file' | 'm.video' | 'm.audio' { + if (!fileType) return 'm.file'; + + if (fileType.startsWith('image/')) return 'm.image'; + if (fileType.startsWith('video/')) return 'm.video'; + if (fileType.startsWith('audio/')) return 'm.audio'; + + return 'm.file'; + } + + private async prepareFileForMatrix(file: NonNullable[0], matrixDomain: string): Promise { + return MatrixMediaService.prepareLocalFileForMatrix(file._id, matrixDomain); + } + + private buildFileMessageContent( + file: NonNullable[0], + mxcUri: string, + ): { + body: string; + msgtype: 'm.image' | 'm.file' | 'm.video' | 'm.audio'; + url: string; + info: { + mimetype?: string; + size?: number; + // TODO: Add thumbnail support when RC provides thumbnail metadata + // thumbnail_url?: string; + // thumbnail_info?: { + // mimetype?: string; + // size?: number; + // w?: number; + // h?: number; + // }; + }; + } { + const msgtype = this.determineFileMessageType(file.type); + + const content: ReturnType = { + body: file.name || 'file', + msgtype, + url: mxcUri, + info: { + mimetype: file.type, + size: file.size, + }, + }; + + // Note: Rocket.Chat doesn't provide separate thumbnail metadata in the file object + // If we need thumbnail support, we'd need to either: + // 1. Generate thumbnails on-the-fly for images/videos + // 2. Use a pre-generated thumbnail if RC provides it in the future + // 3. Link to RC's thumbnail endpoint if available + + return content; + } + + private async handleFileMessage( + message: IMessage, + matrixRoomId: string, + matrixUserId: string, + matrixDomain: string, + ): Promise<{ eventId: string } | null> { + if (!message.files || message.files.length === 0) { + return null; + } + + try { + // For now, handle only the first file since RC typically sends one file per message + // If multiple files need to be sent, each should be a separate Matrix message + const file = message.files[0]; + + const mxcUri = await this.prepareFileForMatrix(file, matrixDomain); + + const fileContent = this.buildFileMessageContent(file, mxcUri); + + const result = await this.homeserverServices.message.sendFileMessage(matrixRoomId, fileContent, matrixUserId); + + if (result) { + this.logger.info('Successfully sent file message to Matrix', { + messageId: message._id, + fileId: file._id, + fileName: file.name, + mxcUri, + eventId: result.eventId, + }); + } + + if (message.files.length > 1) { + this.logger.warn('Message contains multiple files, but only the first was sent to Matrix', { + messageId: message._id, + totalFiles: message.files.length, + sentFile: file.name, + }); + } + + return result; + } catch (error) { + this.logger.error('Failed to handle file message', { + messageId: message._id, + error, + }); + throw error; + } + } + + private async handleTextMessage( + message: IMessage, + matrixRoomId: string, + matrixUserId: string, + matrixDomain: string, + ): Promise<{ eventId: string } | null> { + const parsedMessage = await toExternalMessageFormat({ + message: message.msg, + externalRoomId: matrixRoomId, + homeServerDomain: matrixDomain, + }); + + // Check if this is a threaded message + if (message.tmid) { + return this.handleThreadedMessage(message, matrixRoomId, matrixUserId, matrixDomain, parsedMessage); + } + + // Check if this is a quote/reply message + if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { + return this.handleQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); + } + + // Send regular message + return this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, matrixUserId); + } + + private async handleThreadedMessage( + message: IMessage, + matrixRoomId: string, + matrixUserId: string, + matrixDomain: string, + parsedMessage: string, + ): Promise<{ eventId: string } | null> { + const threadRootMessage = await Messages.findOneById(message.tmid!); + const threadRootEventId = threadRootMessage?.federation?.eventId; + + if (!threadRootEventId) { + this.logger.warn('Thread root event ID not found, sending as regular message'); + // Fall back to regular message or quote message + if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { + return this.handleQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); + } + return this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, matrixUserId); + } + + // Get the latest thread message for proper threading + const latestThreadMessage = await Messages.findOne( + { + 'tmid': message.tmid, + 'federation.eventId': { $exists: true }, + '_id': { $ne: message._id }, + }, + { sort: { ts: -1 } }, + ); + const latestThreadEventId = latestThreadMessage?.federation?.eventId; + + // Check if this is a quote within a thread + if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { + const quoteMessage = await this.getQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); + if (!quoteMessage) { + throw new Error('Failed to retrieve quote message'); + } + return this.homeserverServices.message.sendReplyToInsideThreadMessage( + matrixRoomId, + quoteMessage.rawMessage, + quoteMessage.formattedMessage, + matrixUserId, + threadRootEventId, + quoteMessage.eventToReplyTo, + ); + } + + // Send regular thread message + return this.homeserverServices.message.sendThreadMessage( + matrixRoomId, + message.msg, + parsedMessage, + matrixUserId, + threadRootEventId, + latestThreadEventId, + ); + } + + private async handleQuoteMessage( + message: IMessage, + matrixRoomId: string, + matrixUserId: string, + matrixDomain: string, + ): Promise<{ eventId: string } | null> { + const quoteMessage = await this.getQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); + if (!quoteMessage) { + throw new Error('Failed to retrieve quote message'); + } + return this.homeserverServices.message.sendReplyToMessage( + matrixRoomId, + quoteMessage.rawMessage, + quoteMessage.formattedMessage, + quoteMessage.eventToReplyTo, + matrixUserId, + ); + } async sendMessage(message: IMessage, room: IRoom, user: IUser): Promise { try { @@ -417,84 +632,10 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const actualMatrixUserId = existingMatrixUserId || matrixUserId; let result; - - const parsedMessage = await toExternalMessageFormat({ - message: message.msg, - externalRoomId: matrixRoomId, - homeServerDomain: this.serverName, - }); - if (!message.tmid) { - if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { - const quoteMessage = await this.getQuoteMessage(message, matrixRoomId, actualMatrixUserId, this.serverName); - if (!quoteMessage) { - throw new Error('Failed to retrieve quote message'); - } - result = await this.homeserverServices.message.sendReplyToMessage( - matrixRoomId, - quoteMessage.rawMessage, - quoteMessage.formattedMessage, - quoteMessage.eventToReplyTo, - actualMatrixUserId, - ); - } else { - result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, actualMatrixUserId); - } + if (message.files && message.files.length > 0) { + result = await this.handleFileMessage(message, matrixRoomId, actualMatrixUserId, matrixDomain); } else { - const threadRootMessage = await Messages.findOneById(message.tmid); - const threadRootEventId = threadRootMessage?.federation?.eventId; - - if (threadRootEventId) { - const latestThreadMessage = await Messages.findOne( - { - 'tmid': message.tmid, - 'federation.eventId': { $exists: true }, - '_id': { $ne: message._id }, // Exclude the current message - }, - { sort: { ts: -1 } }, - ); - const latestThreadEventId = latestThreadMessage?.federation?.eventId; - - if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { - const quoteMessage = await this.getQuoteMessage(message, matrixRoomId, actualMatrixUserId, this.serverName); - if (!quoteMessage) { - throw new Error('Failed to retrieve quote message'); - } - result = await this.homeserverServices.message.sendReplyToInsideThreadMessage( - matrixRoomId, - quoteMessage.rawMessage, - quoteMessage.formattedMessage, - actualMatrixUserId, - threadRootEventId, - quoteMessage.eventToReplyTo, - ); - } else { - result = await this.homeserverServices.message.sendThreadMessage( - matrixRoomId, - message.msg, - parsedMessage, - actualMatrixUserId, - threadRootEventId, - latestThreadEventId, - ); - } - } else { - this.logger.warn('Thread root event ID not found, sending as regular message'); - if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { - const quoteMessage = await this.getQuoteMessage(message, matrixRoomId, actualMatrixUserId, this.serverName); - if (!quoteMessage) { - throw new Error('Failed to retrieve quote message'); - } - result = await this.homeserverServices.message.sendReplyToMessage( - matrixRoomId, - quoteMessage.rawMessage, - quoteMessage.formattedMessage, - quoteMessage.eventToReplyTo, - actualMatrixUserId, - ); - } else { - result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, actualMatrixUserId); - } - } + result = await this.handleTextMessage(message, matrixRoomId, actualMatrixUserId, matrixDomain); } if (!result) { @@ -934,4 +1075,214 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } await this.homeserverServices.room.setPowerLevelForUser(matrixRoomId, senderMatrixUserId, matrixUserId, powerLevel); } + + private validateRemoteFile(file: any): { + isValid: boolean; + error?: string; + mxcUri?: string; + serverName?: string; + mediaId?: string; + } { + const mxcUri = file.federation?.mxcUri; + const serverName = file.federation?.serverName; + const mediaId = file.federation?.mediaId; + + if (!mxcUri || !serverName || !mediaId) { + return { + isValid: false, + error: 'Remote file metadata missing', + }; + } + + return { + isValid: true, + mxcUri, + serverName, + mediaId, + }; + } + + private parseMxcUri( + mxcUri: string, + serverName: string, + mediaId: string, + ): { + originServer: string; + actualMediaId: string; + } { + const mxcParts = mxcUri.match(/^mxc:\/\/([^\/]+)\/(.+)$/); + return { + originServer: mxcParts ? mxcParts[1] : serverName, + actualMediaId: mxcParts ? mxcParts[2] : mediaId, + }; + } + + private buildMatrixMediaEndpoints( + originServer: string, + mediaId: string, + ): Array<{ + url: string; + name: string; + headers: Record; + }> { + const endpoints = [ + { + url: `https://${originServer}/_matrix/media/v1/download/${originServer}/${mediaId}`, + name: 'media_v1_https', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + { + url: `https://${originServer}/_matrix/media/v3/download/${originServer}/${mediaId}`, + name: 'media_v3_https', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + { + url: `http://${originServer}/_matrix/media/v3/download/${originServer}/${mediaId}`, + name: 'media_v3_http', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + { + url: `https://${originServer}/_matrix/media/r0/download/${originServer}/${mediaId}`, + name: 'media_r0_https', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + { + url: `http://${originServer}/_matrix/media/r0/download/${originServer}/${mediaId}`, + name: 'media_r0_http', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + { + url: `https://${originServer}/_matrix/client/v1/media/download/${originServer}/${mediaId}`, + name: 'client_v1_https', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + { + url: `http://${originServer}/_matrix/client/v1/media/download/${originServer}/${mediaId}`, + name: 'client_v1_http', + headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, + }, + ]; + + return endpoints; + } + + private async createHttpAgent(isHttps: boolean): Promise { + if (isHttps) { + return { + agent: new (await import('https')).Agent({ + rejectUnauthorized: false, + }), + }; + } + return { + agent: new (await import('http')).Agent({ + keepAlive: true, + }), + }; + } + + private async fetchFromEndpoints(endpoints: Array<{ url: string; name: string; headers: Record }>): Promise<{ + response: any | null; + lastError: any; + }> { + const fetch = (await import('node-fetch')).default; + let response: any = null; + let lastError: any = null; + + for await (const endpoint of endpoints) { + this.logger.info(`Trying ${endpoint.name} endpoint`, { + url: endpoint.url, + method: 'GET', + headers: endpoint.headers, + }); + + try { + const isHttps = endpoint.url.startsWith('https://'); + const agentOptions = await this.createHttpAgent(isHttps); + + response = await fetch(endpoint.url, { + method: 'GET', + headers: endpoint.headers, + timeout: FederationMatrix.FETCH_TIMEOUT, + ...agentOptions, + }); + + if (response.ok) { + this.logger.info(`Successfully fetched file via ${endpoint.name}`, { + status: response.status, + endpoint: endpoint.name, + url: endpoint.url, + }); + break; + } + + lastError = `${endpoint.name}: ${response.status} ${response.statusText}`; + } catch (fetchError: any) { + this.logger.warn(`Failed to fetch from ${endpoint.name}`, { + error: fetchError.message, + code: fetchError.code, + }); + lastError = fetchError; + } + } + + return { response, lastError }; + } + + private streamResponseToClient(response: any, res: any, file: any): void { + const contentType = response.headers.get('content-type') || file.type || 'application/octet-stream'; + const contentLength = response.headers.get('content-length'); + + res.setHeader('Content-Type', contentType); + if (contentLength) { + res.setHeader('Content-Length', contentLength); + } + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.name || '')}"`); + res.setHeader('Cache-Control', `public, max-age=${FederationMatrix.CACHE_MAX_AGE}`); + + response.body?.pipe(res); + } + + /** + * Download and stream a remote Matrix file to the client + * This method handles proxying remote Matrix files to Rocket.Chat clients + */ + async downloadRemoteFile(file: any, _req: any, res: any): Promise { + try { + const validation = this.validateRemoteFile(file); + if (!validation.isValid) { + this.logger.error('Invalid remote file metadata', { + error: validation.error, + federation: file.federation, + }); + res.writeHead(404); + res.end(validation.error); + return; + } + + const { mxcUri, serverName, mediaId } = validation; + const { originServer, actualMediaId } = this.parseMxcUri(mxcUri!, serverName!, mediaId!); + + const endpoints = this.buildMatrixMediaEndpoints(originServer, actualMediaId); + + const { response, lastError } = await this.fetchFromEndpoints(endpoints); + if (!response || !response.ok) { + this.logger.error('Failed to fetch remote file from all endpoints', { + lastError, + mxcUri, + originServer, + actualMediaId, + }); + res.writeHead(404); + res.end(`Failed to fetch remote file: ${lastError}`); + return; + } + + this.streamResponseToClient(response, res, file); + } catch (error) { + this.logger.error('Error handling remote Matrix file download:', error); + res.writeHead(500); + res.end('Internal server error'); + } + } } diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts new file mode 100644 index 0000000000000..c66e4a0599c47 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -0,0 +1,349 @@ +import crypto from 'crypto'; + +import { extractSignaturesFromHeader, validateAuthorizationHeader } from '@hs/core'; +import type { HomeserverServices } from '@hs/federation-sdk'; +import { Router } from '@rocket.chat/http-router'; +import { Logger } from '@rocket.chat/logger'; +import { Uploads } from '@rocket.chat/models'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; + +import { MatrixMediaService } from '../../services/MatrixMediaService'; +import type { IUploadWithFederation } from '../../types/IUploadWithFederation'; + +const logger = new Logger('federation-matrix:media'); +const ENFORCE_FEDERATION_VERIFICATION = process.env.ENFORCE_FEDERATION_VERIFICATION === 'true'; +const MediaDownloadParamsSchema = { + type: 'object', + properties: { + mediaId: { type: 'string' }, + }, + required: ['mediaId'], + additionalProperties: false, +}; + +const ErrorResponseSchema = { + type: 'object', + properties: { + errcode: { type: 'string' }, + error: { type: 'string' }, + }, + required: ['errcode', 'error'], +}; + +const BufferResponseSchema = { + type: 'object', + description: 'Raw file buffer or multipart response', +}; + +const isMediaDownloadParamsProps = ajv.compile(MediaDownloadParamsSchema); +const isErrorResponseProps = ajv.compile(ErrorResponseSchema); +const isBufferResponseProps = ajv.compile(BufferResponseSchema); + +// TODO: Move to homeserver +async function verifyMatrixSignature( + homeserverServices: HomeserverServices, + authHeader: string, + method: string, + uri: string, + body?: any, +): Promise<{ isValid: boolean; origin?: string; error?: string }> { + try { + const { origin, destination, key, signature } = extractSignaturesFromHeader(authHeader); + const ourServerName = homeserverServices.config.getServerConfig().name; + + if (destination !== ourServerName) { + return { + isValid: false, + error: `Destination mismatch: expected ${ourServerName}, got ${destination}`, + }; + } + + let publicKey: string; + try { + const keyResponse = await fetch(`https://${origin}/_matrix/key/v2/server`); + if (!keyResponse.ok) { + throw new Error(`Failed to fetch keys from ${origin}: ${keyResponse.status}`); + } + + const keyData = (await keyResponse.json()) as any; + if (!keyData.verify_keys || !keyData.verify_keys[key]) { + throw new Error(`Key ${key} not found in response from ${origin}`); + } + + publicKey = keyData.verify_keys[key].key; + } catch (fetchError) { + logger.error('Failed to fetch public key from origin server', { + origin, + keyId: key, + error: fetchError instanceof Error ? fetchError.message : 'Unknown error', + }); + + if (!ENFORCE_FEDERATION_VERIFICATION) { + logger.warn('Allowing request despite key fetch failure (development mode)'); + return { isValid: true, origin }; + } + return { isValid: false, error: 'Failed to fetch public key from origin server' }; + } + + try { + const isValid = await validateAuthorizationHeader(origin, publicKey, destination, method, uri, signature, body); + if (isValid) { + logger.info('X-Matrix signature verified successfully', { origin, keyId: key }); + return { isValid: true, origin }; + } + logger.warn('X-Matrix signature validation returned false', { origin, keyId: key }); + } catch (validationError) { + logger.warn('X-Matrix signature verification failed', { + origin, + keyId: key, + method, + uri, + destination, + error: validationError instanceof Error ? validationError.message : 'Unknown error', + }); + + if (!ENFORCE_FEDERATION_VERIFICATION) { + logger.warn('Allowing request despite verification failure (development mode)'); + return { isValid: true, origin }; + } + return { isValid: false, error: 'Invalid signature' }; + } + + return { isValid: false, error: 'Signature verification failed' }; + } catch (error) { + logger.error('Error during X-Matrix signature verification', { + error, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + }); + return { + isValid: false, + error: error instanceof Error ? error.message : 'Signature verification error', + }; + } +} + +function addSecurityHeaders(headers: Record): Record { + return { + ...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[] = []; + + if (metadata) { + parts.push(`--${boundary}`); + parts.push('Content-Type: application/json'); + parts.push(''); + parts.push(JSON.stringify(metadata)); + } + + parts.push(`--${boundary}`); + parts.push(`Content-Type: ${mimeType}`); + parts.push(`Content-Disposition: attachment; filename="${fileName}"`); + parts.push(''); + + const headerBuffer = Buffer.from(`${parts.join('\r\n')}\r\n`); + const endBoundary = Buffer.from(`\r\n--${boundary}--\r\n`); + const multipartBody = Buffer.concat([headerBuffer, buffer, endBoundary]); + + return { + body: multipartBody, + contentType: `multipart/mixed; boundary=${boundary}`, + }; +} + +async function getMediaFile( + mediaId: string, + serverName: string, +): Promise<{ + file: IUploadWithFederation | null; + buffer: Buffer | null; +}> { + const mxcUri = `mxc://${serverName}/${mediaId}`; + let file: IUploadWithFederation | null = await MatrixMediaService.getLocalFileForMatrixNode(mxcUri); + + if (!file) { + const directFile = await Uploads.findOneById(mediaId); + if (!directFile) { + return { file: null, buffer: null }; + } + file = { + ...directFile, + federation: directFile.federation || { type: 'local', isRemote: false }, + } as IUploadWithFederation; + } + + const buffer = await MatrixMediaService.getLocalFileBuffer(file._id); + return { file, buffer }; +} + +async function handleFederationAuth( + context: any, + homeserverServices: HomeserverServices, +): Promise<{ isValid: boolean; requestingServer: string; errorResponse?: any }> { + const authHeader = context.req.header('Authorization'); + let requestingServer = 'unknown'; + + if (!authHeader || (!authHeader.startsWith('X-Matrix') && !ENFORCE_FEDERATION_VERIFICATION)) { + return { isValid: true, requestingServer }; + } + + const verificationResult = await verifyMatrixSignature( + homeserverServices, + authHeader, + context.req.method, + context.req.path, + context.req.body, + ); + + if (!verificationResult.isValid) { + return { + isValid: false, + requestingServer, + errorResponse: { + statusCode: 401, + body: { + errcode: 'M_UNAUTHORIZED', + error: verificationResult.error || 'Invalid X-Matrix signature', + }, + }, + }; + } + + requestingServer = verificationResult.origin || 'unknown'; + + return { isValid: true, requestingServer }; +} + +export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => { + const { config } = homeserverServices; + const router = new Router('/federation'); + + // Federation V1 Download Endpoint + router.get( + '/v1/media/download/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 200: isBufferResponseProps, + 401: isErrorResponseProps, + 404: isErrorResponseProps, + 429: isErrorResponseProps, + 500: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], + }, + async (context: any) => { + try { + const { mediaId } = context.req.param(); + const serverName = config.getServerConfig().name; + + const authResult = await handleFederationAuth(context, homeserverServices); + if (!authResult.isValid) { + return authResult.errorResponse; + } + + const { file, buffer } = await getMediaFile(mediaId, serverName); + if (!file || !buffer) { + return { + statusCode: 404, + body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + }; + } + + const mimeType = file.type || 'application/octet-stream'; + const fileName = file.name || mediaId; + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName, { + 'content-type': mimeType, + 'content-length': buffer.length, + }); + + return { + statusCode: 200, + headers: addSecurityHeaders({ + 'content-type': multipartResponse.contentType, + 'content-length': String(multipartResponse.body.length), + }), + body: multipartResponse.body, + }; + } catch (error) { + logger.error('Federation media download error:', error); + return { + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, + }; + } + }, + ); + + // Federation V1 Thumbnail Endpoint + router.get( + '/v1/media/thumbnail/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 200: isBufferResponseProps, + 401: isErrorResponseProps, + 404: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], + }, + async (context: any) => { + try { + const { mediaId } = context.req.param(); + const serverName = config.getServerConfig().name; + + const authResult = await handleFederationAuth(context, homeserverServices); + if (!authResult.isValid) { + return authResult.errorResponse; + } + + const { file, buffer } = await getMediaFile(mediaId, serverName); + if (!file || !buffer) { + return { + statusCode: 404, + body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + }; + } + + const mimeType = file.type || 'application/octet-stream'; + const fileName = file.name || mediaId; + + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName, { + 'content-type': mimeType, + 'content-length': buffer.length, + 'thumbnail': true, + }); + + return { + statusCode: 200, + headers: addSecurityHeaders({ + 'content-type': multipartResponse.contentType, + 'content-length': String(multipartResponse.body.length), + }), + body: multipartResponse.body, + }; + } catch (error) { + logger.error('Federation thumbnail error:', error); + return { + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, + }; + } + }, + ); + + return router; +}; diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index 42c4f001d97dd..3aa84ac337e6e 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -1,130 +1,267 @@ import type { HomeserverEventSignatures } from '@hs/federation-sdk'; import { FederationMatrix, Message, MeteorService } from '@rocket.chat/core-services'; import { UserStatus } from '@rocket.chat/core-typings'; -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, IRoom } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; import { Users, MatrixBridgedUser, MatrixBridgedRoom, Rooms, Subscriptions, Messages } from '@rocket.chat/models'; import { toInternalMessageFormat, toInternalQuoteMessageFormat } from '../helpers/message.parsers'; +import { MatrixMediaService } from '../services/MatrixMediaService'; const logger = new Logger('federation-matrix:message'); -export function message(emitter: Emitter, serverName: string) { - emitter.on('homeserver.matrix.message', async (data) => { - try { - const message = data.content?.body?.toString(); - if (!message) { - logger.debug('No message found in event content'); - return; - } +async function getOrCreateFederatedUser(matrixUserId: string): Promise { + const [userPart, domain] = matrixUserId.split(':'); + if (!userPart || !domain) { + logger.error('Invalid Matrix sender ID format:', matrixUserId); + return null; + } + const username = userPart.substring(1); - const content = data.content as any; - const replyToRelation = content?.['m.relates_to']; - const isThreadMessage = replyToRelation?.rel_type === 'm.thread'; - const isQuoteMessage = replyToRelation?.['m.in_reply_to']?.event_id && !replyToRelation?.is_falling_back; - const threadRootEventId = isThreadMessage ? replyToRelation.event_id : undefined; + let user = await Users.findOneByUsername(matrixUserId); - const [userPart, domain] = data.sender.split(':'); - if (!userPart || !domain) { - logger.error('Invalid Matrix sender ID format:', data.sender); - return; - } - const username = userPart.substring(1); + if (!user) { + logger.info('Creating new federated user:', { username: matrixUserId, externalId: matrixUserId }); - const internalUsername = data.sender; - let user = await Users.findOneByUsername(internalUsername); + const userData: Partial = { + username: matrixUserId, + name: username, // TODO: Fetch display name from Matrix profile + type: 'user', + status: UserStatus.ONLINE, + active: true, + roles: ['user'], + requirePasswordChange: false, + federated: true, + federation: { + version: 1, + }, + createdAt: new Date(), + _updatedAt: new Date(), + }; - if (!user) { - logger.info('Creating new federated user:', { username: internalUsername, externalId: data.sender }); - - const userData: Partial = { - username: internalUsername, - name: username, // TODO: Fetch display name from Matrix profile - type: 'user', - status: UserStatus.ONLINE, - active: true, - roles: ['user'], - requirePasswordChange: false, - federated: true, // Mark as federated user - federation: { - version: 1, - }, - createdAt: new Date(), - _updatedAt: new Date(), - }; + const { insertedId } = await Users.insertOne(userData as IUser); - const { insertedId } = await Users.insertOne(userData as IUser); + await MatrixBridgedUser.createOrUpdateByLocalId( + insertedId, + matrixUserId, + true, // isRemote = true for external Matrix users + domain, + ); - await MatrixBridgedUser.createOrUpdateByLocalId( - insertedId, - data.sender, - true, // isRemote = true for external Matrix users - domain, - ); + user = await Users.findOneById(insertedId); + if (!user) { + logger.error('Failed to create user:', matrixUserId); + return null; + } - user = await Users.findOneById(insertedId); - if (!user) { - logger.error('Failed to create user:', internalUsername); - return; - } + logger.info('Successfully created federated user:', { userId: user._id, username }); + } else { + await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, false, domain); + } - logger.info('Successfully created federated user:', { userId: user._id, username }); - } else { - await MatrixBridgedUser.createOrUpdateByLocalId(user._id, data.sender, false, domain); - } + return user; +} - const internalRoomId = await MatrixBridgedRoom.getLocalRoomId(data.room_id); - if (!internalRoomId) { - logger.error('Room not found in bridge mapping:', data.room_id); - // TODO: Handle room creation for unknown federated rooms - return; - } +async function getRoomAndEnsureSubscription(matrixRoomId: string, user: IUser): Promise { + const internalRoomId = await MatrixBridgedRoom.getLocalRoomId(matrixRoomId); + if (!internalRoomId) { + logger.error('Room not found in bridge mapping:', matrixRoomId); + // TODO: Handle room creation for unknown federated rooms + return null; + } - const room = await Rooms.findOneById(internalRoomId); - if (!room) { - logger.error('Room not found:', internalRoomId); - return; - } + const room = await Rooms.findOneById(internalRoomId); + if (!room) { + logger.error('Room not found:', internalRoomId); + return null; + } - if (!room.federated) { - logger.error('Room is not marked as federated:', { roomId: room._id, matrixRoomId: data.room_id }); - // TODO: Should we update the room to be federated? - } + if (!room.federated) { + logger.error('Room is not marked as federated:', { roomId: room._id, matrixRoomId }); + // TODO: Should we update the room to be federated? + } - const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); + const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); - if (!existingSubscription) { - logger.info('Creating subscription for federated user in room:', { userId: user._id, roomId: room._id }); + if (!existingSubscription) { + logger.info('Creating subscription for federated user in room:', { userId: user._id, roomId: room._id }); - const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { - ts: new Date(), - open: false, - alert: false, - unread: 0, - userMentions: 0, - groupMentions: 0, - // Federation status is inherited from room.federated and user.federated - }); + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { + ts: new Date(), + open: false, + alert: false, + unread: 0, + userMentions: 0, + groupMentions: 0, + }); - if (insertedId) { - logger.debug('Successfully created subscription:', insertedId); - // TODO: Import and use notifyOnSubscriptionChangedById if needed - // void notifyOnSubscriptionChangedById(insertedId, 'inserted'); - } + if (insertedId) { + logger.debug('Successfully created subscription:', insertedId); + // TODO: Import and use notifyOnSubscriptionChangedById if needed + } + } + + return room; +} + +async function getThreadMessageId(threadRootEventId: string | undefined): Promise { + if (!threadRootEventId) { + return undefined; + } + + const threadRootMessage = await Messages.findOneByFederationId(threadRootEventId); + if (threadRootMessage) { + logger.debug('Found thread root message:', { tmid: threadRootMessage._id, threadRootEventId }); + return threadRootMessage._id; + } + logger.warn('Thread root message not found for event:', threadRootEventId); + return undefined; +} + +async function handleMediaMessage( + content: any, + msgtype: string, + messageBody: string, + user: IUser, + room: IRoom, + eventId: string, + tmid?: string, +): Promise<{ + fromId: string; + rid: string; + msg: string; + federation_event_id: string; + tmid?: string; + file: any; + files: any[]; + attachments: any[]; +}> { + const fileInfo = content.info || {}; + const mimeType = fileInfo.mimetype; + + const fileRefId = await MatrixMediaService.createRemoteFileReference(content.url, { + name: messageBody, + size: fileInfo.size, + type: mimeType, + roomId: room._id, + userId: user._id, + }); + + const fileName = messageBody; + const fileExtension = fileName.includes('.') ? fileName.split('.').pop()?.toLowerCase() || '' : mimeType.split('/')[1] || ''; + + const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`; + const attachment: any = { + title: fileName, + type: 'file', + title_link: fileUrl, + title_link_download: true, + }; + + if (msgtype === 'm.image') { + attachment.image_url = fileUrl; + attachment.image_type = mimeType; + attachment.image_size = fileInfo.size || 0; + attachment.description = ''; + if (fileInfo.w && fileInfo.h) { + attachment.image_dimensions = { + width: fileInfo.w, + height: fileInfo.h, + }; + } + } else if (msgtype === 'm.video') { + attachment.video_url = fileUrl; + attachment.video_type = mimeType; + attachment.video_size = fileInfo.size || 0; + attachment.description = ''; + } else if (msgtype === 'm.audio') { + attachment.audio_url = fileUrl; + attachment.audio_type = mimeType; + attachment.audio_size = fileInfo.size || 0; + attachment.description = ''; + } else { + attachment.description = ''; + } + + const fileData = { + _id: fileRefId, + name: fileName, + type: mimeType, + size: fileInfo.size || 0, + format: fileExtension, + }; + + logger.info('Sending attachment data to saveMessageWithAttachmentFromFederation', { + attachment: JSON.stringify(attachment, null, 2), + fileData: JSON.stringify(fileData, null, 2), + msgtype, + }); + + return { + fromId: user._id, + rid: room._id, + msg: '', + federation_event_id: eventId, + tmid, + file: fileData, + files: [fileData], + attachments: [attachment], + }; +} + +export function message(emitter: Emitter) { + emitter.on('homeserver.matrix.message', async (data) => { + try { + console.log('on homeserver.matrix.message', data); + + const content = data.content as any; + const msgtype = content?.msgtype; + const messageBody = content?.body?.toString(); + + logger.info('[federation-matrix:message] Received Matrix message event', { + eventId: data.event_id, + sender: data.sender, + roomId: data.room_id, + msgtype, + body: messageBody, + hasUrl: !!content?.url, + url: content?.url, + hasInfo: !!content?.info, + infoSize: content?.info?.size, + infoMimetype: content?.info?.mimetype, + }); + + if (!messageBody && !msgtype) { + logger.debug('No message content found in event'); + return; } - let tmid: string | undefined; - if (isThreadMessage && threadRootEventId) { - const threadRootMessage = await Messages.findOneByFederationId(threadRootEventId); - if (threadRootMessage) { - tmid = threadRootMessage._id; - logger.debug('Found thread root message:', { tmid, threadRootEventId }); - } else { - logger.warn('Thread root message not found for event:', threadRootEventId); - } + // Get or create the federated user + const user = await getOrCreateFederatedUser(data.sender); + if (!user) { + return; } + // Get room and ensure subscription exists + const room = await getRoomAndEnsureSubscription(data.room_id, user); + if (!room) { + return; + } + + // Handle thread messages and relations + const replyToRelation = content?.['m.relates_to']; + const threadRelation = content?.['m.relates_to']; + const isThreadMessage = threadRelation?.rel_type === 'm.thread'; + const isQuoteMessage = replyToRelation?.['m.in_reply_to']?.event_id && !replyToRelation?.is_falling_back; + const threadRootEventId = isThreadMessage ? threadRelation.event_id : undefined; + const tmid = await getThreadMessageId(threadRootEventId); + + // Process the message based on type + const isMediaMessage = ['m.image', 'm.file', 'm.video', 'm.audio'].includes(msgtype); + + // Handle message editing + const localDomain = await getMatrixLocalDomain(); const isEditedMessage = data.content['m.relates_to']?.rel_type === 'm.replace'; if (isEditedMessage && data.content['m.relates_to']?.event_id && data.content['m.new_content']) { logger.debug('Received edited message from Matrix, updating existing message'); @@ -151,8 +288,8 @@ export function message(emitter: Emitter, serverName: const formatted = await toInternalQuoteMessageFormat({ messageToReplyToUrl, formattedMessage: data.content.formatted_body || '', - rawMessage: message, - homeServerDomain: serverName, + rawMessage: messageBody, + homeServerDomain: localDomain, senderExternalId: data.sender, }); await Message.updateMessage( @@ -182,23 +319,25 @@ export function message(emitter: Emitter, serverName: ); return; } + + // Handle quote messages if (isQuoteMessage && room.name) { const originalMessage = await Messages.findOneByFederationId(replyToRelation?.['m.in_reply_to']?.event_id); if (!originalMessage) { - logger.error('Original message not found for edit:', replyToRelation?.['m.in_reply_to']?.event_id); + logger.error('Original message not found for quote:', replyToRelation?.['m.in_reply_to']?.event_id); return; } const messageToReplyToUrl = await MeteorService.getMessageURLToReplyTo(room.t as string, room._id, room.name, originalMessage._id); const formatted = await toInternalQuoteMessageFormat({ messageToReplyToUrl, formattedMessage: data.content.formatted_body || '', - rawMessage: message, - homeServerDomain: serverName, + rawMessage: messageBody, + homeServerDomain: localDomain, senderExternalId: data.sender, }); await Message.saveMessageFromFederation({ fromId: user._id, - rid: internalRoomId, + rid: room._id, msg: formatted, federation_event_id: data.event_id, tmid, @@ -206,19 +345,32 @@ export function message(emitter: Emitter, serverName: return; } - const formatted = await toInternalMessageFormat({ - rawMessage: message, - formattedMessage: data.content.formatted_body || '', - homeServerDomain: serverName, - senderExternalId: data.sender, - }); - await Message.saveMessageFromFederation({ - fromId: user._id, - rid: internalRoomId, - msg: formatted, - federation_event_id: data.event_id, - tmid, - }); + // Handle media and plain messages + if (isMediaMessage && content?.url) { + logger.info('Processing media message from Matrix', { + eventId: data.event_id, + msgtype, + url: content.url, + body: messageBody, + hasInfo: !!content.info, + }); + const result = await handleMediaMessage(content, msgtype, messageBody, user, room, data.event_id, tmid); + await Message.saveMessageFromFederation(result); + } else { + const formatted = toInternalMessageFormat({ + rawMessage: messageBody, + formattedMessage: data.content.formatted_body || '', + homeServerDomain: localDomain, + senderExternalId: data.sender, + }); + await Message.saveMessageFromFederation({ + fromId: user._id, + rid: room._id, + msg: formatted, + federation_event_id: data.event_id, + tmid, + }); + } } catch (error) { logger.error('Error processing Matrix message:', error); } diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts new file mode 100644 index 0000000000000..cd7f0df2f8e94 --- /dev/null +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -0,0 +1,273 @@ +import crypto from 'crypto'; + +import { Upload } from '@rocket.chat/core-services'; +import { Logger } from '@rocket.chat/logger'; +import { Uploads, Subscriptions, Settings } from '@rocket.chat/models'; + +import type { IUploadWithFederation } from '../types/IUploadWithFederation'; + +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}`; + } + + static parseMXCUri(mxcUri: string): { serverName: string; mediaId: string } | null { + const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/); + if (!match) { + logger.error('Invalid MXC URI format', { mxcUri }); + return null; + } + return { + serverName: match[1], + mediaId: match[2], + }; + } + + static async prepareLocalFileForMatrix(fileId: string, serverName: string): Promise { + try { + const file = await Uploads.findOneById(fileId); + if (!file) { + logger.error(`File ${fileId} not found in database`); + throw new Error(`File ${fileId} not found`); + } + + if (file.federation?.mxcUri) { + logger.debug('File already has MXC URI', { + fileId, + mxcUri: file.federation.mxcUri, + }); + return file.federation.mxcUri; + } + + const mxcUri = this.generateMXCUri(fileId, serverName); + + await Uploads.updateOne( + { _id: fileId }, + { + $set: { + 'federation.type': 'matrix', + 'federation.mxcUri': mxcUri, + 'federation.isRemote': false, + 'federation.serverName': serverName, + 'federation.mediaId': fileId, + }, + }, + ); + + return mxcUri; + } catch (error) { + logger.error('Error preparing file for Matrix:', error); + throw error; + } + } + + static async getLocalFileForMatrixNode(mxcUri: string): Promise { + try { + const parts = this.parseMXCUri(mxcUri); + if (!parts) { + return null; + } + + let file = (await Uploads.findOne({ + 'federation.mxcUri': mxcUri, + 'federation.isRemote': false, + })) as IUploadWithFederation | null; + + if (!file) { + file = (await Uploads.findOneById(parts.mediaId)) as IUploadWithFederation | null; + } + + if (!file) { + logger.warn('Local file not found for MXC URI', { mxcUri }); + return null; + } + + return file; + } catch (error) { + logger.error('Error retrieving local file:', error); + return null; + } + } + + static async createI( + mxcUri: string, + metadata: { + name: string; + size: number; + type: string; + messageId?: string; + roomId?: string; + userId?: string; + }, + ): Promise { + try { + const parts = this.parseMXCUri(mxcUri); + if (!parts) { + logger.error('Invalid MXC URI format', { mxcUri }); + throw new Error('Invalid MXC URI'); + } + + const existing = await Uploads.findOne({ + 'federation.mxcUri': mxcUri, + 'federation.isRemote': true, + }); + + if (existing) { + logger.debug('Remote file reference already exists', { + mxcUri, + fileId: existing._id, + }); + return existing._id; + } + + const pseudoId = `matrix_remote_${crypto.randomBytes(16).toString('hex')}`; + + const fileRecord: IUploadWithFederation = { + _id: pseudoId, + name: metadata.name || 'unnamed', + size: metadata.size || 0, + type: metadata.type || 'application/octet-stream', + rid: metadata.roomId, + userId: metadata.userId, + store: 'MatrixRemote:Uploads', + complete: true, + uploading: false, + extension: this.getFileExtension(metadata.name), + progress: 1, + uploadedAt: new Date(), + federation: { + type: 'matrix', + mxcUri, + isRemote: true, + serverName: parts.serverName, + mediaId: parts.mediaId, + }, + _updatedAt: new Date(), + } as any; + + await Uploads.insertOne(fileRecord); + + return pseudoId; + } catch (error) { + logger.error('Error creating remote file reference:', error); + throw error; + } + } + + static async getLocalFileBuffer(fileId: string): Promise { + try { + const fileRecord = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; + if (!fileRecord || (fileRecord as any).federation?.isRemote) { + return null; + } + + return await Upload.getFileBuffer({ file: fileRecord }); + } catch (error) { + logger.error('Error retrieving file buffer:', error); + return null; + } + } + + static async isRemoteFile(fileId: string): Promise { + try { + const file = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; + return (file as any)?.federation?.isRemote === true; + } catch (error) { + logger.error('Error checking if file is remote:', error); + return false; + } + } + + static async getRemoteFileInfo(fileId: string): Promise { + try { + const file = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; + if (!file || !(file as any)?.federation?.isRemote) { + return null; + } + + const fed = (file as any).federation; + return { + name: file.name || '', + size: file.size || 0, + type: file.type || '', + mxcUri: fed.mxcUri || '', + serverName: fed.serverName || '', + mediaId: fed.mediaId || '', + }; + } catch (error) { + logger.error('Error getting remote file info:', error); + return null; + } + } + + static async validateUserAccess(userId: string, fileId: string): Promise { + try { + const file = await Uploads.findOneById(fileId); + if (!file) { + return false; + } + + if (file.rid) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(file.rid, userId); + if (!subscription) { + return false; + } + } + + return true; + } catch (error) { + logger.error('Error validating user access:', error); + return false; + } + } + + static async isUploadEnabled(): Promise { + try { + const fileUploadEnabled = await Settings.getValueById('FileUpload_Enabled'); + return Boolean(fileUploadEnabled); + } catch (error) { + logger.error('Error checking upload settings:', error); + return false; + } + } + + private static getFileExtension(fileName: string): string { + if (!fileName) return ''; + const lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex === -1) { + return ''; + } + return fileName.substring(lastDotIndex + 1).toLowerCase(); + } + + static async cleanupOrphanedReferences(): Promise { + try { + const cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const result = await Uploads.deleteMany({ + 'federation.isRemote': true, + 'uploadedAt': { $lt: cutoffDate }, + 'store': 'MatrixRemote:Uploads', + }); + + if (result.deletedCount > 0) { + logger.debug('Cleaned up orphaned remote file references', { + count: result.deletedCount, + }); + } + } catch (error) { + logger.error('Error cleaning up orphaned references:', error); + } + } +} diff --git a/ee/packages/federation-matrix/src/types/IUploadWithFederation.ts b/ee/packages/federation-matrix/src/types/IUploadWithFederation.ts new file mode 100644 index 0000000000000..6bad6a728db56 --- /dev/null +++ b/ee/packages/federation-matrix/src/types/IUploadWithFederation.ts @@ -0,0 +1,15 @@ +import type { IUpload } from '@rocket.chat/core-typings'; + +export interface IFederationMetadata { + type: 'matrix'; + mxcUri: string; + isRemote: boolean; + serverName?: string; + mediaId?: string; + uploadToken?: string; + origin?: string; +} + +export interface IUploadWithFederation extends IUpload { + federation?: IFederationMetadata; +} diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index a1ea231df3535..7aa5b826fc9bf 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,4 +1,6 @@ -import type { AtLeast, IMessage, IRoomFederated, IUser } from '@rocket.chat/core-typings'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import type { AtLeast, IMessage, IRoomFederated, IUser, IUpload } from '@rocket.chat/core-typings'; import type { Router } from '@rocket.chat/http-router'; export interface IRouteContext { @@ -35,4 +37,5 @@ export interface IFederationMatrixService { role: 'moderator' | 'owner' | 'leader' | 'user', ): Promise; inviteUsersToRoom(room: IRoomFederated, usersUserName: string[], inviter: Pick): Promise; + downloadRemoteFile(file: IUpload, req: IncomingMessage, res: ServerResponse): Promise; } diff --git a/packages/core-services/src/types/IMessageService.ts b/packages/core-services/src/types/IMessageService.ts index 0b2e5a743b11f..521ca0c3544c6 100644 --- a/packages/core-services/src/types/IMessageService.ts +++ b/packages/core-services/src/types/IMessageService.ts @@ -15,12 +15,18 @@ export interface IMessageService { msg, federation_event_id, tmid, + file, + files, + attachments, }: { fromId: string; rid: string; msg: string; federation_event_id: string; tmid?: string; + file?: IMessage['file']; + files?: IMessage['files']; + attachments?: IMessage['attachments']; }): Promise; saveSystemMessageAndNotifyUser( type: MessageTypesValues, diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 8bcabee2fa8de..43a77add82a20 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -59,6 +59,12 @@ export interface IUpload { hashes?: { sha256: string; }; + federation?: { + type: string; + mxcUri?: string; + uploadToken?: string; + origin?: string; + }; } export type IUploadWithUser = IUpload & { user?: Pick }; From f4f98a30607ed859596e0fa440754878ff66976b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 1 Sep 2025 08:27:53 -0300 Subject: [PATCH 02/33] chore: kek --- .../federation-matrix/src/services/MatrixMediaService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index cd7f0df2f8e94..51d6d5c527d0a 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -100,7 +100,7 @@ export class MatrixMediaService { } } - static async createI( + static async createRemoteFileReference( mxcUri: string, metadata: { name: string; @@ -189,7 +189,7 @@ export class MatrixMediaService { } } - static async getRemoteFileInfo(fileId: string): Promise { + static async getRemoteFileInfo(fileId: string): Promise { try { const file = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; if (!file || !(file as any)?.federation?.isRemote) { From 19e024efffd01410c247b230ad5c10d36220769e Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 8 Sep 2025 12:05:37 -0300 Subject: [PATCH 03/33] chore: fixing conflicts --- .../server/services/messages/service.ts | 9 -------- .../federation-matrix/src/FederationMatrix.ts | 4 ++-- .../src/api/_matrix/media.ts | 6 ++--- .../federation-matrix/src/events/message.ts | 9 ++++---- yarn.lock | 23 +++++++++++-------- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index e046b905e4676..29767fe0f32a5 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -91,18 +91,12 @@ export class MessageService extends ServiceClassInternal implements IMessageServ msg, federation_event_id, tmid, - file, - files, - attachments, }: { fromId: string; rid: string; msg: string; federation_event_id: string; tmid?: string; - file?: IMessage['file']; - files?: IMessage['files']; - attachments?: IMessage['attachments']; }): Promise { const threadParams = tmid ? { tmid, tshow: true } : {}; return executeSendMessage(fromId, { @@ -110,9 +104,6 @@ export class MessageService extends ServiceClassInternal implements IMessageServ msg, ...threadParams, federation: { eventId: federation_event_id }, - file, - files, - attachments, }); } diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 0488c0f6fd208..a491628037116 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -633,9 +633,9 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS let result; if (message.files && message.files.length > 0) { - result = await this.handleFileMessage(message, matrixRoomId, actualMatrixUserId, matrixDomain); + result = await this.handleFileMessage(message, matrixRoomId, actualMatrixUserId, this.serverName); } else { - result = await this.handleTextMessage(message, matrixRoomId, actualMatrixUserId, matrixDomain); + result = await this.handleTextMessage(message, matrixRoomId, actualMatrixUserId, this.serverName); } if (!result) { diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index c66e4a0599c47..645dce990a4bd 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -49,7 +49,7 @@ async function verifyMatrixSignature( ): Promise<{ isValid: boolean; origin?: string; error?: string }> { try { const { origin, destination, key, signature } = extractSignaturesFromHeader(authHeader); - const ourServerName = homeserverServices.config.getServerConfig().name; + const ourServerName = homeserverServices.config.serverName; if (destination !== ourServerName) { return { @@ -248,7 +248,7 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => async (context: any) => { try { const { mediaId } = context.req.param(); - const serverName = config.getServerConfig().name; + const { serverName} = config; const authResult = await handleFederationAuth(context, homeserverServices); if (!authResult.isValid) { @@ -303,7 +303,7 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => async (context: any) => { try { const { mediaId } = context.req.param(); - const serverName = config.getServerConfig().name; + const { serverName } = config; const authResult = await handleFederationAuth(context, homeserverServices); if (!authResult.isValid) { diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index 3aa84ac337e6e..c85e28a111ec6 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -210,7 +210,7 @@ async function handleMediaMessage( }; } -export function message(emitter: Emitter) { +export function message(emitter: Emitter, serverName: string) { emitter.on('homeserver.matrix.message', async (data) => { try { console.log('on homeserver.matrix.message', data); @@ -261,7 +261,6 @@ export function message(emitter: Emitter) { const isMediaMessage = ['m.image', 'm.file', 'm.video', 'm.audio'].includes(msgtype); // Handle message editing - const localDomain = await getMatrixLocalDomain(); const isEditedMessage = data.content['m.relates_to']?.rel_type === 'm.replace'; if (isEditedMessage && data.content['m.relates_to']?.event_id && data.content['m.new_content']) { logger.debug('Received edited message from Matrix, updating existing message'); @@ -289,7 +288,7 @@ export function message(emitter: Emitter) { messageToReplyToUrl, formattedMessage: data.content.formatted_body || '', rawMessage: messageBody, - homeServerDomain: localDomain, + homeServerDomain: serverName, senderExternalId: data.sender, }); await Message.updateMessage( @@ -332,7 +331,7 @@ export function message(emitter: Emitter) { messageToReplyToUrl, formattedMessage: data.content.formatted_body || '', rawMessage: messageBody, - homeServerDomain: localDomain, + homeServerDomain: serverName, senderExternalId: data.sender, }); await Message.saveMessageFromFederation({ @@ -360,7 +359,7 @@ export function message(emitter: Emitter) { const formatted = toInternalMessageFormat({ rawMessage: messageBody, formattedMessage: data.content.formatted_body || '', - homeServerDomain: localDomain, + homeServerDomain: serverName, senderExternalId: data.sender, }); await Message.saveMessageFromFederation({ diff --git a/yarn.lock b/yarn.lock index a29db1af1cab5..0af19cbe1263c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28141,15 +28141,6 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^5.1.5": - version: 5.1.5 - resolution: "nanoid@npm:5.1.5" - bin: - nanoid: bin/nanoid.js - checksum: 10/6de2d006b51c983be385ef7ee285f7f2a57bd96f8c0ca881c4111461644bd81fafc2544f8e07cb834ca0f3e0f3f676c1fe78052183f008b0809efe6e273119f5 - languageName: node - linkType: hard - "napi-build-utils@npm:^2.0.0": version: 2.0.0 resolution: "napi-build-utils@npm:2.0.0" @@ -33273,6 +33264,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^4.0.0": + version: 4.0.0 + resolution: "secure-json-parse@npm:4.0.0" + checksum: 10/c36c9dec9afaf4ef929a5469995d70d2f20d3d89b57219f22e0349b342715987283dbc1a80ab6f39e0bb28f8c3f3f073ce5363765c20c8d003ac243b4a89bd3d + languageName: node + linkType: hard + "seek-bzip@npm:^1.0.5": version: 1.0.6 resolution: "seek-bzip@npm:1.0.6" @@ -34794,6 +34792,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^5.0.2": + version: 5.0.3 + resolution: "strip-json-comments@npm:5.0.3" + checksum: 10/3ccbf26f278220f785e4b71f8a719a6a063d72558cc63cb450924254af258a4f4c008b8c9b055373a680dc7bd525be9e543ad742c177f8a7667e0b726258e0e4 + languageName: node + linkType: hard + "strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" From 876a36bba700d4fe56fdaabe4951ca770700e02e Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 8 Sep 2025 13:09:03 -0300 Subject: [PATCH 04/33] chore: fixing conflicts --- ee/packages/federation-matrix/src/api/_matrix/media.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index 645dce990a4bd..07e56bebd7eab 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -248,7 +248,7 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => async (context: any) => { try { const { mediaId } = context.req.param(); - const { serverName} = config; + const { serverName } = config; const authResult = await handleFederationAuth(context, homeserverServices); if (!authResult.isValid) { From ce6ff01186a2b46907da135df65de32382889003 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 16:23:12 -0300 Subject: [PATCH 05/33] refactor: removes MatrixRemote file store --- .gitignore | 3 ++- .../app/file-upload/server/config/MatrixRemote.ts | 15 --------------- .../server/config/_configUploadStorage.ts | 1 - 3 files changed, 2 insertions(+), 17 deletions(-) delete mode 100644 apps/meteor/app/file-upload/server/config/MatrixRemote.ts diff --git a/.gitignore b/.gitignore index 819869a58a86f..296c657d2a976 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ registration.yaml storybook-static development/tempo-data/ -homeserver \ No newline at end of file +.bmad-core +.claude diff --git a/apps/meteor/app/file-upload/server/config/MatrixRemote.ts b/apps/meteor/app/file-upload/server/config/MatrixRemote.ts deleted file mode 100644 index 79c896788791e..0000000000000 --- a/apps/meteor/app/file-upload/server/config/MatrixRemote.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FederationMatrix } from '@rocket.chat/core-services'; -import { Uploads } from '@rocket.chat/models'; - -import { FileUploadClass } from '../lib/FileUpload'; - -const MatrixRemoteHandler = new FileUploadClass({ - name: 'MatrixRemote:Uploads', - model: Uploads, - - async get(file, req, res) { - await FederationMatrix.downloadRemoteFile(file, req, res); - }, -}); - -export { MatrixRemoteHandler }; diff --git a/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts b/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts index 26c643bfd49c1..55cc1afbbab16 100644 --- a/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts +++ b/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts @@ -7,7 +7,6 @@ import './AmazonS3'; import './FileSystem'; import './GoogleStorage'; import './GridFS'; -import './MatrixRemote'; import './Webdav'; const configStore = _.debounce(() => { From d6902941235c236c6a5c62521ff7eaa132e739e8 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 16:27:46 -0300 Subject: [PATCH 06/33] refactor: removes unused code --- .../federation-matrix/src/FederationMatrix.ts | 232 ------------------ .../src/types/IFederationMatrixService.ts | 5 +- 2 files changed, 1 insertion(+), 236 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index a491628037116..81cd8b4913fe4 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -32,13 +32,6 @@ import { MatrixMediaService } from './services/MatrixMediaService'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; - // Media related constants - private static readonly FETCH_TIMEOUT = 30000; - - private static readonly CACHE_MAX_AGE = 86400; - - private static readonly USER_AGENT = 'RocketChat-Federation/1.0'; - private eventHandler: Emitter; private homeserverServices: HomeserverServices; @@ -471,26 +464,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } try { - // For now, handle only the first file since RC typically sends one file per message - // If multiple files need to be sent, each should be a separate Matrix message const file = message.files[0]; - const mxcUri = await this.prepareFileForMatrix(file, matrixDomain); - const fileContent = this.buildFileMessageContent(file, mxcUri); - const result = await this.homeserverServices.message.sendFileMessage(matrixRoomId, fileContent, matrixUserId); - if (result) { - this.logger.info('Successfully sent file message to Matrix', { - messageId: message._id, - fileId: file._id, - fileName: file.name, - mxcUri, - eventId: result.eventId, - }); - } - if (message.files.length > 1) { this.logger.warn('Message contains multiple files, but only the first was sent to Matrix', { messageId: message._id, @@ -1075,214 +1053,4 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } await this.homeserverServices.room.setPowerLevelForUser(matrixRoomId, senderMatrixUserId, matrixUserId, powerLevel); } - - private validateRemoteFile(file: any): { - isValid: boolean; - error?: string; - mxcUri?: string; - serverName?: string; - mediaId?: string; - } { - const mxcUri = file.federation?.mxcUri; - const serverName = file.federation?.serverName; - const mediaId = file.federation?.mediaId; - - if (!mxcUri || !serverName || !mediaId) { - return { - isValid: false, - error: 'Remote file metadata missing', - }; - } - - return { - isValid: true, - mxcUri, - serverName, - mediaId, - }; - } - - private parseMxcUri( - mxcUri: string, - serverName: string, - mediaId: string, - ): { - originServer: string; - actualMediaId: string; - } { - const mxcParts = mxcUri.match(/^mxc:\/\/([^\/]+)\/(.+)$/); - return { - originServer: mxcParts ? mxcParts[1] : serverName, - actualMediaId: mxcParts ? mxcParts[2] : mediaId, - }; - } - - private buildMatrixMediaEndpoints( - originServer: string, - mediaId: string, - ): Array<{ - url: string; - name: string; - headers: Record; - }> { - const endpoints = [ - { - url: `https://${originServer}/_matrix/media/v1/download/${originServer}/${mediaId}`, - name: 'media_v1_https', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - { - url: `https://${originServer}/_matrix/media/v3/download/${originServer}/${mediaId}`, - name: 'media_v3_https', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - { - url: `http://${originServer}/_matrix/media/v3/download/${originServer}/${mediaId}`, - name: 'media_v3_http', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - { - url: `https://${originServer}/_matrix/media/r0/download/${originServer}/${mediaId}`, - name: 'media_r0_https', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - { - url: `http://${originServer}/_matrix/media/r0/download/${originServer}/${mediaId}`, - name: 'media_r0_http', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - { - url: `https://${originServer}/_matrix/client/v1/media/download/${originServer}/${mediaId}`, - name: 'client_v1_https', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - { - url: `http://${originServer}/_matrix/client/v1/media/download/${originServer}/${mediaId}`, - name: 'client_v1_http', - headers: { 'User-Agent': FederationMatrix.USER_AGENT, 'Accept': '*/*' }, - }, - ]; - - return endpoints; - } - - private async createHttpAgent(isHttps: boolean): Promise { - if (isHttps) { - return { - agent: new (await import('https')).Agent({ - rejectUnauthorized: false, - }), - }; - } - return { - agent: new (await import('http')).Agent({ - keepAlive: true, - }), - }; - } - - private async fetchFromEndpoints(endpoints: Array<{ url: string; name: string; headers: Record }>): Promise<{ - response: any | null; - lastError: any; - }> { - const fetch = (await import('node-fetch')).default; - let response: any = null; - let lastError: any = null; - - for await (const endpoint of endpoints) { - this.logger.info(`Trying ${endpoint.name} endpoint`, { - url: endpoint.url, - method: 'GET', - headers: endpoint.headers, - }); - - try { - const isHttps = endpoint.url.startsWith('https://'); - const agentOptions = await this.createHttpAgent(isHttps); - - response = await fetch(endpoint.url, { - method: 'GET', - headers: endpoint.headers, - timeout: FederationMatrix.FETCH_TIMEOUT, - ...agentOptions, - }); - - if (response.ok) { - this.logger.info(`Successfully fetched file via ${endpoint.name}`, { - status: response.status, - endpoint: endpoint.name, - url: endpoint.url, - }); - break; - } - - lastError = `${endpoint.name}: ${response.status} ${response.statusText}`; - } catch (fetchError: any) { - this.logger.warn(`Failed to fetch from ${endpoint.name}`, { - error: fetchError.message, - code: fetchError.code, - }); - lastError = fetchError; - } - } - - return { response, lastError }; - } - - private streamResponseToClient(response: any, res: any, file: any): void { - const contentType = response.headers.get('content-type') || file.type || 'application/octet-stream'; - const contentLength = response.headers.get('content-length'); - - res.setHeader('Content-Type', contentType); - if (contentLength) { - res.setHeader('Content-Length', contentLength); - } - res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.name || '')}"`); - res.setHeader('Cache-Control', `public, max-age=${FederationMatrix.CACHE_MAX_AGE}`); - - response.body?.pipe(res); - } - - /** - * Download and stream a remote Matrix file to the client - * This method handles proxying remote Matrix files to Rocket.Chat clients - */ - async downloadRemoteFile(file: any, _req: any, res: any): Promise { - try { - const validation = this.validateRemoteFile(file); - if (!validation.isValid) { - this.logger.error('Invalid remote file metadata', { - error: validation.error, - federation: file.federation, - }); - res.writeHead(404); - res.end(validation.error); - return; - } - - const { mxcUri, serverName, mediaId } = validation; - const { originServer, actualMediaId } = this.parseMxcUri(mxcUri!, serverName!, mediaId!); - - const endpoints = this.buildMatrixMediaEndpoints(originServer, actualMediaId); - - const { response, lastError } = await this.fetchFromEndpoints(endpoints); - if (!response || !response.ok) { - this.logger.error('Failed to fetch remote file from all endpoints', { - lastError, - mxcUri, - originServer, - actualMediaId, - }); - res.writeHead(404); - res.end(`Failed to fetch remote file: ${lastError}`); - return; - } - - this.streamResponseToClient(response, res, file); - } catch (error) { - this.logger.error('Error handling remote Matrix file download:', error); - res.writeHead(500); - res.end('Internal server error'); - } - } } diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 7aa5b826fc9bf..a1ea231df3535 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,6 +1,4 @@ -import type { IncomingMessage, ServerResponse } from 'http'; - -import type { AtLeast, IMessage, IRoomFederated, IUser, IUpload } from '@rocket.chat/core-typings'; +import type { AtLeast, IMessage, IRoomFederated, IUser } from '@rocket.chat/core-typings'; import type { Router } from '@rocket.chat/http-router'; export interface IRouteContext { @@ -37,5 +35,4 @@ export interface IFederationMatrixService { role: 'moderator' | 'owner' | 'leader' | 'user', ): Promise; inviteUsersToRoom(room: IRoomFederated, usersUserName: string[], inviter: Pick): Promise; - downloadRemoteFile(file: IUpload, req: IncomingMessage, res: ServerResponse): Promise; } From 5b5822e097e856ad9f75403fc95b60670fa0641d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 16:47:38 -0300 Subject: [PATCH 07/33] refactor: makes files be sent into rc messages --- .../server/services/messages/service.ts | 9 + .../src/api/_matrix/media.ts | 138 +----------- .../federation-matrix/src/events/message.ts | 22 +- .../src/services/MatrixMediaService.ts | 206 +++++++----------- .../model-typings/src/models/IUploadsModel.ts | 2 + packages/models/src/models/Uploads.ts | 4 + 6 files changed, 104 insertions(+), 277 deletions(-) diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index 29767fe0f32a5..f90ece3ee56a9 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -91,12 +91,18 @@ export class MessageService extends ServiceClassInternal implements IMessageServ msg, federation_event_id, tmid, + file, + files, + attachments, }: { fromId: string; rid: string; msg: string; federation_event_id: string; tmid?: string; + file?: IMessage['file']; + files?: IMessage['files']; + attachments?: IMessage['attachments']; }): Promise { const threadParams = tmid ? { tmid, tshow: true } : {}; return executeSendMessage(fromId, { @@ -104,6 +110,9 @@ export class MessageService extends ServiceClassInternal implements IMessageServ msg, ...threadParams, federation: { eventId: federation_event_id }, + ...(file && { file }), + ...(files && { files }), + ...(attachments && { attachments }), }); } diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index 07e56bebd7eab..3dcff28bee5c7 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -1,6 +1,5 @@ import crypto from 'crypto'; -import { extractSignaturesFromHeader, validateAuthorizationHeader } from '@hs/core'; import type { HomeserverServices } from '@hs/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; @@ -11,7 +10,7 @@ import { MatrixMediaService } from '../../services/MatrixMediaService'; import type { IUploadWithFederation } from '../../types/IUploadWithFederation'; const logger = new Logger('federation-matrix:media'); -const ENFORCE_FEDERATION_VERIFICATION = process.env.ENFORCE_FEDERATION_VERIFICATION === 'true'; + const MediaDownloadParamsSchema = { type: 'object', properties: { @@ -39,90 +38,6 @@ const isMediaDownloadParamsProps = ajv.compile(MediaDownloadParamsSchema); const isErrorResponseProps = ajv.compile(ErrorResponseSchema); const isBufferResponseProps = ajv.compile(BufferResponseSchema); -// TODO: Move to homeserver -async function verifyMatrixSignature( - homeserverServices: HomeserverServices, - authHeader: string, - method: string, - uri: string, - body?: any, -): Promise<{ isValid: boolean; origin?: string; error?: string }> { - try { - const { origin, destination, key, signature } = extractSignaturesFromHeader(authHeader); - const ourServerName = homeserverServices.config.serverName; - - if (destination !== ourServerName) { - return { - isValid: false, - error: `Destination mismatch: expected ${ourServerName}, got ${destination}`, - }; - } - - let publicKey: string; - try { - const keyResponse = await fetch(`https://${origin}/_matrix/key/v2/server`); - if (!keyResponse.ok) { - throw new Error(`Failed to fetch keys from ${origin}: ${keyResponse.status}`); - } - - const keyData = (await keyResponse.json()) as any; - if (!keyData.verify_keys || !keyData.verify_keys[key]) { - throw new Error(`Key ${key} not found in response from ${origin}`); - } - - publicKey = keyData.verify_keys[key].key; - } catch (fetchError) { - logger.error('Failed to fetch public key from origin server', { - origin, - keyId: key, - error: fetchError instanceof Error ? fetchError.message : 'Unknown error', - }); - - if (!ENFORCE_FEDERATION_VERIFICATION) { - logger.warn('Allowing request despite key fetch failure (development mode)'); - return { isValid: true, origin }; - } - return { isValid: false, error: 'Failed to fetch public key from origin server' }; - } - - try { - const isValid = await validateAuthorizationHeader(origin, publicKey, destination, method, uri, signature, body); - if (isValid) { - logger.info('X-Matrix signature verified successfully', { origin, keyId: key }); - return { isValid: true, origin }; - } - logger.warn('X-Matrix signature validation returned false', { origin, keyId: key }); - } catch (validationError) { - logger.warn('X-Matrix signature verification failed', { - origin, - keyId: key, - method, - uri, - destination, - error: validationError instanceof Error ? validationError.message : 'Unknown error', - }); - - if (!ENFORCE_FEDERATION_VERIFICATION) { - logger.warn('Allowing request despite verification failure (development mode)'); - return { isValid: true, origin }; - } - return { isValid: false, error: 'Invalid signature' }; - } - - return { isValid: false, error: 'Signature verification failed' }; - } catch (error) { - logger.error('Error during X-Matrix signature verification', { - error, - errorMessage: error instanceof Error ? error.message : 'Unknown error', - errorStack: error instanceof Error ? error.stack : undefined, - }); - return { - isValid: false, - error: error instanceof Error ? error.message : 'Signature verification error', - }; - } -} - function addSecurityHeaders(headers: Record): Record { return { ...headers, @@ -189,49 +104,10 @@ async function getMediaFile( return { file, buffer }; } -async function handleFederationAuth( - context: any, - homeserverServices: HomeserverServices, -): Promise<{ isValid: boolean; requestingServer: string; errorResponse?: any }> { - const authHeader = context.req.header('Authorization'); - let requestingServer = 'unknown'; - - if (!authHeader || (!authHeader.startsWith('X-Matrix') && !ENFORCE_FEDERATION_VERIFICATION)) { - return { isValid: true, requestingServer }; - } - - const verificationResult = await verifyMatrixSignature( - homeserverServices, - authHeader, - context.req.method, - context.req.path, - context.req.body, - ); - - if (!verificationResult.isValid) { - return { - isValid: false, - requestingServer, - errorResponse: { - statusCode: 401, - body: { - errcode: 'M_UNAUTHORIZED', - error: verificationResult.error || 'Invalid X-Matrix signature', - }, - }, - }; - } - - requestingServer = verificationResult.origin || 'unknown'; - - return { isValid: true, requestingServer }; -} - export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => { const { config } = homeserverServices; const router = new Router('/federation'); - // Federation V1 Download Endpoint router.get( '/v1/media/download/:mediaId', { @@ -250,11 +126,6 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => const { mediaId } = context.req.param(); const { serverName } = config; - const authResult = await handleFederationAuth(context, homeserverServices); - if (!authResult.isValid) { - return authResult.errorResponse; - } - const { file, buffer } = await getMediaFile(mediaId, serverName); if (!file || !buffer) { return { @@ -265,6 +136,7 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => const mimeType = file.type || 'application/octet-stream'; const fileName = file.name || mediaId; + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName, { 'content-type': mimeType, 'content-length': buffer.length, @@ -288,7 +160,6 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => }, ); - // Federation V1 Thumbnail Endpoint router.get( '/v1/media/thumbnail/:mediaId', { @@ -305,11 +176,6 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => const { mediaId } = context.req.param(); const { serverName } = config; - const authResult = await handleFederationAuth(context, homeserverServices); - if (!authResult.isValid) { - return authResult.errorResponse; - } - const { file, buffer } = await getMediaFile(mediaId, serverName); if (!file || !buffer) { return { diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index c85e28a111ec6..0656525e40df9 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -137,10 +137,11 @@ async function handleMediaMessage( files: any[]; attachments: any[]; }> { - const fileInfo = content.info || {}; + const fileInfo = content.info; const mimeType = fileInfo.mimetype; + const fileName = messageBody; - const fileRefId = await MatrixMediaService.createRemoteFileReference(content.url, { + const fileRefId = await MatrixMediaService.downloadAndStoreRemoteFile(content.url, { name: messageBody, size: fileInfo.size, type: mimeType, @@ -148,8 +149,15 @@ async function handleMediaMessage( userId: user._id, }); - const fileName = messageBody; - const fileExtension = fileName.includes('.') ? fileName.split('.').pop()?.toLowerCase() || '' : mimeType.split('/')[1] || ''; + let fileExtension = ''; + if (fileName && fileName.includes('.')) { + fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; + } else if (mimeType && mimeType.includes('/')) { + fileExtension = mimeType.split('/')[1] || ''; + if (fileExtension === 'jpeg') { + fileExtension = 'jpg'; + } + } const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`; const attachment: any = { @@ -192,12 +200,6 @@ async function handleMediaMessage( format: fileExtension, }; - logger.info('Sending attachment data to saveMessageWithAttachmentFromFederation', { - attachment: JSON.stringify(attachment, null, 2), - fileData: JSON.stringify(fileData, null, 2), - msgtype, - }); - return { fromId: user._id, rid: room._id, diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 51d6d5c527d0a..fa58e04851f33 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -1,8 +1,7 @@ -import crypto from 'crypto'; - import { Upload } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; -import { Uploads, Subscriptions, Settings } from '@rocket.chat/models'; +import { Uploads } from '@rocket.chat/models'; +import fetch from 'node-fetch'; import type { IUploadWithFederation } from '../types/IUploadWithFederation'; @@ -100,7 +99,7 @@ export class MatrixMediaService { } } - static async createRemoteFileReference( + static async downloadAndStoreRemoteFile( mxcUri: string, metadata: { name: string; @@ -118,156 +117,101 @@ export class MatrixMediaService { throw new Error('Invalid MXC URI'); } - const existing = await Uploads.findOne({ - 'federation.mxcUri': mxcUri, - 'federation.isRemote': true, - }); - - if (existing) { - logger.debug('Remote file reference already exists', { - mxcUri, - fileId: existing._id, - }); - return existing._id; + const uploadAlreadyExists = await Uploads.findByFederationMxcUri(mxcUri); + if (uploadAlreadyExists) { + return uploadAlreadyExists._id; } - const pseudoId = `matrix_remote_${crypto.randomBytes(16).toString('hex')}`; - - const fileRecord: IUploadWithFederation = { - _id: pseudoId, - name: metadata.name || 'unnamed', - size: metadata.size || 0, - type: metadata.type || 'application/octet-stream', - rid: metadata.roomId, - userId: metadata.userId, - store: 'MatrixRemote:Uploads', - complete: true, - uploading: false, - extension: this.getFileExtension(metadata.name), - progress: 1, - uploadedAt: new Date(), - federation: { - type: 'matrix', - mxcUri, - isRemote: true, - serverName: parts.serverName, - mediaId: parts.mediaId, + const buffer = await this.downloadFromMatrixServer(parts.serverName, parts.mediaId); + + const uploadedFile = await Upload.uploadFile({ + userId: metadata.userId || 'federation', + buffer, + details: { + name: metadata.name || 'unnamed', + size: buffer.length, + type: metadata.type || 'application/octet-stream', + rid: metadata.roomId, + userId: metadata.userId || 'federation', }, - _updatedAt: new Date(), - } as any; + }); - await Uploads.insertOne(fileRecord); + await Uploads.updateOne( + { _id: uploadedFile._id }, + { + $set: { + 'federation.type': 'matrix', + 'federation.mxcUri': mxcUri, + 'federation.serverName': parts.serverName, + 'federation.mediaId': parts.mediaId, + 'federation.originalUrl': mxcUri, + }, + }, + ); - return pseudoId; + return uploadedFile._id; } catch (error) { - logger.error('Error creating remote file reference:', error); + logger.error('Error downloading and storing remote file:', error); throw error; } } - static async getLocalFileBuffer(fileId: string): Promise { + private static async downloadFromMatrixServer(serverName: string, mediaId: string): Promise { try { - const fileRecord = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; - if (!fileRecord || (fileRecord as any).federation?.isRemote) { - return null; + // try different endpoints in order of preference + // according to MSC3916, new authenticated federation endpoints don't include server name in path + // first try new authenticated federation endpoints, then fall back to legacy endpoints + const endpoints = [ + `https://${serverName}/_matrix/federation/v1/media/download/${mediaId}`, + `https://${serverName}/federation/v1/media/download/${mediaId}`, + // legacy endpoints (deprecated but still needed for backwards compatibility) + `https://${serverName}/_matrix/media/v3/download/${serverName}/${mediaId}`, + `https://${serverName}/_matrix/media/r0/download/${serverName}/${mediaId}`, + ]; + + for await (const endpoint of endpoints) { + try { + const response = await fetch(endpoint, { + method: 'GET', + timeout: 15000, // 15 seconds timeout per endpoint + headers: { + 'User-Agent': 'Rocket.Chat Federation', + 'Accept': '*/*', + }, + }); + + if (response.ok) { + return response.buffer(); + } + + logger.debug('Non-OK response from endpoint', { + endpoint, + status: response.status, + statusText: response.statusText, + }); + } catch (error) { + logger.error('Error downloading from Matrix server:', error); + } } - return await Upload.getFileBuffer({ file: fileRecord }); - } catch (error) { - logger.error('Error retrieving file buffer:', error); - return null; - } - } - - static async isRemoteFile(fileId: string): Promise { - try { - const file = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; - return (file as any)?.federation?.isRemote === true; + throw new Error(`Failed to download file from Matrix server ${serverName}/${mediaId}`); } catch (error) { - logger.error('Error checking if file is remote:', error); - return false; + logger.error('Error downloading from Matrix server:', error); + throw error; } } - static async getRemoteFileInfo(fileId: string): Promise { + static async getLocalFileBuffer(fileId: string): Promise { try { - const file = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; - if (!file || !(file as any)?.federation?.isRemote) { + const fileRecord = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; + if (!fileRecord || (fileRecord as any).federation?.isRemote) { return null; } - const fed = (file as any).federation; - return { - name: file.name || '', - size: file.size || 0, - type: file.type || '', - mxcUri: fed.mxcUri || '', - serverName: fed.serverName || '', - mediaId: fed.mediaId || '', - }; + return await Upload.getFileBuffer({ file: fileRecord }); } catch (error) { - logger.error('Error getting remote file info:', error); + logger.error('Error retrieving file buffer:', error); return null; } } - - static async validateUserAccess(userId: string, fileId: string): Promise { - try { - const file = await Uploads.findOneById(fileId); - if (!file) { - return false; - } - - if (file.rid) { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(file.rid, userId); - if (!subscription) { - return false; - } - } - - return true; - } catch (error) { - logger.error('Error validating user access:', error); - return false; - } - } - - static async isUploadEnabled(): Promise { - try { - const fileUploadEnabled = await Settings.getValueById('FileUpload_Enabled'); - return Boolean(fileUploadEnabled); - } catch (error) { - logger.error('Error checking upload settings:', error); - return false; - } - } - - private static getFileExtension(fileName: string): string { - if (!fileName) return ''; - const lastDotIndex = fileName.lastIndexOf('.'); - if (lastDotIndex === -1) { - return ''; - } - return fileName.substring(lastDotIndex + 1).toLowerCase(); - } - - static async cleanupOrphanedReferences(): Promise { - try { - const cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - const result = await Uploads.deleteMany({ - 'federation.isRemote': true, - 'uploadedAt': { $lt: cutoffDate }, - 'store': 'MatrixRemote:Uploads', - }); - - if (result.deletedCount > 0) { - logger.debug('Cleaned up orphaned remote file references', { - count: result.deletedCount, - }); - } - } catch (error) { - logger.error('Error cleaning up orphaned references:', error); - } - } } diff --git a/packages/model-typings/src/models/IUploadsModel.ts b/packages/model-typings/src/models/IUploadsModel.ts index 1e80fcfe39b52..df8587fcccd9a 100644 --- a/packages/model-typings/src/models/IUploadsModel.ts +++ b/packages/model-typings/src/models/IUploadsModel.ts @@ -14,4 +14,6 @@ export interface IUploadsModel extends IBaseUploadsModel { uploadedAt?: Date, options?: Omit, 'sort'>, ): FindPaginated>>; + + findByFederationMxcUri(mxcUri: string): Promise; } diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index fc1b72bce53b1..3c8f5cc1ad419 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -47,6 +47,10 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { }); } + findByFederationMxcUri(mxcUri: string): Promise { + return this.findOne({ 'federation.mxcUri': mxcUri }); + } + findPaginatedWithoutThumbs(query: Filter = {}, options?: FindOptions): FindPaginated>> { return this.findPaginated( { From 063ab06874bb8c735ce7e108c1138011de14fbcb Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 17:23:26 -0300 Subject: [PATCH 08/33] chore: adds homeserver back to .gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 296c657d2a976..819869a58a86f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,5 +60,4 @@ registration.yaml storybook-static development/tempo-data/ -.bmad-core -.claude +homeserver \ No newline at end of file From 80dff5272dd33fda42b4703f0ae86ad4e30bcccd Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 17:39:48 -0300 Subject: [PATCH 09/33] chore: improves code style --- .../app/file-upload/server/lib/FileUpload.ts | 6 --- .../federation-matrix/src/FederationMatrix.ts | 37 ++----------------- .../src/models/IMessagesModel.ts | 2 + packages/models/src/models/Messages.ts | 17 +++++++++ 4 files changed, 22 insertions(+), 40 deletions(-) diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 769cfbd0c1a21..73f67fb473744 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -440,12 +440,6 @@ export const FileUpload = { return true; } - // Allow MatrixRemote files to bypass normal access control - // They are handled by federation-specific logic in the onRead method - if (file?.store === 'MatrixRemote:Uploads') { - return true; - } - const { query } = URL.parse(url, true); // eslint-disable-next-line @typescript-eslint/naming-convention let { rc_uid, rc_token, rc_rid, rc_room_type } = query as Record; diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 81cd8b4913fe4..5e3aba1c0e580 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -422,20 +422,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS info: { mimetype?: string; size?: number; - // TODO: Add thumbnail support when RC provides thumbnail metadata - // thumbnail_url?: string; - // thumbnail_info?: { - // mimetype?: string; - // size?: number; - // w?: number; - // h?: number; - // }; }; } { const msgtype = this.determineFileMessageType(file.type); - - const content: ReturnType = { - body: file.name || 'file', + return { + body: file.name, msgtype, url: mxcUri, info: { @@ -443,14 +434,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS size: file.size, }, }; - - // Note: Rocket.Chat doesn't provide separate thumbnail metadata in the file object - // If we need thumbnail support, we'd need to either: - // 1. Generate thumbnails on-the-fly for images/videos - // 2. Use a pre-generated thumbnail if RC provides it in the future - // 3. Link to RC's thumbnail endpoint if available - - return content; } private async handleFileMessage( @@ -499,17 +482,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS homeServerDomain: matrixDomain, }); - // Check if this is a threaded message if (message.tmid) { return this.handleThreadedMessage(message, matrixRoomId, matrixUserId, matrixDomain, parsedMessage); } - // Check if this is a quote/reply message if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { return this.handleQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); } - // Send regular message return this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, matrixUserId); } @@ -525,25 +505,15 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (!threadRootEventId) { this.logger.warn('Thread root event ID not found, sending as regular message'); - // Fall back to regular message or quote message if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { return this.handleQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); } return this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, matrixUserId); } - // Get the latest thread message for proper threading - const latestThreadMessage = await Messages.findOne( - { - 'tmid': message.tmid, - 'federation.eventId': { $exists: true }, - '_id': { $ne: message._id }, - }, - { sort: { ts: -1 } }, - ); + const latestThreadMessage = await Messages.findLatestFederationThreadMessageByTmid(message.tmid, message.rid); const latestThreadEventId = latestThreadMessage?.federation?.eventId; - // Check if this is a quote within a thread if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { const quoteMessage = await this.getQuoteMessage(message, matrixRoomId, matrixUserId, matrixDomain); if (!quoteMessage) { @@ -559,7 +529,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ); } - // Send regular thread message return this.homeserverServices.message.sendThreadMessage( matrixRoomId, message.msg, diff --git a/packages/model-typings/src/models/IMessagesModel.ts b/packages/model-typings/src/models/IMessagesModel.ts index 6b79ac0c21829..eb29a4017ac0a 100644 --- a/packages/model-typings/src/models/IMessagesModel.ts +++ b/packages/model-typings/src/models/IMessagesModel.ts @@ -103,6 +103,8 @@ export interface IMessagesModel extends IBaseModel { findOneByFederationId(federationEventId: string): Promise; + findLatestFederationThreadMessageByTmid(tmid: string, roomId: IRoom['_id'], options?: FindOptions): Promise; + setFederationEventIdById(_id: string, federationEventId: string): Promise; removeByRoomId(roomId: IRoom['_id']): Promise; diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index 50f73cab73d42..b0dd9da7a7d94 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -604,6 +604,23 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.findOne({ 'federation.eventId': federationEventId }); } + async findLatestFederationThreadMessageByTmid( + tmid: string, + rootMessageId: IMessage['_id'], + ): Promise | null> { + return this.findOne( + { + '_id': { $ne: rootMessageId }, + tmid, + 'federation.eventId': { $exists: true }, + }, + { + sort: { ts: -1 }, + projection: { 'federation.eventId': 1, '_id': 1 }, + }, + ); + } + async setFederationEventIdById(_id: string, federationEventId: string): Promise { await this.updateOne( { _id }, From 2c92d4ebc2e4576e7504b483524a9f162a7f6c83 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 17:52:44 -0300 Subject: [PATCH 10/33] chore: removes duplicated IUpload type --- .../src/api/_matrix/media.ts | 11 +++---- .../federation-matrix/src/events/message.ts | 29 ------------------- .../src/services/MatrixMediaService.ts | 16 +++++----- .../src/types/IUploadWithFederation.ts | 15 ---------- packages/core-typings/src/IUpload.ts | 3 +- 5 files changed, 13 insertions(+), 61 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/types/IUploadWithFederation.ts diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index 3dcff28bee5c7..82dac0099f9f5 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -1,13 +1,13 @@ import crypto from 'crypto'; import type { HomeserverServices } from '@hs/federation-sdk'; +import type { IUpload } from '@rocket.chat/core-typings'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; import { Uploads } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { MatrixMediaService } from '../../services/MatrixMediaService'; -import type { IUploadWithFederation } from '../../types/IUploadWithFederation'; const logger = new Logger('federation-matrix:media'); @@ -83,21 +83,18 @@ async function getMediaFile( mediaId: string, serverName: string, ): Promise<{ - file: IUploadWithFederation | null; + file: IUpload | null; buffer: Buffer | null; }> { const mxcUri = `mxc://${serverName}/${mediaId}`; - let file: IUploadWithFederation | null = await MatrixMediaService.getLocalFileForMatrixNode(mxcUri); + let file = await MatrixMediaService.getLocalFileForMatrixNode(mxcUri); if (!file) { const directFile = await Uploads.findOneById(mediaId); if (!directFile) { return { file: null, buffer: null }; } - file = { - ...directFile, - federation: directFile.federation || { type: 'local', isRemote: false }, - } as IUploadWithFederation; + file = directFile; } const buffer = await MatrixMediaService.getLocalFileBuffer(file._id); diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index 0656525e40df9..044feacfb656d 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -215,43 +215,25 @@ async function handleMediaMessage( export function message(emitter: Emitter, serverName: string) { emitter.on('homeserver.matrix.message', async (data) => { try { - console.log('on homeserver.matrix.message', data); - const content = data.content as any; const msgtype = content?.msgtype; const messageBody = content?.body?.toString(); - logger.info('[federation-matrix:message] Received Matrix message event', { - eventId: data.event_id, - sender: data.sender, - roomId: data.room_id, - msgtype, - body: messageBody, - hasUrl: !!content?.url, - url: content?.url, - hasInfo: !!content?.info, - infoSize: content?.info?.size, - infoMimetype: content?.info?.mimetype, - }); - if (!messageBody && !msgtype) { logger.debug('No message content found in event'); return; } - // Get or create the federated user const user = await getOrCreateFederatedUser(data.sender); if (!user) { return; } - // Get room and ensure subscription exists const room = await getRoomAndEnsureSubscription(data.room_id, user); if (!room) { return; } - // Handle thread messages and relations const replyToRelation = content?.['m.relates_to']; const threadRelation = content?.['m.relates_to']; const isThreadMessage = threadRelation?.rel_type === 'm.thread'; @@ -259,10 +241,8 @@ export function message(emitter: Emitter, serverName: const threadRootEventId = isThreadMessage ? threadRelation.event_id : undefined; const tmid = await getThreadMessageId(threadRootEventId); - // Process the message based on type const isMediaMessage = ['m.image', 'm.file', 'm.video', 'm.audio'].includes(msgtype); - // Handle message editing const isEditedMessage = data.content['m.relates_to']?.rel_type === 'm.replace'; if (isEditedMessage && data.content['m.relates_to']?.event_id && data.content['m.new_content']) { logger.debug('Received edited message from Matrix, updating existing message'); @@ -321,7 +301,6 @@ export function message(emitter: Emitter, serverName: return; } - // Handle quote messages if (isQuoteMessage && room.name) { const originalMessage = await Messages.findOneByFederationId(replyToRelation?.['m.in_reply_to']?.event_id); if (!originalMessage) { @@ -346,15 +325,7 @@ export function message(emitter: Emitter, serverName: return; } - // Handle media and plain messages if (isMediaMessage && content?.url) { - logger.info('Processing media message from Matrix', { - eventId: data.event_id, - msgtype, - url: content.url, - body: messageBody, - hasInfo: !!content.info, - }); const result = await handleMediaMessage(content, msgtype, messageBody, user, room, data.event_id, tmid); await Message.saveMessageFromFederation(result); } else { diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index fa58e04851f33..8231ae06347c2 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -1,10 +1,9 @@ import { Upload } from '@rocket.chat/core-services'; +import type { IUpload } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Uploads } from '@rocket.chat/models'; import fetch from 'node-fetch'; -import type { IUploadWithFederation } from '../types/IUploadWithFederation'; - const logger = new Logger('federation-matrix:media-service'); export interface IRemoteFileReference { @@ -71,20 +70,20 @@ export class MatrixMediaService { } } - static async getLocalFileForMatrixNode(mxcUri: string): Promise { + static async getLocalFileForMatrixNode(mxcUri: string): Promise { try { const parts = this.parseMXCUri(mxcUri); if (!parts) { return null; } - let file = (await Uploads.findOne({ + let file = await Uploads.findOne({ 'federation.mxcUri': mxcUri, 'federation.isRemote': false, - })) as IUploadWithFederation | null; + }); if (!file) { - file = (await Uploads.findOneById(parts.mediaId)) as IUploadWithFederation | null; + file = await Uploads.findOneById(parts.mediaId); } if (!file) { @@ -203,11 +202,10 @@ export class MatrixMediaService { static async getLocalFileBuffer(fileId: string): Promise { try { - const fileRecord = (await Uploads.findOneById(fileId)) as IUploadWithFederation | null; - if (!fileRecord || (fileRecord as any).federation?.isRemote) { + const fileRecord = await Uploads.findOneById(fileId); + if (!fileRecord || fileRecord.federation) { return null; } - return await Upload.getFileBuffer({ file: fileRecord }); } catch (error) { logger.error('Error retrieving file buffer:', error); diff --git a/ee/packages/federation-matrix/src/types/IUploadWithFederation.ts b/ee/packages/federation-matrix/src/types/IUploadWithFederation.ts deleted file mode 100644 index 6bad6a728db56..0000000000000 --- a/ee/packages/federation-matrix/src/types/IUploadWithFederation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IUpload } from '@rocket.chat/core-typings'; - -export interface IFederationMetadata { - type: 'matrix'; - mxcUri: string; - isRemote: boolean; - serverName?: string; - mediaId?: string; - uploadToken?: string; - origin?: string; -} - -export interface IUploadWithFederation extends IUpload { - federation?: IFederationMetadata; -} diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 43a77add82a20..25cf5bdb5f265 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -62,7 +62,8 @@ export interface IUpload { federation?: { type: string; mxcUri?: string; - uploadToken?: string; + serverName?: string; + mediaId?: string; origin?: string; }; } From f93a37bd96f4dd2d30d00f5e1d4ab345e78da544 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 18:21:46 -0300 Subject: [PATCH 11/33] chore: adds setFederationInfo into Upload model --- .../src/api/_matrix/media.ts | 91 +++++++------------ .../src/services/MatrixMediaService.ts | 66 +++----------- packages/core-typings/src/IUpload.ts | 2 - .../model-typings/src/models/IUploadsModel.ts | 6 +- packages/models/src/models/Uploads.ts | 13 ++- 5 files changed, 64 insertions(+), 114 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index 82dac0099f9f5..c6f3b8a4f0d0c 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -4,7 +4,6 @@ import type { HomeserverServices } from '@hs/federation-sdk'; import type { IUpload } from '@rocket.chat/core-typings'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { Uploads } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { MatrixMediaService } from '../../services/MatrixMediaService'; @@ -38,66 +37,41 @@ const isMediaDownloadParamsProps = ajv.compile(MediaDownloadParamsSchema); const isErrorResponseProps = ajv.compile(ErrorResponseSchema); const isBufferResponseProps = ajv.compile(BufferResponseSchema); -function addSecurityHeaders(headers: Record): Record { - return { - ...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', - }; -} +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, + metadata: Record = {}, ): { body: Buffer; contentType: string } { const boundary = crypto.randomBytes(16).toString('hex'); const parts: string[] = []; - if (metadata) { - parts.push(`--${boundary}`); - parts.push('Content-Type: application/json'); - parts.push(''); - parts.push(JSON.stringify(metadata)); - } - - parts.push(`--${boundary}`); - parts.push(`Content-Type: ${mimeType}`); - parts.push(`Content-Disposition: attachment; filename="${fileName}"`); - parts.push(''); + 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`); - const multipartBody = Buffer.concat([headerBuffer, buffer, endBoundary]); return { - body: multipartBody, + body: Buffer.concat([headerBuffer, buffer, endBoundary]), contentType: `multipart/mixed; boundary=${boundary}`, }; } -async function getMediaFile( - mediaId: string, - serverName: string, -): Promise<{ - file: IUpload | null; - buffer: Buffer | null; -}> { - const mxcUri = `mxc://${serverName}/${mediaId}`; - let file = await MatrixMediaService.getLocalFileForMatrixNode(mxcUri); - +async function getMediaFile(mediaId: string, serverName: string): Promise<{ file: IUpload; buffer: Buffer } | null> { + const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName); if (!file) { - const directFile = await Uploads.findOneById(mediaId); - if (!directFile) { - return { file: null, buffer: null }; - } - file = directFile; + return null; } - const buffer = await MatrixMediaService.getLocalFileBuffer(file._id); + const buffer = await MatrixMediaService.getLocalFileBuffer(file); return { file, buffer }; } @@ -118,33 +92,33 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => }, tags: ['Federation', 'Media'], }, - async (context: any) => { + async (c) => { try { - const { mediaId } = context.req.param(); + const { mediaId } = c.req.param(); const { serverName } = config; - const { file, buffer } = await getMediaFile(mediaId, serverName); - if (!file || !buffer) { + const result = await getMediaFile(mediaId, serverName); + if (!result) { return { statusCode: 404, body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, }; } + const { file, buffer } = result; + const mimeType = file.type || 'application/octet-stream'; const fileName = file.name || mediaId; - const multipartResponse = createMultipartResponse(buffer, mimeType, fileName, { - 'content-type': mimeType, - 'content-length': buffer.length, - }); + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); return { statusCode: 200, - headers: addSecurityHeaders({ + headers: { + ...SECURITY_HEADERS, 'content-type': multipartResponse.contentType, 'content-length': String(multipartResponse.body.length), - }), + }, body: multipartResponse.body, }; } catch (error) { @@ -173,29 +147,28 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => const { mediaId } = context.req.param(); const { serverName } = config; - const { file, buffer } = await getMediaFile(mediaId, serverName); - if (!file || !buffer) { + const result = await getMediaFile(mediaId, serverName); + if (!result) { return { statusCode: 404, body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, }; } + const { file, buffer } = result; + const mimeType = file.type || 'application/octet-stream'; const fileName = file.name || mediaId; - const multipartResponse = createMultipartResponse(buffer, mimeType, fileName, { - 'content-type': mimeType, - 'content-length': buffer.length, - 'thumbnail': true, - }); + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); return { statusCode: 200, - headers: addSecurityHeaders({ + headers: { + ...SECURITY_HEADERS, 'content-type': multipartResponse.contentType, 'content-length': String(multipartResponse.body.length), - }), + }, body: multipartResponse.body, }; } catch (error) { diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 8231ae06347c2..3b55503008fe7 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -41,27 +41,16 @@ export class MatrixMediaService { } if (file.federation?.mxcUri) { - logger.debug('File already has MXC URI', { - fileId, - mxcUri: file.federation.mxcUri, - }); return file.federation.mxcUri; } const mxcUri = this.generateMXCUri(fileId, serverName); - await Uploads.updateOne( - { _id: fileId }, - { - $set: { - 'federation.type': 'matrix', - 'federation.mxcUri': mxcUri, - 'federation.isRemote': false, - 'federation.serverName': serverName, - 'federation.mediaId': fileId, - }, - }, - ); + await Uploads.setFederationInfo(fileId, { + mxcUri, + serverName, + mediaId: fileId, + }); return mxcUri; } catch (error) { @@ -70,24 +59,15 @@ export class MatrixMediaService { } } - static async getLocalFileForMatrixNode(mxcUri: string): Promise { + static async getLocalFileForMatrixNode(mediaId: string, serverName: string): Promise { try { - const parts = this.parseMXCUri(mxcUri); - if (!parts) { - return null; - } - - let file = await Uploads.findOne({ - 'federation.mxcUri': mxcUri, - 'federation.isRemote': false, - }); + let file = await Uploads.findByFederationMediaIdAndServerName(mediaId, serverName); if (!file) { - file = await Uploads.findOneById(parts.mediaId); + file = await Uploads.findOneById(mediaId); } if (!file) { - logger.warn('Local file not found for MXC URI', { mxcUri }); return null; } @@ -135,18 +115,11 @@ export class MatrixMediaService { }, }); - await Uploads.updateOne( - { _id: uploadedFile._id }, - { - $set: { - 'federation.type': 'matrix', - 'federation.mxcUri': mxcUri, - 'federation.serverName': parts.serverName, - 'federation.mediaId': parts.mediaId, - 'federation.originalUrl': mxcUri, - }, - }, - ); + await Uploads.setFederationInfo(uploadedFile._id, { + mxcUri, + serverName: parts.serverName, + mediaId: parts.mediaId, + }); return uploadedFile._id; } catch (error) { @@ -200,16 +173,7 @@ export class MatrixMediaService { } } - static async getLocalFileBuffer(fileId: string): Promise { - try { - const fileRecord = await Uploads.findOneById(fileId); - if (!fileRecord || fileRecord.federation) { - return null; - } - return await Upload.getFileBuffer({ file: fileRecord }); - } catch (error) { - logger.error('Error retrieving file buffer:', error); - return null; - } + static async getLocalFileBuffer(file: IUpload): Promise { + return Upload.getFileBuffer({ file }); } } diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 25cf5bdb5f265..ae407d8bd9307 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -60,11 +60,9 @@ export interface IUpload { sha256: string; }; federation?: { - type: string; mxcUri?: string; serverName?: string; mediaId?: string; - origin?: string; }; } diff --git a/packages/model-typings/src/models/IUploadsModel.ts b/packages/model-typings/src/models/IUploadsModel.ts index df8587fcccd9a..4975e5ce2f39b 100644 --- a/packages/model-typings/src/models/IUploadsModel.ts +++ b/packages/model-typings/src/models/IUploadsModel.ts @@ -1,5 +1,5 @@ import type { IRoom, IUpload } from '@rocket.chat/core-typings'; -import type { FindCursor, WithId, Filter, FindOptions } from 'mongodb'; +import type { FindCursor, WithId, Filter, FindOptions, UpdateResult } from 'mongodb'; import type { FindPaginated } from './IBaseModel'; import type { IBaseUploadsModel } from './IBaseUploadsModel'; @@ -16,4 +16,8 @@ export interface IUploadsModel extends IBaseUploadsModel { ): FindPaginated>>; findByFederationMxcUri(mxcUri: string): Promise; + + findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise; + + setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise; } diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index 3c8f5cc1ad419..2a4eac50d4f6d 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -2,7 +2,7 @@ import type { IUpload, RocketChatRecordDeleted, IRoom } from '@rocket.chat/core-typings'; import type { FindPaginated, IUploadsModel } from '@rocket.chat/model-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import type { Collection, FindCursor, Db, IndexDescription, WithId, Filter, FindOptions } from 'mongodb'; +import type { Collection, FindCursor, Db, IndexDescription, WithId, Filter, FindOptions, UpdateResult } from 'mongodb'; import { BaseUploadModelRaw } from './BaseUploadModel'; @@ -51,6 +51,17 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { return this.findOne({ 'federation.mxcUri': mxcUri }); } + findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise { + return this.findOne({ 'federation.mediaId': mediaId, 'federation.serverName': serverName }); + } + + setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise { + return this.updateOne( + { _id: fileId }, + { $set: { 'federation.mxcUri': info.mxcUri, 'federation.serverName': info.serverName, 'federation.mediaId': info.mediaId } }, + ); + } + findPaginatedWithoutThumbs(query: Filter = {}, options?: FindOptions): FindPaginated>> { return this.findPaginated( { From e994863b92ac6b30fe6cc7e803a6ae3252006e74 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 18:34:31 -0300 Subject: [PATCH 12/33] chore: removes unneeded function wrapper --- .../federation-matrix/src/FederationMatrix.ts | 12 ++++++------ packages/models/src/models/Messages.ts | 6 +----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 5e3aba1c0e580..ad2d9d178aedc 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -408,10 +408,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return 'm.file'; } - private async prepareFileForMatrix(file: NonNullable[0], matrixDomain: string): Promise { - return MatrixMediaService.prepareLocalFileForMatrix(file._id, matrixDomain); - } - private buildFileMessageContent( file: NonNullable[0], mxcUri: string, @@ -448,7 +444,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS try { const file = message.files[0]; - const mxcUri = await this.prepareFileForMatrix(file, matrixDomain); + const mxcUri = await MatrixMediaService.prepareLocalFileForMatrix(file._id, matrixDomain); const fileContent = this.buildFileMessageContent(file, mxcUri); const result = await this.homeserverServices.message.sendFileMessage(matrixRoomId, fileContent, matrixUserId); @@ -500,7 +496,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS matrixDomain: string, parsedMessage: string, ): Promise<{ eventId: string } | null> { - const threadRootMessage = await Messages.findOneById(message.tmid!); + if (!message.tmid) { + throw new Error('Thread message ID not found'); + } + + const threadRootMessage = await Messages.findOneById(message.tmid); const threadRootEventId = threadRootMessage?.federation?.eventId; if (!threadRootEventId) { diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index b0dd9da7a7d94..d2ec2bb7c9887 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -604,10 +604,7 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.findOne({ 'federation.eventId': federationEventId }); } - async findLatestFederationThreadMessageByTmid( - tmid: string, - rootMessageId: IMessage['_id'], - ): Promise | null> { + async findLatestFederationThreadMessageByTmid(tmid: string, rootMessageId: IMessage['_id']): Promise { return this.findOne( { '_id': { $ne: rootMessageId }, @@ -616,7 +613,6 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { }, { sort: { ts: -1 }, - projection: { 'federation.eventId': 1, '_id': 1 }, }, ); } From d193e107f9f1af5889c997a99e205e7fa69edb53 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 18:43:37 -0300 Subject: [PATCH 13/33] chore: merges related functions --- .../federation-matrix/src/FederationMatrix.ts | 47 +++++-------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index ad2d9d178aedc..80f1e5fceaf4c 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -408,30 +408,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return 'm.file'; } - private buildFileMessageContent( - file: NonNullable[0], - mxcUri: string, - ): { - body: string; - msgtype: 'm.image' | 'm.file' | 'm.video' | 'm.audio'; - url: string; - info: { - mimetype?: string; - size?: number; - }; - } { - const msgtype = this.determineFileMessageType(file.type); - return { - body: file.name, - msgtype, - url: mxcUri, - info: { - mimetype: file.type, - size: file.size, - }, - }; - } - private async handleFileMessage( message: IMessage, matrixRoomId: string, @@ -445,18 +421,19 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS try { const file = message.files[0]; const mxcUri = await MatrixMediaService.prepareLocalFileForMatrix(file._id, matrixDomain); - const fileContent = this.buildFileMessageContent(file, mxcUri); - const result = await this.homeserverServices.message.sendFileMessage(matrixRoomId, fileContent, matrixUserId); - - if (message.files.length > 1) { - this.logger.warn('Message contains multiple files, but only the first was sent to Matrix', { - messageId: message._id, - totalFiles: message.files.length, - sentFile: file.name, - }); - } - return result; + const msgtype = this.determineFileMessageType(file.type); + const fileContent = { + body: file.name, + msgtype, + url: mxcUri, + info: { + mimetype: file.type, + size: file.size, + }, + }; + + return this.homeserverServices.message.sendFileMessage(matrixRoomId, fileContent, matrixUserId); } catch (error) { this.logger.error('Failed to handle file message', { messageId: message._id, From d9129a02f71d7a7b555a63d83c3ebacdc2bbf5b2 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 20:25:34 -0300 Subject: [PATCH 14/33] refactor: moves file download to homeserver --- .../federation-matrix/src/FederationMatrix.ts | 1 + .../src/services/MatrixMediaService.ts | 59 ++++--------------- 2 files changed, 13 insertions(+), 47 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 80f1e5fceaf4c..93e3839218097 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -102,6 +102,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await createFederationContainer(containerOptions, config); instance.homeserverServices = getAllServices(); + MatrixMediaService.setHomeserverServices(instance.homeserverServices); instance.buildMatrixHTTPRoutes(); instance.onEvent('user.typing', async ({ isTyping, roomId, user: { username } }): Promise => { if (!roomId || !username) { diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index 3b55503008fe7..c218885dbaaf7 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -1,8 +1,8 @@ +import type { HomeserverServices } from '@hs/federation-sdk'; import { Upload } from '@rocket.chat/core-services'; import type { IUpload } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Uploads } from '@rocket.chat/models'; -import fetch from 'node-fetch'; const logger = new Logger('federation-matrix:media-service'); @@ -16,6 +16,12 @@ export interface IRemoteFileReference { } export class MatrixMediaService { + private static homeserverServices: HomeserverServices; + + static setHomeserverServices(services: HomeserverServices): void { + this.homeserverServices = services; + } + static generateMXCUri(fileId: string, serverName: string): string { return `mxc://${serverName}/${fileId}`; } @@ -101,7 +107,11 @@ export class MatrixMediaService { return uploadAlreadyExists._id; } - const buffer = await this.downloadFromMatrixServer(parts.serverName, parts.mediaId); + if (!this.homeserverServices) { + throw new Error('Homeserver services not initialized. Call setHomeserverServices first.'); + } + + const buffer = await this.homeserverServices.media.downloadFromRemoteServer(parts.serverName, parts.mediaId); const uploadedFile = await Upload.uploadFile({ userId: metadata.userId || 'federation', @@ -128,51 +138,6 @@ export class MatrixMediaService { } } - private static async downloadFromMatrixServer(serverName: string, mediaId: string): Promise { - try { - // try different endpoints in order of preference - // according to MSC3916, new authenticated federation endpoints don't include server name in path - // first try new authenticated federation endpoints, then fall back to legacy endpoints - const endpoints = [ - `https://${serverName}/_matrix/federation/v1/media/download/${mediaId}`, - `https://${serverName}/federation/v1/media/download/${mediaId}`, - // legacy endpoints (deprecated but still needed for backwards compatibility) - `https://${serverName}/_matrix/media/v3/download/${serverName}/${mediaId}`, - `https://${serverName}/_matrix/media/r0/download/${serverName}/${mediaId}`, - ]; - - for await (const endpoint of endpoints) { - try { - const response = await fetch(endpoint, { - method: 'GET', - timeout: 15000, // 15 seconds timeout per endpoint - headers: { - 'User-Agent': 'Rocket.Chat Federation', - 'Accept': '*/*', - }, - }); - - if (response.ok) { - return response.buffer(); - } - - logger.debug('Non-OK response from endpoint', { - endpoint, - status: response.status, - statusText: response.statusText, - }); - } catch (error) { - logger.error('Error downloading from Matrix server:', error); - } - } - - throw new Error(`Failed to download file from Matrix server ${serverName}/${mediaId}`); - } catch (error) { - logger.error('Error downloading from Matrix server:', error); - throw error; - } - } - static async getLocalFileBuffer(file: IUpload): Promise { return Upload.getFileBuffer({ file }); } From 92417415eac188d0bac08385b9417926efd5ffd6 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sat, 13 Sep 2025 15:07:14 -0300 Subject: [PATCH 15/33] feat: adds Federation canAccessMedia middleware (#36926) --- .../src/api/_matrix/media.ts | 8 +++- .../federation-matrix/src/api/middlewares.ts | 44 ++++++++++++++++++- .../src/services/MatrixMediaService.ts | 3 ++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index c6f3b8a4f0d0c..7ab093f9034a8 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -7,6 +7,7 @@ import { Logger } from '@rocket.chat/logger'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { MatrixMediaService } from '../../services/MatrixMediaService'; +import { canAccessMedia } from '../middlewares'; const logger = new Logger('federation-matrix:media'); @@ -76,7 +77,7 @@ async function getMediaFile(mediaId: string, serverName: string): Promise<{ file } export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => { - const { config } = homeserverServices; + const { config, federationAuth } = homeserverServices; const router = new Router('/federation'); router.get( @@ -86,12 +87,14 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => response: { 200: isBufferResponseProps, 401: isErrorResponseProps, + 403: isErrorResponseProps, 404: isErrorResponseProps, 429: isErrorResponseProps, 500: isErrorResponseProps, }, tags: ['Federation', 'Media'], }, + canAccessMedia(federationAuth), async (c) => { try { const { mediaId } = c.req.param(); @@ -122,7 +125,6 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => body: multipartResponse.body, }; } catch (error) { - logger.error('Federation media download error:', error); return { statusCode: 500, body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, @@ -138,10 +140,12 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => response: { 200: isBufferResponseProps, 401: isErrorResponseProps, + 403: isErrorResponseProps, 404: isErrorResponseProps, }, tags: ['Federation', 'Media'], }, + canAccessMedia(federationAuth), async (context: any) => { try { const { mediaId } = context.req.param(); diff --git a/ee/packages/federation-matrix/src/api/middlewares.ts b/ee/packages/federation-matrix/src/api/middlewares.ts index bcfff8dada781..7ad62bcf7422b 100644 --- a/ee/packages/federation-matrix/src/api/middlewares.ts +++ b/ee/packages/federation-matrix/src/api/middlewares.ts @@ -1,7 +1,49 @@ import type { EventAuthorizationService } from '@hs/federation-sdk'; import { errCodes } from '@hs/federation-sdk'; import type { EventID } from '@hs/room'; -import type { Context, Next } from 'hono'; + +export const canAccessMedia = (federationAuth: EventAuthorizationService) => { + return async (c: Context, next: Next) => { + const mediaId = c.req.param('mediaId'); + const authHeader = c.req.header('Authorization') || ''; + const { method } = c.req; + const { path } = c.req; + const query = c.req.query(); + + let uriForSignature = path; + const queryString = new URLSearchParams(query).toString(); + if (queryString) { + uriForSignature = `${path}?${queryString}`; + } + + try { + const body = method === 'GET' ? undefined : await c.req.json(); + + const verificationResult = await federationAuth.canAccessMediaFromAuthorizationHeader( + mediaId, + authHeader, + method, + uriForSignature, // use URI with query params for signature verification + body, + ); + + if (!verificationResult.authorized) { + const errorResponse = errCodes[verificationResult.errorCode]; + return c.json( + { + errcode: errorResponse.errcode, + error: errorResponse.error, + }, + errorResponse.status, + ); + } + + return next(); + } catch (error) { + return c.json(errCodes.M_UNKNOWN, 500); + } + }; +}; export const canAccessEvent = (federationAuth: EventAuthorizationService) => async (c: Context, next: Next) => { try { diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index c218885dbaaf7..e25a4a97377f3 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -112,6 +112,9 @@ export class MatrixMediaService { } const buffer = await this.homeserverServices.media.downloadFromRemoteServer(parts.serverName, parts.mediaId); + if (!buffer) { + throw new Error('Download from remote server returned null content.'); + } const uploadedFile = await Upload.uploadFile({ userId: metadata.userId || 'federation', From 61c7d864c28b0c8bc2e0bc1727fc4ba783d69966 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 11 Sep 2025 21:14:44 -0300 Subject: [PATCH 16/33] feat: adds canAccessMedia middleware --- .../federation-matrix/src/api/middlewares.ts | 59 +++++++------------ 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/middlewares.ts b/ee/packages/federation-matrix/src/api/middlewares.ts index 7ad62bcf7422b..45b73055383bf 100644 --- a/ee/packages/federation-matrix/src/api/middlewares.ts +++ b/ee/packages/federation-matrix/src/api/middlewares.ts @@ -2,47 +2,30 @@ import type { EventAuthorizationService } from '@hs/federation-sdk'; import { errCodes } from '@hs/federation-sdk'; import type { EventID } from '@hs/room'; -export const canAccessMedia = (federationAuth: EventAuthorizationService) => { - return async (c: Context, next: Next) => { - const mediaId = c.req.param('mediaId'); - const authHeader = c.req.header('Authorization') || ''; - const { method } = c.req; - const { path } = c.req; - const query = c.req.query(); - - let uriForSignature = path; - const queryString = new URLSearchParams(query).toString(); - if (queryString) { - uriForSignature = `${path}?${queryString}`; - } - - try { - const body = method === 'GET' ? undefined : await c.req.json(); +export const canAccessMedia = (federationAuth: EventAuthorizationService) => async (c: Context, next: Next) => { + try { + const verificationResult = await federationAuth.canAccessMediaFromAuthorizationHeader( + c.req.param('mediaId'), + c.req.header('Authorization') || '', + c.req.method, + c.req.path, + await c.req.json(), + ); - const verificationResult = await federationAuth.canAccessMediaFromAuthorizationHeader( - mediaId, - authHeader, - method, - uriForSignature, // use URI with query params for signature verification - body, + if (!verificationResult.authorized) { + return c.json( + { + errcode: errCodes[verificationResult.errorCode].errcode, + error: errCodes[verificationResult.errorCode].error, + }, + errCodes[verificationResult.errorCode].status, ); - - if (!verificationResult.authorized) { - const errorResponse = errCodes[verificationResult.errorCode]; - return c.json( - { - errcode: errorResponse.errcode, - error: errorResponse.error, - }, - errorResponse.status, - ); - } - - return next(); - } catch (error) { - return c.json(errCodes.M_UNKNOWN, 500); } - }; + + return next(); + } catch (error) { + return c.json(errCodes.M_UNKNOWN, 500); + } }; export const canAccessEvent = (federationAuth: EventAuthorizationService) => async (c: Context, next: Next) => { From 7ba60f6ade8ccbd817651c6087711ff25f3218f2 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sat, 13 Sep 2025 15:42:39 -0300 Subject: [PATCH 17/33] chore: makes federation sub props mandatory when federation prop exists --- packages/core-typings/src/IUpload.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index ae407d8bd9307..2eb5cf0741a73 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -60,9 +60,9 @@ export interface IUpload { sha256: string; }; federation?: { - mxcUri?: string; - serverName?: string; - mediaId?: string; + mxcUri: string; + serverName: string; + mediaId: string; }; } From 815c4353346378ce13bd1b1b35fcfccb27f4cedb Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sat, 13 Sep 2025 17:49:10 -0300 Subject: [PATCH 18/33] refactor: moves errCodes to homeserver package --- ee/packages/federation-matrix/src/api/middlewares.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/middlewares.ts b/ee/packages/federation-matrix/src/api/middlewares.ts index 45b73055383bf..a919cc0758103 100644 --- a/ee/packages/federation-matrix/src/api/middlewares.ts +++ b/ee/packages/federation-matrix/src/api/middlewares.ts @@ -1,15 +1,19 @@ import type { EventAuthorizationService } from '@hs/federation-sdk'; import { errCodes } from '@hs/federation-sdk'; import type { EventID } from '@hs/room'; +import type { Context, Next } from 'hono'; export const canAccessMedia = (federationAuth: EventAuthorizationService) => async (c: Context, next: Next) => { try { + const url = new URL(c.req.url); + const path = url.search ? `${c.req.path}${url.search}` : c.req.path; + const verificationResult = await federationAuth.canAccessMediaFromAuthorizationHeader( c.req.param('mediaId'), c.req.header('Authorization') || '', c.req.method, - c.req.path, - await c.req.json(), + path, + undefined, ); if (!verificationResult.authorized) { From 940fa125958e5dcdda0702e8cb6ab6f2f88bdfe5 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 15 Sep 2025 15:33:26 -0300 Subject: [PATCH 19/33] chore: changes thumbnail endpoint to return unrecognized --- .../src/api/_matrix/media.ts | 52 +++---------------- 1 file changed, 8 insertions(+), 44 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index 7ab093f9034a8..650452ca09844 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -3,14 +3,11 @@ import crypto from 'crypto'; import type { HomeserverServices } from '@hs/federation-sdk'; import type { IUpload } from '@rocket.chat/core-typings'; import { Router } from '@rocket.chat/http-router'; -import { Logger } from '@rocket.chat/logger'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { MatrixMediaService } from '../../services/MatrixMediaService'; import { canAccessMedia } from '../middlewares'; -const logger = new Logger('federation-matrix:media'); - const MediaDownloadParamsSchema = { type: 'object', properties: { @@ -100,6 +97,7 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => const { mediaId } = c.req.param(); const { serverName } = config; + // TODO: Add file streaming support const result = await getMediaFile(mediaId, serverName); if (!result) { return { @@ -138,51 +136,17 @@ export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => { params: isMediaDownloadParamsProps, response: { - 200: isBufferResponseProps, - 401: isErrorResponseProps, - 403: isErrorResponseProps, 404: isErrorResponseProps, }, tags: ['Federation', 'Media'], }, - canAccessMedia(federationAuth), - async (context: any) => { - try { - const { mediaId } = context.req.param(); - const { serverName } = config; - - const result = await getMediaFile(mediaId, serverName); - if (!result) { - return { - statusCode: 404, - body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, - }; - } - - const { file, buffer } = result; - - 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) { - logger.error('Federation thumbnail error:', error); - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; - } - }, + async () => ({ + statusCode: 404, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on the homeserver side', + }, + }), ); return router; From 7d886b42769b4e756599b15f97ae4fc2f07c4279 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 15 Sep 2025 15:33:48 -0300 Subject: [PATCH 20/33] chore: adds todo to handle multiple files --- ee/packages/federation-matrix/src/FederationMatrix.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 93e3839218097..cc4e16d532cac 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -420,6 +420,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } try { + // TODO: Handle multiple files const file = message.files[0]; const mxcUri = await MatrixMediaService.prepareLocalFileForMatrix(file._id, matrixDomain); From 6c8b824e82b8014372653442d2cbe8923e1b5dcf Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 15 Sep 2025 15:41:55 -0300 Subject: [PATCH 21/33] chore: removes findByFederationMxcUri from uploads model --- .../federation-matrix/src/services/MatrixMediaService.ts | 2 +- packages/model-typings/src/models/IUploadsModel.ts | 2 -- packages/models/src/models/Uploads.ts | 4 ---- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index e25a4a97377f3..afa26bd42bb87 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -102,7 +102,7 @@ export class MatrixMediaService { throw new Error('Invalid MXC URI'); } - const uploadAlreadyExists = await Uploads.findByFederationMxcUri(mxcUri); + const uploadAlreadyExists = await Uploads.findByFederationMediaIdAndServerName(parts.mediaId, parts.serverName); if (uploadAlreadyExists) { return uploadAlreadyExists._id; } diff --git a/packages/model-typings/src/models/IUploadsModel.ts b/packages/model-typings/src/models/IUploadsModel.ts index 4975e5ce2f39b..b3e39b28cd738 100644 --- a/packages/model-typings/src/models/IUploadsModel.ts +++ b/packages/model-typings/src/models/IUploadsModel.ts @@ -15,8 +15,6 @@ export interface IUploadsModel extends IBaseUploadsModel { options?: Omit, 'sort'>, ): FindPaginated>>; - findByFederationMxcUri(mxcUri: string): Promise; - findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise; setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise; diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index 2a4eac50d4f6d..039aefdaaa0da 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -47,10 +47,6 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { }); } - findByFederationMxcUri(mxcUri: string): Promise { - return this.findOne({ 'federation.mxcUri': mxcUri }); - } - findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise { return this.findOne({ 'federation.mediaId': mediaId, 'federation.serverName': serverName }); } From 46621c8c24546e5d51983dfcbc14e2fc7a941b35 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 15 Sep 2025 16:20:11 -0300 Subject: [PATCH 22/33] chore: adds comment to improve uploadFile typing to avoid db calls --- ee/packages/federation-matrix/src/services/MatrixMediaService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts index afa26bd42bb87..6c7aed293e421 100644 --- a/ee/packages/federation-matrix/src/services/MatrixMediaService.ts +++ b/ee/packages/federation-matrix/src/services/MatrixMediaService.ts @@ -116,6 +116,7 @@ export class MatrixMediaService { throw new Error('Download from remote server returned null content.'); } + // TODO: Make uploadFile support Partial to avoid calling a DB update right after the upload to set the federation info const uploadedFile = await Upload.uploadFile({ userId: metadata.userId || 'federation', buffer, From bbd966982fb3833a689a57a7815508e03fde054d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 15 Sep 2025 17:37:47 -0300 Subject: [PATCH 23/33] refactor: makes getMatrixMessageType to use a dict --- .../federation-matrix/src/FederationMatrix.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index cc4e16d532cac..0f4c147dc3ed5 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -29,6 +29,8 @@ import { saveExternalUserIdForLocalUser } from './helpers/identifiers'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; import { MatrixMediaService } from './services/MatrixMediaService'; +type MatrixFileTypes = 'm.image' | 'm.video' | 'm.audio' | 'm.file'; + export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -399,14 +401,22 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw error; } } - private determineFileMessageType(fileType?: string): 'm.image' | 'm.file' | 'm.video' | 'm.audio' { - if (!fileType) return 'm.file'; - if (fileType.startsWith('image/')) return 'm.image'; - if (fileType.startsWith('video/')) return 'm.video'; - if (fileType.startsWith('audio/')) return 'm.audio'; + private readonly fileTypes: Record = { + image: 'm.image', + video: 'm.video', + audio: 'm.audio', + file: 'm.file', + }; + + private getMatrixMessageType(mimeType?: string): MatrixFileTypes { + const mainType = mimeType?.split('/')[0]; + + if (!mainType) { + throw new Error(`Unknown file type: ${mimeType}`); + } - return 'm.file'; + return this.fileTypes[mainType] ?? this.fileTypes.file; } private async handleFileMessage( @@ -424,7 +434,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const file = message.files[0]; const mxcUri = await MatrixMediaService.prepareLocalFileForMatrix(file._id, matrixDomain); - const msgtype = this.determineFileMessageType(file.type); + const msgtype = this.getMatrixMessageType(file.type); const fileContent = { body: file.name, msgtype, From ca0e6e87b16458b3182725ddb9d560a365ecd5c2 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 17 Sep 2025 14:47:11 -0300 Subject: [PATCH 24/33] updates yarn.lock --- yarn.lock | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0af19cbe1263c..a29db1af1cab5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28141,6 +28141,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^5.1.5": + version: 5.1.5 + resolution: "nanoid@npm:5.1.5" + bin: + nanoid: bin/nanoid.js + checksum: 10/6de2d006b51c983be385ef7ee285f7f2a57bd96f8c0ca881c4111461644bd81fafc2544f8e07cb834ca0f3e0f3f676c1fe78052183f008b0809efe6e273119f5 + languageName: node + linkType: hard + "napi-build-utils@npm:^2.0.0": version: 2.0.0 resolution: "napi-build-utils@npm:2.0.0" @@ -33264,13 +33273,6 @@ __metadata: languageName: node linkType: hard -"secure-json-parse@npm:^4.0.0": - version: 4.0.0 - resolution: "secure-json-parse@npm:4.0.0" - checksum: 10/c36c9dec9afaf4ef929a5469995d70d2f20d3d89b57219f22e0349b342715987283dbc1a80ab6f39e0bb28f8c3f3f073ce5363765c20c8d003ac243b4a89bd3d - languageName: node - linkType: hard - "seek-bzip@npm:^1.0.5": version: 1.0.6 resolution: "seek-bzip@npm:1.0.6" @@ -34792,13 +34794,6 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:^5.0.2": - version: 5.0.3 - resolution: "strip-json-comments@npm:5.0.3" - checksum: 10/3ccbf26f278220f785e4b71f8a719a6a063d72558cc63cb450924254af258a4f4c008b8c9b055373a680dc7bd525be9e543ad742c177f8a7667e0b726258e0e4 - languageName: node - linkType: hard - "strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" From 6382975bef83c0efeb98065bf1180d4568f7a35d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 18 Sep 2025 08:19:32 -0300 Subject: [PATCH 25/33] uses shared file types definition --- .../federation-matrix/src/FederationMatrix.ts | 16 ++++++++-------- .../federation-matrix/src/events/message.ts | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 0f4c147dc3ed5..e394b86328c31 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -31,6 +31,13 @@ import { MatrixMediaService } from './services/MatrixMediaService'; type MatrixFileTypes = 'm.image' | 'm.video' | 'm.audio' | 'm.file'; +export const fileTypes: Record = { + image: 'm.image', + video: 'm.video', + audio: 'm.audio', + file: 'm.file', +}; + export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -402,13 +409,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - private readonly fileTypes: Record = { - image: 'm.image', - video: 'm.video', - audio: 'm.audio', - file: 'm.file', - }; - private getMatrixMessageType(mimeType?: string): MatrixFileTypes { const mainType = mimeType?.split('/')[0]; @@ -416,7 +416,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error(`Unknown file type: ${mimeType}`); } - return this.fileTypes[mainType] ?? this.fileTypes.file; + return fileTypes[mainType] ?? fileTypes.file; } private async handleFileMessage( diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index 044feacfb656d..f34ee6f011936 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -6,6 +6,7 @@ import type { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; import { Users, MatrixBridgedUser, MatrixBridgedRoom, Rooms, Subscriptions, Messages } from '@rocket.chat/models'; +import { fileTypes } from '../FederationMatrix'; import { toInternalMessageFormat, toInternalQuoteMessageFormat } from '../helpers/message.parsers'; import { MatrixMediaService } from '../services/MatrixMediaService'; @@ -241,7 +242,7 @@ export function message(emitter: Emitter, serverName: const threadRootEventId = isThreadMessage ? threadRelation.event_id : undefined; const tmid = await getThreadMessageId(threadRootEventId); - const isMediaMessage = ['m.image', 'm.file', 'm.video', 'm.audio'].includes(msgtype); + const isMediaMessage = Object.values(fileTypes).includes(msgtype); const isEditedMessage = data.content['m.relates_to']?.rel_type === 'm.replace'; if (isEditedMessage && data.content['m.relates_to']?.event_id && data.content['m.new_content']) { From bc342c8c00e66ed58ebf1129ceac4b1f125f4537 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 18 Sep 2025 10:18:28 -0300 Subject: [PATCH 26/33] fixes setFederationInfo typings --- packages/model-typings/src/models/IUploadsModel.ts | 2 +- packages/models/src/models/Uploads.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/model-typings/src/models/IUploadsModel.ts b/packages/model-typings/src/models/IUploadsModel.ts index b3e39b28cd738..488c83af86ad7 100644 --- a/packages/model-typings/src/models/IUploadsModel.ts +++ b/packages/model-typings/src/models/IUploadsModel.ts @@ -17,5 +17,5 @@ export interface IUploadsModel extends IBaseUploadsModel { findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise; - setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise; + setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise; } diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index 039aefdaaa0da..a247c025ca33f 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -51,7 +51,7 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { return this.findOne({ 'federation.mediaId': mediaId, 'federation.serverName': serverName }); } - setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise { + setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise { return this.updateOne( { _id: fileId }, { $set: { 'federation.mxcUri': info.mxcUri, 'federation.serverName': info.serverName, 'federation.mediaId': info.mediaId } }, From d7bdea4b2389070cd21bce0c4e27df2307e04962 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 18 Sep 2025 10:22:51 -0300 Subject: [PATCH 27/33] returns file type when mimeType validation fails --- ee/packages/federation-matrix/src/FederationMatrix.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index e394b86328c31..0379a4546e7aa 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -411,9 +411,8 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private getMatrixMessageType(mimeType?: string): MatrixFileTypes { const mainType = mimeType?.split('/')[0]; - if (!mainType) { - throw new Error(`Unknown file type: ${mimeType}`); + return fileTypes.file; } return fileTypes[mainType] ?? fileTypes.file; From 844a07c13d3e8a580d7d005bb50e47825f2191a3 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 18 Sep 2025 12:32:03 -0300 Subject: [PATCH 28/33] fixes findLatestFederationThreadMessageByTmid signature --- ee/packages/federation-matrix/src/FederationMatrix.ts | 2 +- packages/model-typings/src/models/IMessagesModel.ts | 2 +- packages/models/src/models/Messages.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 0379a4546e7aa..77d936646b2e0 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -499,7 +499,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, parsedMessage, matrixUserId); } - const latestThreadMessage = await Messages.findLatestFederationThreadMessageByTmid(message.tmid, message.rid); + const latestThreadMessage = await Messages.findLatestFederationThreadMessageByTmid(message.tmid, message._id); const latestThreadEventId = latestThreadMessage?.federation?.eventId; if (message.attachments?.some((attachment) => isQuoteAttachment(attachment) && Boolean(attachment.message_link))) { diff --git a/packages/model-typings/src/models/IMessagesModel.ts b/packages/model-typings/src/models/IMessagesModel.ts index eb29a4017ac0a..20ba7048ac092 100644 --- a/packages/model-typings/src/models/IMessagesModel.ts +++ b/packages/model-typings/src/models/IMessagesModel.ts @@ -103,7 +103,7 @@ export interface IMessagesModel extends IBaseModel { findOneByFederationId(federationEventId: string): Promise; - findLatestFederationThreadMessageByTmid(tmid: string, roomId: IRoom['_id'], options?: FindOptions): Promise; + findLatestFederationThreadMessageByTmid(tmid: string, messageId: IMessage['_id']): Promise; setFederationEventIdById(_id: string, federationEventId: string): Promise; diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index d2ec2bb7c9887..452c3fbb05d4d 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -604,10 +604,10 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.findOne({ 'federation.eventId': federationEventId }); } - async findLatestFederationThreadMessageByTmid(tmid: string, rootMessageId: IMessage['_id']): Promise { + async findLatestFederationThreadMessageByTmid(tmid: string, messageId: IMessage['_id']): Promise { return this.findOne( { - '_id': { $ne: rootMessageId }, + '_id': { $ne: messageId }, tmid, 'federation.eventId': { $exists: true }, }, From 20bb413d742ea50e2889f1e2a01c633ecaf265f9 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 18 Sep 2025 16:34:03 -0300 Subject: [PATCH 29/33] fixes broken type with encrypted rooms --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 13 ++++++------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 ++-- packages/core-typings/src/IMessage/IMessage.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 69732d0cb859f..ac97caba1395a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,5 +1,6 @@ import { Base64 } from '@rocket.chat/base64'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast } from '@rocket.chat/core-typings'; +import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, AtLeast, EncryptedMessageContent } from '@rocket.chat/core-typings'; +import { isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; @@ -670,11 +671,9 @@ export class E2ERoom extends Emitter { return this.encryptText(data); } - async decryptContent(data: T) { - if (data.content && data.content.algorithm === 'rc.v1.aes-sha2') { - const content = await this.decrypt(data.content.ciphertext); - Object.assign(data, content); - } + async decryptContent(data: T) { + const content = await this.decrypt(data.content.ciphertext); + Object.assign(data, content); return data; } @@ -693,7 +692,7 @@ export class E2ERoom extends Emitter { } } - message = await this.decryptContent(message); + message = isEncryptedMessageContent(message) ? await this.decryptContent(message) : message; return { ...message, diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 268cdfa4c8aeb..cd42765297d49 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -2,7 +2,7 @@ import QueryString from 'querystring'; import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; -import { isE2EEMessage } from '@rocket.chat/core-typings'; +import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -664,7 +664,7 @@ class E2E extends Emitter { } async decryptFileContent(file: IUploadWithUser): Promise { - if (!file.rid) { + if (!file.rid || !isEncryptedMessageContent(file)) { return file; } diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index d1dc67b00230c..ae265f26d77ab 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -237,6 +237,20 @@ export interface IMessage extends IRocketChatRecord { }; } +export type EncryptedMessageContent = { + content: { + algorithm: 'rc.v1.aes-sha2'; + ciphertext: string; + }; +}; + +export const isEncryptedMessageContent = (content: unknown): content is EncryptedMessageContent => + typeof content === 'object' && + content !== null && + 'content' in content && + typeof (content as any).content === 'object' && + (content as any).content?.algorithm === 'rc.v1.aes-sha2'; + export interface ISystemMessage extends IMessage { t: MessageTypesValues; } From 5b8de1e2026ecd7916c21283c8f30ff3c03a4449 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Sep 2025 11:25:38 -0300 Subject: [PATCH 30/33] code style --- .../federation-matrix/src/events/message.ts | 112 +++++++++--------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index f34ee6f011936..ae227dad7bf86 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -20,48 +20,48 @@ async function getOrCreateFederatedUser(matrixUserId: string): Promise = { - username: matrixUserId, - name: username, // TODO: Fetch display name from Matrix profile - type: 'user', - status: UserStatus.ONLINE, - active: true, - roles: ['user'], - requirePasswordChange: false, - federated: true, - federation: { - version: 1, - }, - createdAt: new Date(), - _updatedAt: new Date(), - }; - - const { insertedId } = await Users.insertOne(userData as IUser); - - await MatrixBridgedUser.createOrUpdateByLocalId( - insertedId, - matrixUserId, - true, // isRemote = true for external Matrix users - domain, - ); - - user = await Users.findOneById(insertedId); - if (!user) { - logger.error('Failed to create user:', matrixUserId); - return null; - } - - logger.info('Successfully created federated user:', { userId: user._id, username }); - } else { + const user = await Users.findOneByUsername(matrixUserId); + if (user) { await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, false, domain); + return user; } - return user; + logger.info('Creating new federated user:', { username: matrixUserId, externalId: matrixUserId }); + + const userData = { + username: matrixUserId, + name: username, // TODO: Fetch display name from Matrix profile + type: 'user', + status: UserStatus.ONLINE, + active: true, + roles: ['user'], + requirePasswordChange: false, + federated: true, + federation: { + version: 1, + }, + createdAt: new Date(), + _updatedAt: new Date(), + }; + + const { insertedId } = await Users.insertOne(userData); + + await MatrixBridgedUser.createOrUpdateByLocalId( + insertedId, + matrixUserId, + true, // isRemote = true for external Matrix users + domain, + ); + + const newUser = await Users.findOneById(insertedId); + if (!newUser) { + logger.error('Failed to create user:', matrixUserId); + return null; + } + + logger.info('Successfully created federated user:', { userId: newUser._id, username }); + + return newUser; } async function getRoomAndEnsureSubscription(matrixRoomId: string, user: IUser): Promise { @@ -85,22 +85,24 @@ async function getRoomAndEnsureSubscription(matrixRoomId: string, user: IUser): const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); - if (!existingSubscription) { - logger.info('Creating subscription for federated user in room:', { userId: user._id, roomId: room._id }); - - const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { - ts: new Date(), - open: false, - alert: false, - unread: 0, - userMentions: 0, - groupMentions: 0, - }); - - if (insertedId) { - logger.debug('Successfully created subscription:', insertedId); - // TODO: Import and use notifyOnSubscriptionChangedById if needed - } + if (existingSubscription) { + return room; + } + + logger.info('Creating subscription for federated user in room:', { userId: user._id, roomId: room._id }); + + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { + ts: new Date(), + open: false, + alert: false, + unread: 0, + userMentions: 0, + groupMentions: 0, + }); + + if (insertedId) { + logger.debug('Successfully created subscription:', insertedId); + // TODO: Import and use notifyOnSubscriptionChangedById if needed } return room; From 4eaeead9e971a15ffe5d8e3b3744325e9186872b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Sep 2025 17:01:41 -0300 Subject: [PATCH 31/33] improve upload model to set federation --- packages/model-typings/src/models/IUploadsModel.ts | 2 +- packages/models/src/models/Uploads.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/model-typings/src/models/IUploadsModel.ts b/packages/model-typings/src/models/IUploadsModel.ts index 488c83af86ad7..1c0cbb316e427 100644 --- a/packages/model-typings/src/models/IUploadsModel.ts +++ b/packages/model-typings/src/models/IUploadsModel.ts @@ -17,5 +17,5 @@ export interface IUploadsModel extends IBaseUploadsModel { findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise; - setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise; + setFederationInfo(fileId: IUpload['_id'], info: Required['federation']): Promise; } diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index a247c025ca33f..633af8ace43d8 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -51,11 +51,8 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { return this.findOne({ 'federation.mediaId': mediaId, 'federation.serverName': serverName }); } - setFederationInfo(fileId: string, info: { mxcUri: string; serverName: string; mediaId: string }): Promise { - return this.updateOne( - { _id: fileId }, - { $set: { 'federation.mxcUri': info.mxcUri, 'federation.serverName': info.serverName, 'federation.mediaId': info.mediaId } }, - ); + setFederationInfo(fileId: IUpload['_id'], info: Required['federation']): Promise { + return this.updateOne({ _id: fileId }, { $set: { federation: info } }); } findPaginatedWithoutThumbs(query: Filter = {}, options?: FindOptions): FindPaginated>> { From f48c1acabf1a7c83ec00d07b2b7f7eaec1c63b23 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 18 Sep 2025 17:26:42 -0300 Subject: [PATCH 32/33] add TODOs --- ee/packages/federation-matrix/src/events/message.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ee/packages/federation-matrix/src/events/message.ts b/ee/packages/federation-matrix/src/events/message.ts index ae227dad7bf86..52e28576bdb7d 100644 --- a/ee/packages/federation-matrix/src/events/message.ts +++ b/ee/packages/federation-matrix/src/events/message.ts @@ -123,6 +123,7 @@ async function getThreadMessageId(threadRootEventId: string | undefined): Promis } async function handleMediaMessage( + // TODO improve typing content: any, msgtype: string, messageBody: string, @@ -163,6 +164,8 @@ async function handleMediaMessage( } const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`; + + // TODO improve typing const attachment: any = { title: fileName, type: 'file', @@ -218,6 +221,7 @@ async function handleMediaMessage( export function message(emitter: Emitter, serverName: string) { emitter.on('homeserver.matrix.message', async (data) => { try { + // TODO remove type casting const content = data.content as any; const msgtype = content?.msgtype; const messageBody = content?.body?.toString(); From b83f6cb4706762aad102320ccf9d38deeaf56b57 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 18 Sep 2025 17:56:17 -0300 Subject: [PATCH 33/33] add index on uploads collection for mxcUri and serverName --- packages/models/src/models/Uploads.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/models/src/models/Uploads.ts b/packages/models/src/models/Uploads.ts index 633af8ace43d8..760080a0cf3b6 100644 --- a/packages/models/src/models/Uploads.ts +++ b/packages/models/src/models/Uploads.ts @@ -12,7 +12,12 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel { } protected modelIndexes(): IndexDescription[] { - return [...super.modelIndexes(), { key: { uploadedAt: -1 } }, { key: { rid: 1, _hidden: 1, typeGroup: 1 } }]; + return [ + ...super.modelIndexes(), + { key: { uploadedAt: -1 } }, + { key: { rid: 1, _hidden: 1, typeGroup: 1 } }, + { key: { 'federation.mediaId': 1, 'federation.serverName': 1 }, unique: true, sparse: true }, + ]; } findNotHiddenFilesOfRoom(roomId: string, searchText: string, fileType: string, limit: number): FindCursor {