From f57901d91feaccedd00dee65b78775b20235825b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 15 Jun 2026 19:48:06 -0300 Subject: [PATCH 1/2] chore: migrate five more client DDP callers to new REST endpoints (#40724) --- .changeset/ddp-migrate-batch3-callers.md | 10 ++ .../rest-e2e-request-subscription-keys.md | 6 + .changeset/rest-im-block-user.md | 6 + .changeset/rest-settings-post.md | 6 + apps/meteor/app/api/server/v1/e2e.ts | 19 +++ apps/meteor/app/api/server/v1/im.ts | 42 +++++- apps/meteor/app/api/server/v1/settings.ts | 28 ++++ .../server/methods/requestSubscriptionKeys.ts | 44 +++--- .../app/lib/server/functions/blockUser.ts | 35 +++++ .../lib/server/functions/saveSettingsBulk.ts | 129 ++++++++++++++++++ .../app/lib/server/functions/unblockUser.ts | 23 ++++ .../app/lib/server/methods/blockUser.ts | 35 +---- .../app/lib/server/methods/saveSettings.ts | 112 +-------------- .../app/lib/server/methods/unblockUser.ts | 25 +--- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- .../client/providers/SettingsProvider.tsx | 6 +- .../actions/useBlockUserAction.ts | 15 +- apps/meteor/tests/e2e/utils/saveSettings.ts | 10 +- .../tests/end-to-end/api/custom-sounds.ts | 63 ++++++++- .../tests/end-to-end/api/direct-message.ts | 57 ++++++++ apps/meteor/tests/end-to-end/api/settings.ts | 78 +++++++++++ apps/meteor/tests/end-to-end/api/users.ts | 16 +++ .../src/v1/dm/DmBlockUserProps.ts | 23 ++++ packages/rest-typings/src/v1/dm/im.ts | 4 + packages/rest-typings/src/v1/dm/index.ts | 1 + packages/rest-typings/src/v1/e2e.ts | 4 + packages/rest-typings/src/v1/settings.ts | 30 +++- 27 files changed, 635 insertions(+), 194 deletions(-) create mode 100644 .changeset/ddp-migrate-batch3-callers.md create mode 100644 .changeset/rest-e2e-request-subscription-keys.md create mode 100644 .changeset/rest-im-block-user.md create mode 100644 .changeset/rest-settings-post.md create mode 100644 apps/meteor/app/lib/server/functions/blockUser.ts create mode 100644 apps/meteor/app/lib/server/functions/saveSettingsBulk.ts create mode 100644 apps/meteor/app/lib/server/functions/unblockUser.ts create mode 100644 packages/rest-typings/src/v1/dm/DmBlockUserProps.ts diff --git a/.changeset/ddp-migrate-batch3-callers.md b/.changeset/ddp-migrate-batch3-callers.md new file mode 100644 index 0000000000000..4606c3a4adcc1 --- /dev/null +++ b/.changeset/ddp-migrate-batch3-callers.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate four client DDP callers to their REST equivalents (the DDP methods stay registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes them): + +- `deleteCustomSound` → `POST /v1/custom-sounds.delete` +- `blockUser` / `unblockUser` → `POST /v1/im.blockUser` (single toggle with `{ roomId, block: boolean }`) +- `saveSettings` → `POST /v1/settings` +- `e2e.requestSubscriptionKeys` → `POST /v1/e2e.requestSubscriptionKeys` diff --git a/.changeset/rest-e2e-request-subscription-keys.md b/.changeset/rest-e2e-request-subscription-keys.md new file mode 100644 index 0000000000000..3b6ee9bd087ef --- /dev/null +++ b/.changeset/rest-e2e-request-subscription-keys.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/e2e.requestSubscriptionKeys` (replaces the deprecated `e2e.requestSubscriptionKeys` DDP method). Auth-gated, no body. Broadcasts `notify.e2e.keyRequest` for every encrypted room the caller is subscribed to without an E2E key, matching the DDP method's behavior. The legacy DDP method remains registered until 9.0.0 with a deprecation log pointing at the new route. diff --git a/.changeset/rest-im-block-user.md b/.changeset/rest-im-block-user.md new file mode 100644 index 0000000000000..d4f840ef42da8 --- /dev/null +++ b/.changeset/rest-im-block-user.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/im.blockUser` (replaces the deprecated `blockUser` / `unblockUser` DDP methods). Body is `{ roomId, block: boolean }` — `block: true` blocks the other DM participant, `block: false` unblocks. Auth-gated and per-room via the `RoomMemberActions.BLOCK` directive (DM-only). Both legacy DDP methods remain registered until 9.0.0 with deprecation logs pointing at the new route. diff --git a/.changeset/rest-settings-post.md b/.changeset/rest-settings-post.md new file mode 100644 index 0000000000000..b1b7f3c4f8686 --- /dev/null +++ b/.changeset/rest-settings-post.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/settings` for batched admin setting updates (replaces the deprecated `saveSettings` DDP method). Body is `{ settings: { _id, value }[] }`. The endpoint requires authentication, enforces 2FA (`twoFactorRequired: true`), and runs the same per-setting permission chain (`edit-privileged-setting` OR `manage-selected-settings` + per-id permission) and audit/notify side effects the DDP method already performed. The legacy DDP method remains registered until 9.0.0 with a deprecation log pointing at the new route. diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index 489a9e27a5953..a864d2cfcaba1 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -16,6 +16,7 @@ import { handleSuggestedGroupKey } from '../../../e2e/server/functions/handleSug import { provideUsersSuggestedGroupKeys } from '../../../e2e/server/functions/provideUsersSuggestedGroupKeys'; import { resetRoomKey } from '../../../e2e/server/functions/resetRoomKey'; import { getUsersOfRoomWithoutKeyMethod } from '../../../e2e/server/methods/getUsersOfRoomWithoutKey'; +import { requestSubscriptionKeysMethod } from '../../../e2e/server/methods/requestSubscriptionKeys'; import { setRoomKeyIDMethod } from '../../../e2e/server/methods/setRoomKeyID'; import { setUserPublicAndPrivateKeysMethod } from '../../../e2e/server/methods/setUserPublicAndPrivateKeys'; import { updateGroupKey } from '../../../e2e/server/methods/updateGroupKey'; @@ -196,6 +197,24 @@ const e2eEndpoints = API.v1 return API.v1.success(); }, ) + .post( + 'e2e.requestSubscriptionKeys', + { + authRequired: true, + response: { + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + await requestSubscriptionKeysMethod(this.userId); + + return API.v1.success(); + }, + ) .get( 'e2e.fetchMyKeys', { diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 1cbcf3237e3ac..826f181d4419a 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -9,6 +9,7 @@ import { validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, + isDmBlockUserProps, isDmFileProps, isDmMemberProps, isDmMessagesProps, @@ -26,7 +27,9 @@ import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; +import { blockUserMethod } from '../../../lib/server/functions/blockUser'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; +import { unblockUserMethod } from '../../../lib/server/functions/unblockUser'; import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -924,6 +927,42 @@ const dmCreateAction = (_path: Path): TypedAction({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +const dmBlockUserAction = (_path: Path): TypedAction => + async function action() { + const { roomId, block } = this.bodyParams; + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + const blocked = room.uids?.find((uid) => uid !== this.userId); + if (!blocked) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'im.blockUser' }); + } + + if (block) { + await blockUserMethod(this.userId, { rid: room._id, blocked }); + } else { + await unblockUserMethod(this.userId, { rid: room._id, blocked }); + } + + return API.v1.success(); + }; + const dmEndpoints = API.v1 .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) @@ -950,7 +989,8 @@ const dmEndpoints = API.v1 .get('dm.list', dmListEndpointsProps, dmListAction('dm.list')) .get('im.list', dmListEndpointsProps, dmListAction('im.list')) .get('dm.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('dm.list.everyone')) - .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')); + .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')) + .post('im.blockUser', dmBlockUserEndpointsProps, dmBlockUserAction('im.blockUser')); export type DmEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 97e4a51d3133c..6c764f4105bef 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -15,8 +15,10 @@ import { isSettingsUpdatePropsColor, isSettingsPublicWithPaginationProps, isSettingsGetParams, + isSettingsBulkProps, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import type { FindOptions } from 'mongodb'; @@ -25,6 +27,7 @@ import _ from 'underscore'; import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { disableCustomScripts } from '../../../lib/server/functions/disableCustomScripts'; +import { saveSettingsBulk } from '../../../lib/server/functions/saveSettingsBulk'; import { checkSettingValueBounds } from '../../../lib/server/lib/checkSettingValueBonds'; import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { addOAuthServiceMethod } from '../../../lib/server/methods/addOAuthService'; @@ -404,6 +407,31 @@ API.v1.post( }, ); +API.v1.post( + 'settings', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: isSettingsBulkProps, + response: { + 200: settingByIdPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await saveSettingsBulk(this.userId, this.bodyParams.settings, { + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + + return API.v1.success(); + }, +); + API.v1.get( 'service.configurations', { diff --git a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts index cf899a5d64ad7..2beea4346dcaa 100644 --- a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts +++ b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts @@ -3,6 +3,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -10,8 +12,31 @@ declare module '@rocket.chat/ddp-client' { } } +export const requestSubscriptionKeysMethod = async (userId: string): Promise => { + // Get all encrypted rooms that the user is subscribed to and has no E2E key yet + const subscriptions = await Subscriptions.findByUserIdWithoutE2E(userId).toArray(); + const roomIds = subscriptions.map((subscription) => subscription.rid); + + // For all subscriptions without E2E key, get the rooms that have encryption enabled + const query = { + e2eKeyId: { + $exists: true, + }, + _id: { + $in: roomIds, + }, + }; + + const rooms = Rooms.find(query); + await rooms.forEach((room) => { + void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); + }); +}; + Meteor.methods({ async 'e2e.requestSubscriptionKeys'() { + methodDeprecationLogger.method('e2e.requestSubscriptionKeys', '9.0.0', '/v1/e2e.requestSubscriptionKeys'); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -19,24 +44,7 @@ Meteor.methods({ }); } - // Get all encrypted rooms that the user is subscribed to and has no E2E key yet - const subscriptions = await Subscriptions.findByUserIdWithoutE2E(userId).toArray(); - const roomIds = subscriptions.map((subscription) => subscription.rid); - - // For all subscriptions without E2E key, get the rooms that have encryption enabled - const query = { - e2eKeyId: { - $exists: true, - }, - _id: { - $in: roomIds, - }, - }; - - const rooms = Rooms.find(query); - await rooms.forEach((room) => { - void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); - }); + await requestSubscriptionKeysMethod(userId); return true; }, diff --git a/apps/meteor/app/lib/server/functions/blockUser.ts b/apps/meteor/app/lib/server/functions/blockUser.ts new file mode 100644 index 0000000000000..c748ade354a34 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/blockUser.ts @@ -0,0 +1,35 @@ +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; + +export const blockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise => { + const room = await Rooms.findOne({ _id: rid }); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, userId))) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); + + if (!blockedUser || !blockerUser) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } +}; diff --git a/apps/meteor/app/lib/server/functions/saveSettingsBulk.ts b/apps/meteor/app/lib/server/functions/saveSettingsBulk.ts new file mode 100644 index 0000000000000..9f4f5a1a83f38 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveSettingsBulk.ts @@ -0,0 +1,129 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { isSettingCode } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { disableCustomScripts } from './disableCustomScripts'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; +import { getSettingPermissionId } from '../../../authorization/lib'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { settings } from '../../../settings/server'; +import { checkSettingValueBounds } from '../lib/checkSettingValueBonds'; +import { notifyOnSettingChangedById } from '../lib/notifyListener'; + +const validJSON = Match.Where((value: string) => { + try { + value === '' || JSON.parse(value); + return true; + } catch (_) { + throw new Meteor.Error('Invalid JSON provided'); + } +}); + +const checkInteger = (value: ISetting['value']) => { + if (!Number.isInteger(value)) { + throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, { + method: 'saveSettings', + }); + } +}; + +export type SaveSettingsAudit = { + username: string; + ip: string; + useragent: string; +}; + +export const saveSettingsBulk = async ( + uid: string, + params: { _id: ISetting['_id']; value: ISetting['value'] }[], + audit: SaveSettingsAudit, +): Promise => { + const settingsNotAllowed: ISetting['_id'][] = []; + + const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); + const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); + + // if the id contains Organization_Name then change the Site_Name + const orgName = params.find(({ _id }) => _id === 'Organization_Name'); + + if (orgName) { + // check if the site name is still the default value or ifs the same as organization name + const siteName = await Settings.findOneById('Site_Name'); + + if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { + params.push({ + _id: 'Site_Name', + value: orgName.value, + }); + } + } + + await Promise.all( + params.map(async ({ _id, value }) => { + // Verify the _id passed in is a string. + check(_id, String); + if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { + return settingsNotAllowed.push(_id); + } + + // Disable custom scripts in cloud trials to prevent phishing campaigns + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + return settingsNotAllowed.push(_id); + } + + const setting = await Settings.findOneById(_id); + // Verify the value is what it should be + switch (setting?.type) { + case 'roomPick': + check(value, Match.OneOf([Object], '')); + break; + case 'boolean': + check(value, Boolean); + break; + case 'timespan': + case 'int': + case 'range': + check(value, Number); + checkInteger(value); + checkSettingValueBounds(setting, value); + break; + case 'multiSelect': + check(value, Array); + break; + case 'code': + check(value, String); + if (isSettingCode(setting) && setting.code === 'application/json') { + check(value, validJSON); + } + break; + default: + check(value, String); + break; + } + }), + ); + + if (settingsNotAllowed.length) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSettings', + settingIds: settingsNotAllowed, + }); + } + + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: audit.username, + ip: audit.ip, + useragent: audit.useragent, + }); + + const promises = params.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(params[index]._id); + } + }); +}; diff --git a/apps/meteor/app/lib/server/functions/unblockUser.ts b/apps/meteor/app/lib/server/functions/unblockUser.ts new file mode 100644 index 0000000000000..03ee770f69d6b --- /dev/null +++ b/apps/meteor/app/lib/server/functions/unblockUser.ts @@ -0,0 +1,23 @@ +import { Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; + +export const unblockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise => { + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); + + if (!blockedUser || !blockerUser) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'unblockUser' }); + } + + const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } +}; diff --git a/apps/meteor/app/lib/server/methods/blockUser.ts b/apps/meteor/app/lib/server/methods/blockUser.ts index 7fe6ec803dd1d..cc873f62e8e24 100644 --- a/apps/meteor/app/lib/server/methods/blockUser.ts +++ b/apps/meteor/app/lib/server/methods/blockUser.ts @@ -1,11 +1,9 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Subscriptions, Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; -import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; +import { blockUserMethod } from '../functions/blockUser'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,40 +14,17 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async blockUser({ rid, blocked }) { + methodDeprecationLogger.method('blockUser', '9.0.0', '/v1/im.blockUser'); check(rid, String); check(blocked, String); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); } - const room = await Rooms.findOne({ _id: rid }); - - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); - } - - if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, userId))) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); - } - - const [blockedUser, blockerUser] = await Promise.all([ - Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), - Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), - ]); - - if (!blockedUser || !blockerUser) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); - } - - const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); - - const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; - - if (listenerUsers.length) { - void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); - } + await blockUserMethod(userId, { rid, blocked }); return true; }, diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index e8efa3be6ebd1..c01f40481b106 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -1,18 +1,10 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import { isSettingCode } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Settings } from '@rocket.chat/models'; -import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; -import { getSettingPermissionId } from '../../../authorization/lib'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { settings } from '../../../settings/server'; -import { disableCustomScripts } from '../functions/disableCustomScripts'; -import { checkSettingValueBounds } from '../lib/checkSettingValueBonds'; -import { notifyOnSettingChangedById } from '../lib/notifyListener'; +import { saveSettingsBulk } from '../functions/saveSettingsBulk'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -26,23 +18,6 @@ declare module '@rocket.chat/ddp-client' { } } -const validJSON = Match.Where((value: string) => { - try { - value === '' || JSON.parse(value); - return true; - } catch (_) { - throw new Meteor.Error('Invalid JSON provided'); - } -}); - -const checkInteger = (value: ISetting['value']) => { - if (!Number.isInteger(value)) { - throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, { - method: 'saveSettings', - }); - } -}; - Meteor.methods({ saveSettings: twoFactorRequired(async function ( params: { @@ -50,98 +25,21 @@ Meteor.methods({ value: ISetting['value']; }[] = [], ) { + methodDeprecationLogger.method('saveSettings', '9.0.0', '/v1/settings'); + const uid = Meteor.userId(); - const settingsNotAllowed: ISetting['_id'][] = []; if (uid === null) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { method: 'saveSetting', }); } - const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); - const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); - - // if the id contains Organization_Name then change the Site_Name - const orgName = params.find(({ _id }) => _id === 'Organization_Name'); - - if (orgName) { - // check if the site name is still the default value or ifs the same as organization name - const siteName = await Settings.findOneById('Site_Name'); - - if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { - params.push({ - _id: 'Site_Name', - value: orgName.value, - }); - } - } - - await Promise.all( - params.map(async ({ _id, value }) => { - // Verify the _id passed in is a string. - check(_id, String); - if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { - return settingsNotAllowed.push(_id); - } - - // Disable custom scripts in cloud trials to prevent phishing campaigns - if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { - return settingsNotAllowed.push(_id); - } - - const setting = await Settings.findOneById(_id); - // Verify the value is what it should be - switch (setting?.type) { - case 'roomPick': - check(value, Match.OneOf([Object], '')); - break; - case 'boolean': - check(value, Boolean); - break; - case 'timespan': - case 'int': - case 'range': - check(value, Number); - checkInteger(value); - checkSettingValueBounds(setting, value); - break; - case 'multiSelect': - check(value, Array); - break; - case 'code': - check(value, String); - if (isSettingCode(setting) && setting.code === 'application/json') { - check(value, validJSON); - } - break; - default: - check(value, String); - break; - } - }), - ); - - if (settingsNotAllowed.length) { - throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { - method: 'saveSettings', - settingIds: settingsNotAllowed, - }); - } - const auditSettingOperation = updateAuditedByUser({ - _id: uid, + await saveSettingsBulk(uid, params, { username: (await Meteor.userAsync())!.username!, ip: this.connection.clientAddress || '', useragent: this.connection.httpHeaders['user-agent'] || '', }); - const promises = params.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); - - (await Promise.all(promises)).forEach((value, index) => { - if (value?.modifiedCount) { - void notifyOnSettingChangedById(params[index]._id); - } - }); - return true; }, {}), }); diff --git a/apps/meteor/app/lib/server/methods/unblockUser.ts b/apps/meteor/app/lib/server/methods/unblockUser.ts index 7b4bc56600103..ef940bf40b4eb 100644 --- a/apps/meteor/app/lib/server/methods/unblockUser.ts +++ b/apps/meteor/app/lib/server/methods/unblockUser.ts @@ -1,9 +1,9 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; +import { unblockUserMethod } from '../functions/unblockUser'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,30 +14,17 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async unblockUser({ rid, blocked }) { + methodDeprecationLogger.method('unblockUser', '9.0.0', '/v1/im.blockUser'); check(rid, String); check(blocked, String); + const userId = Meteor.userId(); if (!userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); - } - - const [blockedUser, blockerUser] = await Promise.all([ - Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), - Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), - ]); - - if (!blockedUser || !blockerUser) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unblockUser' }); } - const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); - - const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; - - if (listenerUsers.length) { - void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); - } + await unblockUserMethod(userId, { rid, blocked }); return true; }, diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 6562dc384e36f..5e04134ed5065 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -513,7 +513,7 @@ class E2E extends Emitter { } async requestSubscriptionKeys(): Promise { - await sdk.call('e2e.requestSubscriptionKeys'); + await sdk.rest.post('/v1/e2e.requestSubscriptionKeys'); } async createRandomPassword(): Promise { diff --git a/apps/meteor/client/providers/SettingsProvider.tsx b/apps/meteor/client/providers/SettingsProvider.tsx index d90f167e6feaf..9412bd1989c2d 100644 --- a/apps/meteor/client/providers/SettingsProvider.tsx +++ b/apps/meteor/client/providers/SettingsProvider.tsx @@ -1,7 +1,7 @@ import type { ISetting } from '@rocket.chat/core-typings'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; import type { SettingsContextQuery, SettingsContextValue } from '@rocket.chat/ui-contexts'; -import { SettingsContext, useAtLeastOnePermission, useMethod } from '@rocket.chat/ui-contexts'; +import { SettingsContext, useAtLeastOnePermission, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useCallback, useMemo } from 'react'; @@ -96,7 +96,7 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { const queryClient = useQueryClient(); - const saveSettings = useMethod('saveSettings'); + const saveSettings = useEndpoint('POST', '/v1/settings'); const dispatch = useCallback( async (changes: Partial[]) => { // FIXME: This is a temporary solution to invalidate queries when settings change @@ -106,7 +106,7 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { } }); - await saveSettings(changes as Pick[]); + await saveSettings({ settings: changes as Pick[] }); }, [queryClient, saveSettings], ); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts index d2c4f1e065e5a..97c431c76f7d7 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts @@ -1,6 +1,13 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, useMethod, useToastMessageDispatch, useUserId, useUserSubscription, useUserRoom } from '@rocket.chat/ui-contexts'; +import { + useTranslation, + useEndpoint, + useToastMessageDispatch, + useUserId, + useUserSubscription, + useUserRoom, +} from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { getRoomDirectives } from '../../../lib/getRoomDirectives'; @@ -20,12 +27,12 @@ export const useBlockUserAction = (user: Pick, rid: I const { roomCanBlock } = getRoomDirectives({ room, showingUserId: uid, userSubscription: currentSubscription }); - const isUserBlocked = currentSubscription?.blocker; - const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); + const isUserBlocked = Boolean(currentSubscription?.blocker); + const toggleBlockUser = useEndpoint('POST', '/v1/im.blockUser'); const toggleBlockUserAction = useStableCallback(async () => { try { - await toggleBlock({ rid, blocked: uid }); + await toggleBlockUser({ roomId: rid, block: !isUserBlocked }); dispatchToastMessage({ type: 'success', message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), diff --git a/apps/meteor/tests/e2e/utils/saveSettings.ts b/apps/meteor/tests/e2e/utils/saveSettings.ts index db44ea6ff8915..e78f826e86936 100644 --- a/apps/meteor/tests/e2e/utils/saveSettings.ts +++ b/apps/meteor/tests/e2e/utils/saveSettings.ts @@ -9,12 +9,4 @@ export const saveSettings = ( _id: ISetting['_id']; value: ISetting['value']; }[], -): Promise => - api.post('/method.call/saveSettings', { - message: JSON.stringify({ - msg: 'method', - id: '1', - method: 'saveSettings', - params: [changes], - }), - }); +): Promise => api.post('/settings', { settings: changes }); diff --git a/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts index bd0e03e0eb671..8cfe7d23d1a1e 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -31,7 +31,14 @@ async function createCustomSound(fileName: string, filePath: string): Promise { + expect(res.body).to.have.property('success', true); + }); } describe('[CustomSounds]', () => { @@ -588,6 +595,60 @@ describe('[CustomSounds]', () => { }); }); + describe('[/custom-sounds.delete]', () => { + let toDeleteFileId: string; + + before(async () => { + toDeleteFileId = await createCustomSound(`${fileName}-delete-${randomUUID()}`, mockWavAudioPath); + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.delete')).send({ _id: toDeleteFileId }).expect(401); + }); + + it('should return bad request when _id is missing', async () => { + await request.post(api('custom-sounds.delete')).set(credentials).send({}).expect(400); + }); + + it('should return bad request when _id is an empty string', async () => { + await request.post(api('custom-sounds.delete')).set(credentials).send({ _id: '' }).expect(400); + }); + + it('should fail when the user does not have manage-sounds permission', async () => { + const testUser = await createUser(); + const testUserCredentials = await login(testUser.username, password); + try { + await request.post(api('custom-sounds.delete')).set(testUserCredentials).send({ _id: toDeleteFileId }).expect(403); + } finally { + await deleteUser(testUser); + } + }); + + it('should delete the sound successfully and remove it from the list', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: toDeleteFileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: toDeleteFileId }).expect(404); + }); + + it('should fail when the sound id does not exist', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: 'non-existent-sound-id' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + describe('[/custom-sounds.getOne]', () => { it('should return unauthorized if not authenticated', async () => { await request.get(api('custom-sounds.getOne')).query({ _id: fileId }).expect(401); diff --git a/apps/meteor/tests/end-to-end/api/direct-message.ts b/apps/meteor/tests/end-to-end/api/direct-message.ts index a5774325b20a2..da32487c7b687 100644 --- a/apps/meteor/tests/end-to-end/api/direct-message.ts +++ b/apps/meteor/tests/end-to-end/api/direct-message.ts @@ -58,6 +58,63 @@ describe('[Direct Messages]', () => { after(() => deleteUser(user)); + describe('/im.blockUser', () => { + const fetchOwnSubscription = async (roomId: IRoom['_id']) => { + const res = await request.get(api('subscriptions.getOne')).set(credentials).query({ roomId }).expect(200); + return res.body.subscription; + }; + + it('should return unauthorized when not authenticated', async () => { + await request.post(api('im.blockUser')).send({ roomId: directMessage._id, block: true }).expect(401); + }); + + it('should return bad request when roomId is missing', async () => { + await request.post(api('im.blockUser')).set(credentials).send({ block: true }).expect(400); + }); + + it('should return bad request when block flag is missing', async () => { + await request.post(api('im.blockUser')).set(credentials).send({ roomId: directMessage._id }).expect(400); + }); + + it('should block the other DM participant and flip the subscription blocker flag', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: directMessage._id, block: true }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await fetchOwnSubscription(directMessage._id); + expect(subscription).to.have.property('blocker', true); + }); + + it('should unblock the other DM participant and clear the subscription blocker flag', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: directMessage._id, block: false }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await fetchOwnSubscription(directMessage._id); + expect(Boolean(subscription.blocker)).to.equal(false); + }); + + it('should fail when called on a non-DM room', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: 'GENERAL', block: true }) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + describe('/im.setTopic', () => { it('should set the topic of the DM with a string', (done) => { void request diff --git a/apps/meteor/tests/end-to-end/api/settings.ts b/apps/meteor/tests/end-to-end/api/settings.ts index a8905e0533dbb..d49e0d77c3ad4 100644 --- a/apps/meteor/tests/end-to-end/api/settings.ts +++ b/apps/meteor/tests/end-to-end/api/settings.ts @@ -115,6 +115,84 @@ describe('[Settings]', () => { expect(res.body.settings[0]).to.have.property('packageValue'); }); }); + + describe('POST (bulk)', () => { + before(async () => { + await updatePermission('edit-privileged-setting', ['admin']); + }); + + after(async () => { + await updatePermission('edit-privileged-setting', ['admin']); + }); + + it('should return unauthorized when not authenticated', async () => { + await request + .post(api('settings')) + .send({ settings: [{ _id: 'LDAP_Enable', value: false }] }) + .expect(401); + }); + + it('should return bad request when settings is missing', async () => { + await request.post(api('settings')).set(credentials).send({}).expect(400); + }); + + it('should return bad request when settings is empty', async () => { + await request.post(api('settings')).set(credentials).send({ settings: [] }).expect(400); + }); + + it('should return bad request when an item is missing value', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'LDAP_Enable' }] }) + .expect(400); + }); + + it('should successfully update multiple settings in a single request', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ + settings: [ + { _id: 'LDAP_Enable', value: false }, + { _id: 'Accounts_Default_User_Preferences_masterVolume', value: 50 }, + ], + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('settings/LDAP_Enable')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', false); + }); + + await request + .get(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', 50); + }); + }); + + it('should fail when the user does not have edit-privileged-setting permission', async () => { + await updatePermission('edit-privileged-setting', []); + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'LDAP_Enable', value: false }] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.match(/error-action-not-allowed/); + }); + }); + }); }); describe('[/settings/:_id]', () => { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 62550d952de6b..453107581e13f 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -247,6 +247,22 @@ describe('[Users]', () => { }); }); + describe('[/e2e.requestSubscriptionKeys]', () => { + it('should return unauthorized when not authenticated', async () => { + await request.post(api('e2e.requestSubscriptionKeys')).expect(401); + }); + + it('should accept the request and return success for an authenticated user', async () => { + await request + .post(api('e2e.requestSubscriptionKeys')) + .set(userCredentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + describe('[/users.create]', () => { before(async () => clearCustomFields()); after(async () => clearCustomFields()); diff --git a/packages/rest-typings/src/v1/dm/DmBlockUserProps.ts b/packages/rest-typings/src/v1/dm/DmBlockUserProps.ts new file mode 100644 index 0000000000000..7b2ea135714d0 --- /dev/null +++ b/packages/rest-typings/src/v1/dm/DmBlockUserProps.ts @@ -0,0 +1,23 @@ +import { ajv } from '../Ajv'; + +export type DmBlockUserProps = { + roomId: string; + block: boolean; +}; + +const DmBlockUserPropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + block: { + type: 'boolean', + }, + }, + required: ['roomId', 'block'], + additionalProperties: false, +}; + +export const isDmBlockUserProps = ajv.compile(DmBlockUserPropsSchema); diff --git a/packages/rest-typings/src/v1/dm/im.ts b/packages/rest-typings/src/v1/dm/im.ts index 82b9e5b909f3a..38574570426db 100644 --- a/packages/rest-typings/src/v1/dm/im.ts +++ b/packages/rest-typings/src/v1/dm/im.ts @@ -1,5 +1,6 @@ import type { IMessage, IRoom, IUser, IUploadWithUser, ISubscription } from '@rocket.chat/core-typings'; +import type { DmBlockUserProps } from './DmBlockUserProps'; import type { DmCreateProps } from './DmCreateProps'; import type { DmFileProps } from './DmFileProps'; import type { DmHistoryProps } from './DmHistoryProps'; @@ -76,4 +77,7 @@ export type ImEndpoints = { topic?: string; }; }; + '/v1/im.blockUser': { + POST: (params: DmBlockUserProps) => void; + }; }; diff --git a/packages/rest-typings/src/v1/dm/index.ts b/packages/rest-typings/src/v1/dm/index.ts index f68824c6e4212..2935d40218a3f 100644 --- a/packages/rest-typings/src/v1/dm/index.ts +++ b/packages/rest-typings/src/v1/dm/index.ts @@ -1,5 +1,6 @@ export type * from './dm'; export type * from './im'; +export * from './DmBlockUserProps'; export * from './DmCreateProps'; export * from './DmFileProps'; export * from './DmMembersProps'; diff --git a/packages/rest-typings/src/v1/e2e.ts b/packages/rest-typings/src/v1/e2e.ts index 5ab6ac9024501..f54988313c7b8 100644 --- a/packages/rest-typings/src/v1/e2e.ts +++ b/packages/rest-typings/src/v1/e2e.ts @@ -26,4 +26,8 @@ export type E2eEndpoints = { '/v1/e2e.setUserPublicAndPrivateKeys': { POST: (params: E2eSetUserPublicAndPrivateKeysProps) => void; }; + + '/v1/e2e.requestSubscriptionKeys': { + POST: () => void; + }; }; diff --git a/packages/rest-typings/src/v1/settings.ts b/packages/rest-typings/src/v1/settings.ts index bfd4571cb1cac..39faf84de2511 100644 --- a/packages/rest-typings/src/v1/settings.ts +++ b/packages/rest-typings/src/v1/settings.ts @@ -1,6 +1,6 @@ import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; -import { ajvQuery } from './Ajv'; +import { ajv, ajvQuery } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -87,6 +87,33 @@ const SettingsGetSchema = { export const isSettingsGetParams = ajvQuery.compile(SettingsGetSchema); +export type SettingsBulkProps = { + settings: { _id: ISetting['_id']; value: ISetting['value'] }[]; +}; + +const SettingsBulkSchema = { + type: 'object', + properties: { + settings: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string', minLength: 1 }, + value: {}, + }, + required: ['_id', 'value'], + additionalProperties: false, + }, + minItems: 1, + }, + }, + required: ['settings'], + additionalProperties: false, +}; + +export const isSettingsBulkProps = ajv.compile(SettingsBulkSchema); + export type SettingsEndpoints = { '/v1/settings.public': { GET: (params: SettingsPublicWithPaginationProps) => PaginatedResult & { @@ -108,6 +135,7 @@ export type SettingsEndpoints = { GET: (params: SettingsGetParams) => { settings: ISetting[]; }; + POST: (params: SettingsBulkProps) => void; }; '/v1/settings/:_id': { From e0948ba73dfd63a2cb162eef6467274c89d35304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:18:23 -0300 Subject: [PATCH 2/2] fix: expired session not redirecting to login page (#40849) Co-authored-by: Diego Sampaio --- .changeset/loose-knives-retire.md | 5 + .../externals/meteor/accounts-base.d.ts | 2 + .../server/services/meteor/userReactivity.ts | 47 +++++++- .../e2e/session-expiration-redirect.spec.ts | 103 ++++++++++++++++++ packages/core-services/src/events/Events.ts | 2 +- 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 .changeset/loose-knives-retire.md create mode 100644 apps/meteor/tests/e2e/session-expiration-redirect.spec.ts diff --git a/.changeset/loose-knives-retire.md b/.changeset/loose-knives-retire.md new file mode 100644 index 0000000000000..8bb9dcbb35d14 --- /dev/null +++ b/.changeset/loose-knives-retire.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the behavior when the login token expires to redirect the user to the login page diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index becb48a4a8137..75a7e4b832d39 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -15,6 +15,8 @@ declare module 'meteor/accounts-base' { callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void, ): string; + function _expireTokens(oldestValidDate?: Date, userId?: string): Promise; + function _bcryptRounds(): number; function _getLoginToken(connectionId: string): string | undefined; diff --git a/apps/meteor/server/services/meteor/userReactivity.ts b/apps/meteor/server/services/meteor/userReactivity.ts index eb1b10eacee62..c6711f93cf175 100644 --- a/apps/meteor/server/services/meteor/userReactivity.ts +++ b/apps/meteor/server/services/meteor/userReactivity.ts @@ -1,5 +1,50 @@ +import { api } from '@rocket.chat/core-services'; +import { Settings, Users } from '@rocket.chat/models'; +import { getLoginExpirationInMs } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; import { MongoInternals } from 'meteor/mongo'; +const { _expireTokens: expireTokensOriginal } = Accounts; + +Accounts._expireTokens = async () => { + const loginExpiration: number | undefined = await Settings.getValueById('Accounts_LoginExpiration'); + const oldestValidDate = new Date(Date.now() - getLoginExpirationInMs(loginExpiration)); + + const users = await Users.find( + { + $or: [ + { 'services.resume.loginTokens.when': { $lt: oldestValidDate } }, + { 'services.resume.loginTokens.when': { $lt: +oldestValidDate } }, + ], + }, + { projection: { services: 1 } }, + ).toArray(); + + const updates: { _id: string; validTokens: any[] }[] = []; + + users.forEach(({ _id, services }) => { + const loginTokens = services?.resume?.loginTokens; + + // remove all tokens that are expired + const validTokens = loginTokens?.filter((token: any) => token.when >= oldestValidDate) || []; + + updates.push({ _id, validTokens }); + }); + + await expireTokensOriginal.call(Accounts); + + updates.forEach(({ _id, validTokens }) => { + void api.broadcast('watch.users', { + clientAction: 'updated', + id: _id, + diff: { + 'services.resume.loginTokens': validTokens, + }, + unset: {}, + }); + }); +}; + type Callbacks = { added(id: string, record: object): void; changed(id: string, record: object): void; @@ -82,7 +127,7 @@ export const processOnChange = (diff: Record, id: string): void => const cbs = userCallbacks.get(id); if (cbs) { [...cbs] - .filter(({ hashedToken }) => tokens === undefined || !tokens.includes(hashedToken)) + .filter(({ hashedToken }) => !tokens?.includes(hashedToken)) .forEach((item) => { item.callbacks.removed(id); cbs.delete(item); diff --git a/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts b/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts new file mode 100644 index 0000000000000..1079d66669950 --- /dev/null +++ b/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts @@ -0,0 +1,103 @@ +import { MongoClient } from 'mongodb'; + +import { URL_MONGODB } from './config/constants'; +import injectInitialData from './fixtures/inject-initial-data'; +import { restoreState, Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { test, expect } from './utils/test'; + +const removeTokensFromdb = async (username: string): Promise => { + const connection = await MongoClient.connect(URL_MONGODB); + + await connection + .db() + .collection('users') + .updateOne({ username }, { $set: { 'services.resume.loginTokens': [] } }); + + await connection.close(); +}; + +test.use({ storageState: Users.user1.state }); + +test.describe('Session Expiration Redirect', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto(`/channel/${targetChannel}`); + }); + test.afterEach(async ({ page }) => { + await injectInitialData(); + await restoreState(page, Users.user1); + }); + + test('should redirect to login page when server-side token is deleted and user tries to interact', async ({ page }) => { + await test.step('expect user to be logged in initially', async () => { + await expect(page.locator('#main-content')).toBeVisible(); + + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + expect(userId).not.toBeNull(); + expect(loginToken).not.toBeNull(); + }); + + await test.step('delete login tokens from database (simulating server-side expiration)', async () => { + await removeTokensFromdb(Users.user1.data.username); + }); + + await test.step('open room search messages (without page reload)', async () => { + await poHomeChannel.roomToolbar.btnSearchMessages.click(); + }); + + await test.step('should redirect to login page', async () => { + const loginForm = page.getByRole('form', { name: 'Login' }); + await loginForm.waitFor({ state: 'visible', timeout: 20000 }); + await expect(loginForm).toBeVisible(); + }); + + await test.step('verify localStorage was cleared', async () => { + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + const loginTokenExpires = await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires')); + + expect(userId).toBeNull(); + expect(loginToken).toBeNull(); + expect(loginTokenExpires).toBeNull(); + }); + }); + + test('should redirect to login page when trying to send message with expired token', async ({ page }) => { + await test.step('type message', async () => { + await poHomeChannel.composer.inputMessage.fill('Test message'); + }); + + await test.step('delete login tokens from database', async () => { + await removeTokensFromdb(Users.user1.data.username); + }); + + await test.step('try to send a message (should trigger auth error)', async () => { + await poHomeChannel.composer.btnSend.click(); + }); + + await test.step('expect automatic redirect to login page', async () => { + await expect(page.getByRole('form', { name: 'Login' })).toBeVisible({ timeout: 10000 }); + }); + + await test.step('verify localStorage was cleared', async () => { + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + expect(userId).toBeNull(); + expect(loginToken).toBeNull(); + }); + }); +}); diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index e0e41503a3dea..aae9118f565ae 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -254,7 +254,7 @@ export type EventSignatures = { } | { clientAction: 'updated'; - diff: Record; + diff: Record; unset: Record; } ),