diff --git a/.changeset/small-pants-reflect.md b/.changeset/small-pants-reflect.md new file mode 100644 index 0000000000000..a086551f4c8a9 --- /dev/null +++ b/.changeset/small-pants-reflect.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes the download of attachments with non-unicode names on E2EE rooms diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index b747a8d5d9555..a32d591ad238f 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,7 +1,8 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Messages, Subscriptions, ReadReceipts, NotificationQueue } from '@rocket.chat/models'; +import { callbacks } from '../../../server/lib/callbacks'; import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId, @@ -82,8 +83,8 @@ export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; await Messages.removeThreadFollowerByThreadId(tmid, uid); } -export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: string; tmid: string }) => { - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { tunread: 1 } }); +export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }) => { + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { tunread: 1 } }); if (!sub) { return; } @@ -91,10 +92,12 @@ export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: s // if the thread being marked as read is the last one unread also clear the unread subscription flag const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); - const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(room._id, user._id, tmid, clearAlert); if (removeUnreadThreadResponse.modifiedCount) { - void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, user._id); } - await NotificationQueue.clearQueueByUserId(userId); + await NotificationQueue.clearQueueByUserId(user._id); + + callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); }; diff --git a/apps/meteor/app/threads/server/methods/getThreadMessages.ts b/apps/meteor/app/threads/server/methods/getThreadMessages.ts index ab5e8a5e3f00d..d90df8d554f19 100644 --- a/apps/meteor/app/threads/server/methods/getThreadMessages.ts +++ b/apps/meteor/app/threads/server/methods/getThreadMessages.ts @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -48,7 +48,7 @@ Meteor.methods({ } await callbacks.run('beforeReadMessages', thread.rid, user._id); - await readThread({ userId: user._id, rid: thread.rid, tmid }); + await readThread({ user: user as IUser, room, tmid }); const result = await Messages.findVisibleThreadByThreadId(tmid, { ...(skip && { skip }), @@ -56,8 +56,6 @@ Meteor.methods({ sort: { ts: -1 }, }).toArray(); - callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); - return [thread, ...result]; }, }); diff --git a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts index 5082dec99d807..a2a8047560ee4 100644 --- a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts +++ b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts @@ -40,16 +40,16 @@ const normalizeAttachments = (attachments: MessageAttachment[], name?: string, t if (isFileAttachment(attachment)) { if (attachment.title_link && !attachment.title_link.startsWith('/file-decrypt/')) { - attachment.title_link = `/file-decrypt${attachment.title_link}?key=${key}`; + attachment.title_link = `/file-decrypt${attachment.title_link}?key=${encodeURIComponent(key)}`; } if (isFileImageAttachment(attachment) && !attachment.image_url.startsWith('/file-decrypt/')) { - attachment.image_url = `/file-decrypt${attachment.image_url}?key=${key}`; + attachment.image_url = `/file-decrypt${attachment.image_url}?key=${encodeURIComponent(key)}`; } if (isFileAudioAttachment(attachment) && !attachment.audio_url.startsWith('/file-decrypt/')) { - attachment.audio_url = `/file-decrypt${attachment.audio_url}?key=${key}`; + attachment.audio_url = `/file-decrypt${attachment.audio_url}?key=${encodeURIComponent(key)}`; } if (isFileVideoAttachment(attachment) && !attachment.video_url.startsWith('/file-decrypt/')) { - attachment.video_url = `/file-decrypt${attachment.video_url}?key=${key}`; + attachment.video_url = `/file-decrypt${attachment.video_url}?key=${encodeURIComponent(key)}`; } } diff --git a/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx b/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx index 1be47e82775b6..faaf1f3a9efed 100644 --- a/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx +++ b/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx @@ -18,7 +18,7 @@ export const useReadReceiptsDetailsAction = (message: IMessage): MessageActionCo id: 'receipt-detail', icon: 'check-double', label: 'Read_Receipts', - context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'], + context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads', 'federated'], type: 'duplication', action() { setModal( diff --git a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts index 6cf84aa0d96a3..bac082c47572e 100644 --- a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts +++ b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts @@ -40,7 +40,7 @@ export const useImagesList = ({ roomId, startingFromId }: { roomId: IRoom['_id'] type: decrypted.type, }), ); - decrypted.path = `/file-decrypt${decrypted.path}?key=${key}`; + decrypted.path = `/file-decrypt${decrypted.path}?key=${encodeURIComponent(key)}`; Object.assign(file, decrypted); } } diff --git a/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx b/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx index af366350fad68..dc6c254674349 100644 --- a/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx +++ b/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx @@ -1,4 +1,4 @@ -import { isRoomFederated, isThreadMainMessage } from '@rocket.chat/core-typings'; +import { isThreadMainMessage, isRoomFederated } from '@rocket.chat/core-typings'; import { useLayout, useUser, useUserPreference, useSetting, useEndpoint, useSearchParameter } from '@rocket.chat/ui-contexts'; import type { ReactNode, RefCallback } from 'react'; import { useMemo, memo } from 'react'; @@ -39,8 +39,10 @@ const MessageListProvider = ({ children, messageListRef, attachmentDimension }: const { isMobile } = useLayout(); + const federationReadReceipts = useSetting('Federation_Service_EDU_Process_Receipt', false); + const autoLinkDomains = useSetting('Message_CustomDomain_AutoLink', ''); - const readReceiptsEnabled = useSetting('Message_Read_Receipt_Enabled', false) && !isRoomFederated(room); + const readReceiptsEnabled = useSetting('Message_Read_Receipt_Enabled', false) && (!isRoomFederated(room) || federationReadReceipts); const readReceiptsStoreUsers = useSetting('Message_Read_Receipt_Store_Users', false); const apiEmbedEnabled = useSetting('API_Embed', false); const showRealName = useSetting('UI_Use_Real_Name', false); diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts index 954f5acb80647..f18aaeae47e57 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts @@ -56,7 +56,7 @@ export const useFilesList = ({ rid, type, text }: { rid: Required['rid' type: decrypted.type, }), ); - decrypted.path = `/file-decrypt${decrypted.path}?key=${key}`; + decrypted.path = `/file-decrypt${decrypted.path}?key=${encodeURIComponent(key)}`; Object.assign(file, decrypted); } } diff --git a/apps/meteor/ee/app/message-read-receipt/server/hooks/afterReadMessages.ts b/apps/meteor/ee/app/message-read-receipt/server/hooks/afterReadMessages.ts index 0d4f419c525cc..b2add51b3ed4f 100644 --- a/apps/meteor/ee/app/message-read-receipt/server/hooks/afterReadMessages.ts +++ b/apps/meteor/ee/app/message-read-receipt/server/hooks/afterReadMessages.ts @@ -1,5 +1,5 @@ import { MessageReads } from '@rocket.chat/core-services'; -import { type IUser, type IRoom, type IMessage, isRoomFederated } from '@rocket.chat/core-typings'; +import { type IUser, type IRoom, type IMessage } from '@rocket.chat/core-typings'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../server/lib/callbacks'; @@ -11,10 +11,7 @@ callbacks.add( if (!settings.get('Message_Read_Receipt_Enabled')) { return; } - // Rooms federated are not supported yet - if (isRoomFederated(room)) { - return; - } + const { uid, lastSeen, tmid } = params; if (tmid) { diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 5a9b4cd0c8918..3ff76a6fc862f 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -2,7 +2,7 @@ import { FederationMatrix, MeteorError, Room } from '@rocket.chat/core-services' import { isEditedMessage, isRoomNativeFederated, isUserNativeFederated } from '@rocket.chat/core-typings'; import type { IRoomNativeFederated, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { callbacks } from '../../../../server/lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../server/lib/callbacks/afterLeaveRoomCallback'; @@ -287,3 +287,26 @@ prepareCreateRoomCallback.add(async ({ extraData }) => { // only an empty "federation" object (extraData as IRoomNativeFederated).federation = { version: 1 } as any; }); + +callbacks.add( + 'afterReadMessages', + async (room: IRoom, params: { uid: IUser['_id']; lastSeen?: Date; tmid?: IMessage['_id'] }) => { + if (!FederationActions.shouldPerformFederationAction(room)) { + return; + } + + const user = await Users.findOneById(params.uid); + if (!user) { + return; + } + + if (isUserNativeFederated(user)) { + // if the user is federated, it means the read receipt came from Matrix, so we don't need to notify Matrix again + return; + } + + await FederationMatrix.notifyRoomRead({ room, userId: params.uid, threadId: params.tmid }); + }, + callbacks.priority.MEDIUM, + 'federation-read-receipt', +); diff --git a/apps/meteor/ee/server/startup/federation.ts b/apps/meteor/ee/server/startup/federation.ts index 6f4e6defd113a..c258823c90b5e 100644 --- a/apps/meteor/ee/server/startup/federation.ts +++ b/apps/meteor/ee/server/startup/federation.ts @@ -30,6 +30,7 @@ const configureFederation = async () => { allowedNonPrivateRooms: settings.get('Federation_Service_Join_Non_Private_Rooms'), processEDUTyping: settings.get('Federation_Service_EDU_Process_Typing'), processEDUPresence: settings.get('Federation_Service_EDU_Process_Presence'), + processEDUReceipt: settings.get('Federation_Service_EDU_Process_Receipt'), }); } catch (err) { logger.error({ msg: 'Failed to start federation-matrix service', err }); @@ -60,6 +61,7 @@ export const startFederationService = async (): Promise => { 'Federation_Service_Domain', 'Federation_Service_EDU_Process_Typing', 'Federation_Service_EDU_Process_Presence', + 'Federation_Service_EDU_Process_Receipt', 'Federation_Service_Matrix_Signing_Key', 'Federation_Service_Matrix_Signing_Algorithm', 'Federation_Service_Matrix_Signing_Version', diff --git a/apps/meteor/package.json b/apps/meteor/package.json index e689325b89394..4400321195bb5 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -103,7 +103,7 @@ "@rocket.chat/emitter": "^0.32.0", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.9", + "@rocket.chat/federation-sdk": "0.4.1", "@rocket.chat/fuselage": "^0.73.0", "@rocket.chat/fuselage-forms": "^1.0.0", "@rocket.chat/fuselage-hooks": "^0.40.0", diff --git a/apps/meteor/public/enc.js b/apps/meteor/public/enc.js index 4ce3e5aa6919a..b16ee27b6566b 100644 --- a/apps/meteor/public/enc.js +++ b/apps/meteor/public/enc.js @@ -1,9 +1,9 @@ -self.addEventListener('install', function(event) { - event.waitUntil(self.skipWaiting()); // Activate worker immediately +self.addEventListener('install', function (event) { + event.waitUntil(self.skipWaiting()); // Activate worker immediately }); -self.addEventListener('activate', function(event) { - event.waitUntil(self.clients.claim()); // Become available to all pages +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); // Become available to all pages }); function base64Decode(string) { @@ -32,7 +32,13 @@ const decrypt = async (key, iv, file) => { const getUrlParams = (url) => { const urlObj = new URL(url, location.origin); - const k = base64DecodeString(urlObj.searchParams.get('key')); + const rawKey = urlObj.searchParams.get('key'); + if (!rawKey) { + throw new Error('Missing "key" query param'); + } + + + const k = base64DecodeString(decodeURIComponent(rawKey)); urlObj.searchParams.delete('key'); @@ -74,7 +80,7 @@ self.addEventListener('fetch', (event) => { const result = await decrypt(key, iv, file); const newHeaders = new Headers(res.headers); - newHeaders.set('Content-Disposition', 'inline; filename="'+name+'"'); + newHeaders.set('Content-Disposition', 'inline; filename="' + name + '"'); newHeaders.set('Content-Type', type); const response = new Response(result, { @@ -114,18 +120,17 @@ self.addEventListener('message', async (event) => { const file = await res.arrayBuffer(); const result = await decrypt(key, iv, file); - event.source - .postMessage({ - id: event.data.id, - type: 'attachment-download-result', - result, - }); - // .catch((error) => { - // console.error('Posting message failed:', error); - // event.source.postMessage({ - // id: event.data.id, - // type: 'attachment-download-result', - // error, - // }); - // }); + event.source.postMessage({ + id: event.data.id, + type: 'attachment-download-result', + result, + }); + // .catch((error) => { + // console.error('Posting message failed:', error); + // event.source.postMessage({ + // id: event.data.id, + // type: 'attachment-download-result', + // error, + // }); + // }); }); diff --git a/apps/meteor/server/methods/readThreads.ts b/apps/meteor/server/methods/readThreads.ts index e0d557c4535ce..c1a6fe3807350 100644 --- a/apps/meteor/server/methods/readThreads.ts +++ b/apps/meteor/server/methods/readThreads.ts @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; @@ -44,8 +44,7 @@ Meteor.methods({ await callbacks.run('beforeReadMessages', thread.rid, user?._id); if (user?._id) { - await readThread({ userId: user._id, rid: thread.rid, tmid }); - callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); + await readThread({ user: user as IUser, room, tmid }); } }, }); diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index d364dd02502fa..a6101c3b2fe71 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -21,10 +21,12 @@ import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import import { removeUserFromRoom, performUserRemoval } from '../../../app/lib/server/functions/removeUserFromRoom'; import { notifyOnSubscriptionChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener'; +import { readThread } from '../../../app/threads/server/functions'; import { getDefaultSubscriptionPref } from '../../../app/utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../app/utils/server/lib/getValidRoomName'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig'; +import { readMessages } from '../../lib/readMessages'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { addRoomLeader } from '../../methods/addRoomLeader'; import { addRoomModerator } from '../../methods/addRoomModerator'; @@ -330,4 +332,16 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return insertedId; } + + async markAsRead(room: IRoom, userId: string, readThreads = false): Promise { + await readMessages(room, userId, readThreads); + } + + async readThread({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }): Promise { + await readThread({ + user, + room, + tmid, + }); + } } diff --git a/apps/meteor/server/settings/federation-service.ts b/apps/meteor/server/settings/federation-service.ts index ecd66d262bac6..af8f8c4f69492 100644 --- a/apps/meteor/server/settings/federation-service.ts +++ b/apps/meteor/server/settings/federation-service.ts @@ -85,6 +85,15 @@ export const createFederationServiceSettings = async (): Promise => { alert: 'Federation_Service_EDU_Process_Presence_Alert', }); + await this.add('Federation_Service_EDU_Process_Receipt', false, { + type: 'boolean', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: false, + alert: 'Federation_Service_EDU_Process_Receipt_Alert', + }); + await this.add('Federation_Service_Join_Encrypted_Rooms', false, { type: 'boolean', public: false, diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts index 9ab5127448a05..e82536368a9c9 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts @@ -96,6 +96,35 @@ test.describe('E2EE File Encryption', () => { }); }); + test('File with Unicode filename uploads and downloads correctly', async ({ page }) => { + const UNICODE_FILE_NAME = 'Новый текстовый документ.txt'; + + await test.step('upload file with Unicode filename', async () => { + await poHomeChannel.content.sendFileMessage(TEST_FILE_TXT); + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill(UNICODE_FILE_NAME); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getLastMessageByFileName(UNICODE_FILE_NAME)).toBeVisible(); + }); + + await test.step('download the file and verify the Unicode filename is preserved', async () => { + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemFiles.click(); + + await expect(poHomeChannel.tabs.files.getFileByName(UNICODE_FILE_NAME)).toBeVisible(); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + poHomeChannel.tabs.files.getFileByName(UNICODE_FILE_NAME).click(), + ]); + + expect(download.suggestedFilename()).toBe(UNICODE_FILE_NAME); + }); + }); + test('File encryption with whitelisted and blacklisted media types', async ({ api }) => { await test.step('send a text file in channel', async () => { const updatedFileName = `edited_${TEST_FILE_TXT}`; diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 82026d83339d3..31c8e0de77880 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -22,7 +22,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.32.0", - "@rocket.chat/federation-sdk": "0.3.9", + "@rocket.chat/federation-sdk": "0.4.1", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index a582339d4a333..842c9e9554d8c 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -36,6 +36,8 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private processEDUPresence: boolean; + private processEDUReceipt: boolean; + private validateUserDomain: boolean; private readonly logger = new Logger(this.name); @@ -62,6 +64,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } }); + this.onSettingChanged('Federation_Service_EDU_Process_Receipt', async ({ setting }): Promise => { + const { value } = setting; + if (typeof value === 'boolean') { + this.processEDUReceipt = value; + } + }); + this.onSettingChanged('Federation_Service_Validate_User_Domain', async ({ setting }): Promise => { const { value } = setting; if (typeof value === 'boolean') { @@ -114,6 +123,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS this.serverName = (await Settings.get('Federation_Service_Domain')) || ''; this.processEDUTyping = (await Settings.get('Federation_Service_EDU_Process_Typing')) || false; this.processEDUPresence = (await Settings.get('Federation_Service_EDU_Process_Presence')) || false; + this.processEDUReceipt = (await Settings.get('Federation_Service_EDU_Process_Receipt')) || false; this.validateUserDomain = (await Settings.get('Federation_Service_Validate_User_Domain')) || false; } @@ -836,4 +846,46 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS }) ?? false ); } + + async notifyRoomRead({ room, userId, threadId }: { room: IRoomNativeFederated; userId: string; threadId?: string }): Promise { + if (!this.processEDUReceipt) { + return; + } + + // get last event_id for the room or thread + const lastMessage = threadId + ? await Messages.findVisibleThreadByThreadId(threadId, { + sort: { ts: -1 }, + projection: { federation: 1 }, + }).next() + : await Messages.findVisibleByRoomId(room._id, { projection: { federation: 1 }, sort: { ts: -1 } }).next(); + + if (!lastMessage?.federation?.eventId) { + this.logger.warn({ msg: 'No event ID found for room, skipping read receipt', roomId: room._id }); + return; + } + + const threadEventId = threadId + ? (await Messages.findOneById(threadId, { projection: { federation: 1 } }))?.federation?.eventId + : undefined; + + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('User not found'); + } + + if (!user.username) { + throw new Error('User username not found'); + } + + // TODO: should use common function to get matrix user ID + const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + + await federationSDK.sendReadReceipt({ + roomId: roomIdSchema.parse(room.federation.mrid), + eventIds: [eventIdSchema.parse(lastMessage?.federation?.eventId)], + userId: userIdSchema.parse(matrixUserId), + ...(threadEventId && { threadId: eventIdSchema.parse(threadEventId) }), + }); + } } diff --git a/ee/packages/federation-matrix/src/events/edu.ts b/ee/packages/federation-matrix/src/events/edu.ts index 2235e8479d811..4870203a51a35 100644 --- a/ee/packages/federation-matrix/src/events/edu.ts +++ b/ee/packages/federation-matrix/src/events/edu.ts @@ -1,8 +1,8 @@ -import { api } from '@rocket.chat/core-services'; +import { api, Room } from '@rocket.chat/core-services'; import { UserStatus } from '@rocket.chat/core-typings'; import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Messages, Rooms, Users } from '@rocket.chat/models'; const logger = new Logger('federation-matrix:edu'); @@ -71,4 +71,44 @@ export const edus = async () => { logger.error({ msg: 'Error handling Matrix presence event', err }); } }); + + federationSDK.eventEmitterService.on('homeserver.matrix.receipt', async (data) => { + try { + const matrixUser = await Users.findOneByUsername(data.user_id); + if (!matrixUser) { + logger.debug({ msg: 'No federated user found for Matrix user_id', userId: data.user_id }); + return; + } + + const matrixRoom = await Rooms.findOne({ 'federation.mrid': data.room_id }); + if (!matrixRoom) { + logger.debug({ msg: 'No bridged room found for Matrix room_id', roomId: data.room_id }); + return; + } + + if (data.thread_id) { + const msg = await Messages.findOneByFederationId(data.thread_id); + if (!msg) { + logger.debug({ msg: 'No message found for Matrix thread_id', threadId: data.thread_id }); + return; + } + + if (msg.rid !== matrixRoom._id) { + logger.warn({ + msg: 'Message thread_id does not belong to the expected room', + threadId: data.thread_id, + expectedRoomId: matrixRoom._id, + actualRoomId: msg.rid, + }); + return; + } + + await Room.readThread({ room: matrixRoom, user: matrixUser, tmid: msg._id }); + } else { + await Room.markAsRead(matrixRoom, matrixUser._id); + } + } catch (err) { + logger.error({ msg: 'Error handling Matrix receipt event', err }); + } + }); }; diff --git a/ee/packages/federation-matrix/src/setup.ts b/ee/packages/federation-matrix/src/setup.ts index ccfffb3ad0da0..c1fb33b1911d8 100644 --- a/ee/packages/federation-matrix/src/setup.ts +++ b/ee/packages/federation-matrix/src/setup.ts @@ -42,6 +42,7 @@ export function configureFederationMatrixSettings(settings: { allowedNonPrivateRooms: boolean; processEDUTyping: boolean; processEDUPresence: boolean; + processEDUReceipt: boolean; }) { const { instanceId, @@ -53,6 +54,7 @@ export function configureFederationMatrixSettings(settings: { allowedNonPrivateRooms, processEDUTyping, processEDUPresence, + processEDUReceipt, } = settings; if (!validateDomain(serverName)) { @@ -94,6 +96,7 @@ export function configureFederationMatrixSettings(settings: { edu: { processTyping: processEDUTyping, processPresence: processEDUPresence, + processReceipt: processEDUReceipt, }, }); } diff --git a/packages/core-services/package.json b/packages/core-services/package.json index d219a0ccc9c6d..2345230bb222e 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.9", + "@rocket.chat/federation-sdk": "0.4.1", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "~0.47.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 452e6ad3ff2de..fb177b40ce300 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -30,4 +30,5 @@ export interface IFederationMatrixService { verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise; canUserAccessFederation(user: IUser): Promise; + notifyRoomRead(params: { room: IRoomNativeFederated; userId: string; threadId?: string }): Promise; } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index a5850fe21bea9..7a757fc3eeda2 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -70,4 +70,6 @@ export interface IRoomService { roles?: ISubscription['roles']; }): Promise; updateDirectMessageRoomName(room: IRoom, ignoreStatusFromSubs?: string[]): Promise; + markAsRead(room: IRoom, userId: string, readThreads?: boolean): Promise; + readThread(params: { user: IUser; room: IRoom; tmid: string }): Promise; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c91d77b4cd8b7..c83d2ed1eb979 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2233,6 +2233,9 @@ "Federation_Service_EDU_Process_Presence": "Process Presence events", "Federation_Service_EDU_Process_Presence_Description": "Send and receive events of user presence (online, offline, etc.) between federated servers.", "Federation_Service_EDU_Process_Presence_Alert": "Enabling presence events may increase the load on your server and network traffic considerably, especially if you have many users. Only enable this option if you understand the implications and have the necessary resources to handle the additional load.", + "Federation_Service_EDU_Process_Receipt": "Process Receipt events", + "Federation_Service_EDU_Process_Receipt_Alert": "Enabling receipt events may increase the load on your server and network traffic considerably, especially if you have many users. Only enable this option if you understand the implications and have the necessary resources to handle the additional load.", + "Federation_Service_EDU_Process_Receipt_Description": "Send and receive events of message read receipts between federated servers.", "Federation_Service_Alert": "Beta feature: Ready for Non-Critical Deployments
This feature is currently undergoing final performance and resilience audits and is not yet recommended for mission-critical production data. Functionality may still experience intermittent issues. Users must be explicitly granted the 'access-federation' permission by the Workspace Administrator to interact with federated rooms.", "Federation_Service_Domain": "Federated Domain", "Federation_Service_Domain_Description": "The domain that this server should respond to, for example: `acme.com`. This will be used as the suffix for user IDs (e.g., `@user:acme.com`).
If your chat server is accessible from a different domain than the one you want to use for federation, you should follow our documentation to configure the `.well-known` file on your web server.", diff --git a/yarn.lock b/yarn.lock index 09e77071186c4..439cdb0ac973b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9102,7 +9102,7 @@ __metadata: dependencies: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.9" + "@rocket.chat/federation-sdk": "npm:0.4.1" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:~0.47.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9260,13 +9260,6 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/emitter@npm:^0.31.25": - version: 0.31.25 - resolution: "@rocket.chat/emitter@npm:0.31.25" - checksum: 10/fee26d0200d60eadb246e4e2b40f99bbfaa6f748d11cb8fbbe350219a178630950b1ecbd6145a5dc93f8ff0298afdaef665f544f82bde7b3d0c687a298b9a1e3 - languageName: node - linkType: hard - "@rocket.chat/emitter@npm:^0.32.0": version: 0.32.0 resolution: "@rocket.chat/emitter@npm:0.32.0" @@ -9319,7 +9312,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.32.0" - "@rocket.chat/federation-sdk": "npm:0.3.9" + "@rocket.chat/federation-sdk": "npm:0.4.1" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9345,22 +9338,22 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.3.9": - version: 0.3.9 - resolution: "@rocket.chat/federation-sdk@npm:0.3.9" +"@rocket.chat/federation-sdk@npm:0.4.1": + version: 0.4.1 + resolution: "@rocket.chat/federation-sdk@npm:0.4.1" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" - "@rocket.chat/emitter": "npm:^0.31.25" + "@rocket.chat/emitter": "npm:^0.32.0" mongodb: "npm:^6.16.0" pino: "npm:^8.21.0" reflect-metadata: "npm:^0.2.2" tsyringe: "npm:^4.10.0" tweetnacl: "npm:^1.0.3" - zod: "npm:^3.24.1" + zod: "npm:~4.3.6" peerDependencies: typescript: ~5.9.2 - checksum: 10/8bf215c37fa3c181d12731a2b2f5068656b64736c05560a070ad8dde6177e48cd262a2a26d8cc56e8cee7850f25a3bc5dd069537c4a1ee3f638b0d94cb11519c + checksum: 10/a9ad0386a27779b4b0d13825a4fecfc1ee7ce4e3f6d39a9370a769757b03a15a5e0004a09499be85294a872ae444f18ea071c734a18e7b4a9515d27251df6c4a languageName: node linkType: hard @@ -9967,7 +9960,7 @@ __metadata: "@rocket.chat/emitter": "npm:^0.32.0" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.9" + "@rocket.chat/federation-sdk": "npm:0.4.1" "@rocket.chat/fuselage": "npm:^0.73.0" "@rocket.chat/fuselage-forms": "npm:^1.0.0" "@rocket.chat/fuselage-hooks": "npm:^0.40.0" @@ -39104,13 +39097,6 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.24.1": - version: 3.24.1 - resolution: "zod@npm:3.24.1" - checksum: 10/54e25956495dec22acb9399c168c6ba657ff279801a7fcd0530c414d867f1dcca279335e160af9b138dd70c332e17d548be4bc4d2f7eaf627dead50d914fec27 - languageName: node - linkType: hard - "zod@npm:^3.25.0 || ^4.0.0, zod@npm:~4.3.6": version: 4.3.6 resolution: "zod@npm:4.3.6"