diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 4f473477f25..ca6bb089d97 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -36,6 +36,7 @@ import { saveRoomSettings } from '../../../channel-settings/server/methods/saveR import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { addUsersToRoomMethod } from '../../../lib/server/methods/addUsersToRoom'; import { createChannelMethod } from '../../../lib/server/methods/createChannel'; +import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -163,10 +164,11 @@ API.v1.addRoute( const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); - const result = await Meteor.callAsync('getChannelHistory', { + const result = await getChannelHistory({ rid: findResult._id, + fromUserId: this.userId, latest: latest ? new Date(latest) : new Date(), - oldest: oldest && new Date(oldest), + oldest: oldest ? new Date(oldest) : undefined, inclusive: inclusive === 'true', offset, count, diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index a216ac4e73f..099a45938b0 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -14,6 +14,7 @@ import { Meteor } from 'meteor/meteor'; import { reportMessage } from '../../../../server/lib/moderation/reportMessage'; import { messageSearch } from '../../../../server/methods/messageSearch'; +import { getMessageHistory } from '../../../../server/publications/messages'; import { roomAccessAttributes } from '../../../authorization/server'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; @@ -101,7 +102,7 @@ API.v1.addRoute( ...(type && { type }), }; - const result = await Meteor.callAsync('messages/get', roomId, getMessagesQuery); + const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); if (!result) { return API.v1.failure(); @@ -109,9 +110,9 @@ API.v1.addRoute( return API.v1.success({ result: { - ...(result.updated && { updated: await normalizeMessagesForUser(result.updated, this.userId) }), - ...(result.deleted && { deleted: result.deleted }), - ...(result.cursor && { cursor: result.cursor }), + updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], + deleted: 'deleted' in result ? result.deleted : [], + cursor: 'cursor' in result ? result.cursor : undefined, }, }); }, diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index f33c9a6db55..df784419554 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -16,6 +16,7 @@ import { hasAllPermissionAsync, hasPermissionAsync } from '../../../authorizatio import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { createPrivateGroupMethod } from '../../../lib/server/methods/createPrivateGroup'; +import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; @@ -505,8 +506,9 @@ API.v1.addRoute( const showThreadMessages = this.queryParams.showThreadMessages !== 'false'; - const result = await Meteor.callAsync('getChannelHistory', { + const result = await getChannelHistory({ rid: findResult.rid, + fromUserId: this.userId, latest: latestDate, oldest: oldestDate, inclusive, diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 765e3c896f7..a4761102aff 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -21,6 +21,7 @@ import { canAccessRoomIdAsync } from '../../../authorization/server/functions/ca import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; +import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; @@ -277,8 +278,9 @@ API.v1.addRoute( const objectParams = { rid: room._id, + fromUserId: this.userId, latest: latest ? new Date(latest) : new Date(), - oldest: oldest && new Date(oldest), + oldest: oldest ? new Date(oldest) : undefined, inclusive: inclusive === 'true', offset, count, @@ -286,7 +288,7 @@ API.v1.addRoute( showThreadMessages: showThreadMessages === 'true', }; - const result = await Meteor.callAsync('getChannelHistory', objectParams); + const result = await getChannelHistory(objectParams); if (!result) { return API.v1.forbidden(); diff --git a/apps/meteor/app/lib/server/methods/getChannelHistory.ts b/apps/meteor/app/lib/server/methods/getChannelHistory.ts index 8fe5812dd62..3f3257b34fd 100644 --- a/apps/meteor/app/lib/server/methods/getChannelHistory.ts +++ b/apps/meteor/app/lib/server/methods/getChannelHistory.ts @@ -26,125 +26,159 @@ declare module '@rocket.chat/ddp-client' { } } -Meteor.methods({ - async getChannelHistory({ rid, latest, oldest, inclusive, offset = 0, count = 20, unreads, showThreadMessages = true }) { - check(rid, String); +export const getChannelHistory = async ({ + rid, + fromUserId, + latest, + oldest, + inclusive, + offset = 0, + count = 20, + unreads, + showThreadMessages = true, +}: { + rid: string; + fromUserId: string; + latest?: Date; + oldest?: Date; + inclusive?: boolean; + offset?: number; + count?: number; + unreads?: boolean; + showThreadMessages?: boolean; +}): Promise => { + check(rid, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getChannelHistory' }); + } - if (!Meteor.userId()) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getChannelHistory' }); - } + if (!fromUserId) { + return false; + } - const fromUserId = Meteor.userId(); - if (!fromUserId) { - return false; - } + const room = await Rooms.findOneById(rid); + if (!room) { + return false; + } - const room = await Rooms.findOneById(rid); - if (!room) { - return false; - } + if (!(await canAccessRoomAsync(room, { _id: fromUserId }))) { + return false; + } - if (!(await canAccessRoomAsync(room, { _id: fromUserId }))) { - return false; - } + // Make sure they can access the room + if ( + room.t === 'c' && + !(await hasPermissionAsync(fromUserId, 'preview-c-room')) && + !(await Subscriptions.findOneByRoomIdAndUserId(rid, fromUserId, { projection: { _id: 1 } })) + ) { + return false; + } - // Make sure they can access the room - if ( - room.t === 'c' && - !(await hasPermissionAsync(fromUserId, 'preview-c-room')) && - !(await Subscriptions.findOneByRoomIdAndUserId(rid, fromUserId, { projection: { _id: 1 } })) - ) { - return false; - } + // Ensure latest is always defined. + if (latest === undefined) { + latest = new Date(); + } - // Ensure latest is always defined. - if (latest === undefined) { - latest = new Date(); - } + // Verify oldest is a date if it exists - // Verify oldest is a date if it exists + if (oldest !== undefined && {}.toString.call(oldest) !== '[object Date]') { + throw new Meteor.Error('error-invalid-date', 'Invalid date', { method: 'getChannelHistory' }); + } - if (oldest !== undefined && {}.toString.call(oldest) !== '[object Date]') { - throw new Meteor.Error('error-invalid-date', 'Invalid date', { method: 'getChannelHistory' }); + const hiddenSystemMessages = settings.get('Hide_System_Messages'); + + const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages); + + const options: Record = { + sort: { + ts: -1, + }, + skip: offset, + limit: count, + }; + + const records = + oldest === undefined + ? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes( + rid, + latest, + hiddenMessageTypes, + options, + showThreadMessages, + inclusive, + ).toArray() + : await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + oldest, + latest, + hiddenMessageTypes, + options, + showThreadMessages, + inclusive, + ).toArray(); + + const messages = await normalizeMessagesForUser(records, fromUserId); + + if (unreads) { + let unreadNotLoaded = 0; + let firstUnread = undefined; + + if (oldest !== undefined) { + const firstMsg = messages[messages.length - 1]; + if (firstMsg !== undefined && firstMsg.ts > oldest) { + const unreadMessages = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + oldest, + firstMsg.ts, + hiddenMessageTypes, + { + limit: 1, + sort: { + ts: 1, + }, + }, + showThreadMessages, + ); + + const totalCursor = await Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + oldest, + firstMsg.ts, + hiddenMessageTypes, + showThreadMessages, + ); + + firstUnread = (await unreadMessages.toArray())[0]; + unreadNotLoaded = totalCursor; + } } - const hiddenSystemMessages = settings.get('Hide_System_Messages'); + return { + messages: messages || [], + firstUnread, + unreadNotLoaded, + }; + } - const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages); + return { + messages: messages || [], + }; +}; - const options: Record = { - sort: { - ts: -1, - }, - skip: offset, - limit: count, - }; +Meteor.methods({ + async getChannelHistory({ rid, latest, oldest, inclusive, offset = 0, count = 20, unreads, showThreadMessages = true }) { + check(rid, String); - const records = - oldest === undefined - ? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes( - rid, - latest, - hiddenMessageTypes, - options, - showThreadMessages, - inclusive, - ).toArray() - : await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( - rid, - oldest, - latest, - hiddenMessageTypes, - options, - showThreadMessages, - inclusive, - ).toArray(); - - const messages = await normalizeMessagesForUser(records, fromUserId); - - if (unreads) { - let unreadNotLoaded = 0; - let firstUnread = undefined; - - if (oldest !== undefined) { - const firstMsg = messages[messages.length - 1]; - if (firstMsg !== undefined && firstMsg.ts > oldest) { - const unreadMessages = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( - rid, - oldest, - firstMsg.ts, - hiddenMessageTypes, - { - limit: 1, - sort: { - ts: 1, - }, - }, - showThreadMessages, - ); - - const totalCursor = await Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes( - rid, - oldest, - firstMsg.ts, - hiddenMessageTypes, - showThreadMessages, - ); - - firstUnread = (await unreadMessages.toArray())[0]; - unreadNotLoaded = totalCursor; - } - } + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getChannelHistory' }); + } - return { - messages: messages || [], - firstUnread, - unreadNotLoaded, - }; + const fromUserId = Meteor.userId(); + if (!fromUserId) { + return false; } - return { - messages: messages || [], - }; + return getChannelHistory({ rid, fromUserId, latest, oldest, inclusive, offset, count, unreads, showThreadMessages }); }, }); diff --git a/apps/meteor/server/publications/messages.ts b/apps/meteor/server/publications/messages.ts index 21fcfd3524c..6aaf35e3916 100644 --- a/apps/meteor/server/publications/messages.ts +++ b/apps/meteor/server/publications/messages.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import type { FindOptions } from 'mongodb'; import { canAccessRoomIdAsync } from '../../app/authorization/server/functions/canAccessRoom'; +import { getChannelHistory } from '../../app/lib/server/methods/getChannelHistory'; type CursorPaginationType = 'UPDATED' | 'DELETED'; @@ -25,14 +26,19 @@ declare module '@rocket.chat/ddp-client' { previous?: string; type?: CursorPaginationType; }, - ) => Promise<{ - updated: IMessage[]; - deleted: IMessage[]; - cursor: { - next: string | null; - previous: string | null; - }; - }>; + ) => Promise< + | { + updated: IMessage[]; + deleted: IMessage[]; + cursor?: { + next: string | null; + previous: string | null; + }; + } + | boolean + | IMessage[] + | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number } + >; } } @@ -155,13 +161,23 @@ export async function handleCursorPagination( count: number, next?: string, previous?: string, -) { +): Promise<{ + updated: IMessage[]; + deleted: IMessage[]; + cursor?: { + next: string | null; + previous: string | null; + }; +}> { const { query, options } = mountCursorQuery({ next, previous, count }); const response = type === 'UPDATED' ? await Messages.findForUpdates(rid, query, options).toArray() - : ((await Messages.trashFind({ rid, _deletedAt: query }, { projection: { _id: 1, _deletedAt: 1 }, ...options })!.toArray()) ?? []); + : ((await Messages.trashFind( + { rid, _deletedAt: query }, + { projection: { _id: 1, _deletedAt: 1 }, ...options }, + )!.toArray()) as IMessage[]); const cursor = { next: mountNextCursor(response, count, type, next, previous), @@ -173,11 +189,89 @@ export async function handleCursorPagination( } return { - [type.toLowerCase()]: response, + updated: type === 'UPDATED' ? response : [], + deleted: type === 'DELETED' ? response : [], cursor, }; } +export const getMessageHistory = async ( + rid: IRoom['_id'], + fromId: string, + { + lastUpdate, + latestDate = new Date(), + oldestDate, + inclusive = false, + count = 20, + unreads = false, + next, + previous, + type, + }: { + lastUpdate?: Date; + latestDate?: Date; + oldestDate?: Date; + inclusive?: boolean; + count?: number; + unreads?: boolean; + next?: string; + previous?: string; + type?: CursorPaginationType; + }, +): Promise< + | { + updated: IMessage[]; + deleted: IMessage[]; + cursor?: { + next: string | null; + previous: string | null; + }; + } + | false + | IMessage[] + | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number } +> => { + if (!(await canAccessRoomIdAsync(rid, fromId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'messages/get' }); + } + + if (type && !['UPDATED', 'DELETED'].includes(type)) { + throw new Meteor.Error('error-type-param-not-supported', 'The "type" parameter must be either "UPDATED" or "DELETED"'); + } + + if ((next || previous) && !type) { + throw new Meteor.Error('error-type-param-required', 'The "type" parameter is required when using the "next" or "previous" parameters'); + } + + if (next && previous) { + throw new Meteor.Error('error-cursor-conflict', 'You cannot provide both "next" and "previous" parameters'); + } + + if ((next || previous) && lastUpdate) { + throw new Meteor.Error( + 'error-cursor-and-lastUpdate-conflict', + 'The attributes "next", "previous" and "lastUpdate" cannot be used together', + ); + } + + const hasCursorPagination = !!((next || previous) && count !== null && type); + + if (!hasCursorPagination && !lastUpdate) { + return getChannelHistory({ rid, fromUserId: fromId, latest: latestDate, oldest: oldestDate, inclusive, count, unreads }); + } + + if (lastUpdate) { + return handleWithoutPagination(rid, lastUpdate); + } + + if (!type) { + throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); + } + + return handleCursorPagination(type, rid, count, next, previous); +}; + Meteor.methods({ async 'messages/get'( rid, @@ -195,53 +289,6 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'messages/get' }); } - if (!(await canAccessRoomIdAsync(rid, fromId))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'messages/get' }); - } - - if (type && !['UPDATED', 'DELETED'].includes(type)) { - throw new Meteor.Error('error-type-param-not-supported', 'The "type" parameter must be either "UPDATED" or "DELETED"'); - } - - if ((next || previous) && !type) { - throw new Meteor.Error( - 'error-type-param-required', - 'The "type" parameter is required when using the "next" or "previous" parameters', - ); - } - - if (next && previous) { - throw new Meteor.Error('error-cursor-conflict', 'You cannot provide both "next" and "previous" parameters'); - } - - if ((next || previous) && lastUpdate) { - throw new Meteor.Error( - 'error-cursor-and-lastUpdate-conflict', - 'The attributes "next", "previous" and "lastUpdate" cannot be used together', - ); - } - - const hasCursorPagination = !!((next || previous) && count !== null && type); - - if (!hasCursorPagination && !lastUpdate) { - return Meteor.callAsync('getChannelHistory', { - rid, - latest: latestDate, - oldest: oldestDate, - inclusive, - count, - unreads, - }); - } - - if (lastUpdate) { - return handleWithoutPagination(rid, lastUpdate); - } - - if (!type) { - throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); - } - - return handleCursorPagination(type, rid, count, next, previous); + return getMessageHistory(rid, fromId, { lastUpdate, latestDate, oldestDate, inclusive, count, unreads, next, previous, type }); }, }); diff --git a/apps/meteor/tests/unit/server/publications/messages.spec.ts b/apps/meteor/tests/unit/server/publications/messages.spec.ts index cb4a86b6cb5..8fb9d6f030b 100644 --- a/apps/meteor/tests/unit/server/publications/messages.spec.ts +++ b/apps/meteor/tests/unit/server/publications/messages.spec.ts @@ -33,6 +33,9 @@ const { 'meteor/check': { check: sinon.stub(), }, + '../../app/lib/server/methods/getChannelHistory': { + getChannelHistory: sinon.stub(), + }, 'meteor/meteor': { 'Meteor': { methods: sinon.stub(),