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': {