Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/ddp-migrate-batch3-callers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rocket.chat/meteor': patch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Version bump and package scope inconsistency.

This changeset declares a patch bump for @rocket.chat/meteor only, but the individual endpoint changesets (rest-custom-sounds-delete.md, rest-e2e-request-subscription-keys.md, rest-settings-bulk.md, rest-users-block-unblock.md) all declare minor bumps for both @rocket.chat/rest-typings and @rocket.chat/meteor. Since this PR adds new REST endpoints (new public API surface) and introduces new types/validators in @rocket.chat/rest-typings (per the stack context), this changeset should match the others.

📝 Suggested fix
 ---
-'`@rocket.chat/meteor`': patch
+'`@rocket.chat/rest-typings`': minor
+'`@rocket.chat/meteor`': minor
 ---
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/ddp-migrate-batch3-callers.md at line 2, The changeset
.changeset/ddp-migrate-batch3-callers.md currently lists only a patch bump for
`@rocket.chat/meteor` but the PR adds new REST endpoints and typing changes;
update this changeset to use a minor bump and include `@rocket.chat/rest-typings`
in its package list so it matches the individual endpoint changesets
(rest-custom-sounds-delete.md, rest-e2e-request-subscription-keys.md,
rest-settings-bulk.md, rest-users-block-unblock.md) that declare minor bumps for
both `@rocket.chat/meteor` and `@rocket.chat/rest-typings`.

---

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`
6 changes: 6 additions & 0 deletions .changeset/rest-e2e-request-subscription-keys.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/rest-im-block-user.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/rest-settings-post.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions apps/meteor/app/api/server/v1/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -196,6 +197,24 @@ const e2eEndpoints = API.v1
return API.v1.success();
},
)
.post(
'e2e.requestSubscriptionKeys',
{
authRequired: true,
response: {
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
Comment on lines +206 to +208

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally the return type should be correctly enforced.

Suggested change
200: ajv.compile<void>({
type: 'object',
}),
200: ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),

},
},

async function action() {
await requestSubscriptionKeysMethod(this.userId);

return API.v1.success();
},
)
.get(
'e2e.fetchMyKeys',
{
Expand Down
42 changes: 41 additions & 1 deletion apps/meteor/app/api/server/v1/im.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
isDmBlockUserProps,
isDmFileProps,
isDmMemberProps,
isDmMessagesProps,
Expand All @@ -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';
Expand Down Expand Up @@ -924,6 +927,42 @@ const dmCreateAction = <Path extends string>(_path: Path): TypedAction<typeof dm
});
};

const dmBlockUserEndpointsProps = {
authRequired: true,
body: isDmBlockUserProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
},
} as const;

const dmBlockUserAction = <Path extends string>(_path: Path): TypedAction<typeof dmBlockUserEndpointsProps, Path> =>
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'))
Expand All @@ -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<typeof dmEndpoints>;

Expand Down
28 changes: 28 additions & 0 deletions apps/meteor/app/api/server/v1/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -404,6 +407,31 @@ API.v1.post(
},
);

API.v1.post(
'settings',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Renaming the bulk settings endpoint from /v1/settings.bulk to /v1/settings is a breaking API change for existing REST clients. Keep the old route (or provide an alias) while migrating callers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/api/server/v1/settings.ts, line 411:

<comment>Renaming the bulk settings endpoint from `/v1/settings.bulk` to `/v1/settings` is a breaking API change for existing REST clients. Keep the old route (or provide an alias) while migrating callers.</comment>

<file context>
@@ -408,7 +408,7 @@ API.v1.post(
 
 API.v1.post(
-	'settings.bulk',
+	'settings',
 	{
 		authRequired: true,
</file context>

{
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',
{
Expand Down
44 changes: 26 additions & 18 deletions apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,48 @@ 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 {
'e2e.requestSubscriptionKeys'(): boolean;
}
}

export const requestSubscriptionKeysMethod = async (userId: string): Promise<void> => {
// 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<ServerMethods>({
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', {
method: 'requestSubscriptionKeys',
});
}

// 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;
},
Expand Down
35 changes: 35 additions & 0 deletions apps/meteor/app/lib/server/functions/blockUser.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
}
};
Loading
Loading