diff --git a/.changeset/ddp-migrate-batch2-callers.md b/.changeset/ddp-migrate-batch2-callers.md new file mode 100644 index 0000000000000..9558b6c12b085 --- /dev/null +++ b/.changeset/ddp-migrate-batch2-callers.md @@ -0,0 +1,12 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate six 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): + +- `loadMissedMessages` → `GET /v1/chat.syncMessages` +- `joinRoom` → `POST /v1/channels.join` (channel-only; non-`c` rooms now error via REST the same way they used to via DDP) +- `userSetUtcOffset` → `POST /v1/users.setPreferences` (new `utcOffset` field) +- `deleteFileMessage` → `POST /v1/chat.delete` (new `fileId` body shape) +- `spotlight` → `GET /v1/spotlight` (new `usernames` / `type` / `rid` query params) +- `listCustomSounds` → `GET /v1/custom-sounds.list` diff --git a/.changeset/rest-chat-delete-by-fileid.md b/.changeset/rest-chat-delete-by-fileid.md new file mode 100644 index 0000000000000..56a660a415400 --- /dev/null +++ b/.changeset/rest-chat-delete-by-fileid.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`POST /v1/chat.delete` now accepts `{ fileId, asUser? }` as an alternative to `{ msgId, roomId, asUser? }`. When `fileId` is provided the server resolves the owning message via `Messages.getMessageByFileId` before running the existing permission and deletion flow. diff --git a/.changeset/rest-spotlight-params-and-anonymous.md b/.changeset/rest-spotlight-params-and-anonymous.md new file mode 100644 index 0000000000000..032538dd9b963 --- /dev/null +++ b/.changeset/rest-spotlight-params-and-anonymous.md @@ -0,0 +1,11 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`GET /v1/spotlight` now mirrors the DDP `spotlight` method: + +- accepts optional `usernames` (comma-separated string), `type` (JSON-encoded `{ users?, mentions?, rooms?, includeFederatedRooms? }`) and `rid` query params; +- response items expose `nickname` / `outside` (users) and `uids` / `usernames` / `fname` (rooms); +- `status` on each user is now optional — outside/federated users were already being returned without one and the previous required-field schema rejected them as `Response validation failed`; +- the endpoint is no longer auth-gated, allowing anonymous-read flows (e.g. `Accounts_AllowAnonymousRead`) to keep finding public channels through the navbar search. diff --git a/.changeset/rest-users-setpreferences-utcoffset.md b/.changeset/rest-users-setpreferences-utcoffset.md new file mode 100644 index 0000000000000..9681c8b57f0cc --- /dev/null +++ b/.changeset/rest-users-setpreferences-utcoffset.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`POST /v1/users.setPreferences` now accepts an optional `data.utcOffset` (number) field. The value is stored at the user-document root via `Users.setUtcOffset` (not under `settings.preferences`), matching what the legacy `userSetUtcOffset` DDP method did. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 383f4312c53b5..62378bd436e7a 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -521,7 +521,7 @@ const chatEndpoints = API.v1 authRequired: true, body: isChatDeleteProps, response: { - 200: ajv.compile<{ _id: string; ts: string; message: Pick }>({ + 200: ajv.compile<{ _id?: string; ts?: string; message?: Pick }>({ type: 'object', properties: { _id: { type: 'string' }, @@ -538,7 +538,7 @@ const chatEndpoints = API.v1 }, success: { type: 'boolean', enum: [true] }, }, - required: ['_id', 'ts', 'message', 'success'], + required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, @@ -546,13 +546,22 @@ const chatEndpoints = API.v1 }, }, async function action() { - const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); + // Deleting by fileId resolves the message that references the file and deletes it. + // An orphan upload (a file with no associated message) is not deletable through this + // endpoint and intentionally returns a failure below. + const msg = + 'fileId' in this.bodyParams + ? await Messages.getMessageByFileId(this.bodyParams.fileId) + : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); if (!msg) { + if ('fileId' in this.bodyParams) { + return API.v1.failure(`No message found with the file id: "${this.bodyParams.fileId}".`); + } return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); } - if (this.bodyParams.roomId !== msg.rid) { + if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { return API.v1.failure('The room id provided does not match where the message is from.'); } @@ -576,7 +585,7 @@ const chatEndpoints = API.v1 return API.v1.success({ _id: msg._id, ts: Date.now().toString(), - message: msg, + message: { _id: msg._id, rid: msg.rid, u: msg.u }, }); }, ) diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index d7b0be701a3f5..44ebb96478b23 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -6,6 +6,8 @@ import { ajv, isShieldSvgProps, isSpotlightProps, + parseSpotlightUsernames, + parseSpotlightType, isDirectoryProps, isFingerprintProps, isMeteorCall, @@ -339,7 +341,11 @@ API.v1.get( ); const spotlightResponseSchema = ajv.compile<{ - users: Pick[]; + users: (Pick & + Partial> & { + nickname?: string; + outside?: boolean; + })[]; rooms: Pick[]; }>({ type: 'object', @@ -356,7 +362,7 @@ const spotlightResponseSchema = ajv.compile<{ statusText: { type: 'string' }, avatarETag: { type: 'string' }, }, - required: ['_id', 'name', 'username', 'status'], + required: ['_id', 'name', 'username'], additionalProperties: true, }, }, @@ -383,7 +389,10 @@ const spotlightResponseSchema = ajv.compile<{ API.v1.get( 'spotlight', { - authRequired: true, + // DDP `spotlight` accepts anonymous calls (Accounts_AllowAnonymousRead). + // Keep parity so anonymous-user / embedded-layout flows can still + // resolve a public channel through the navbar search. + authRequired: false, query: isSpotlightProps, response: { 200: spotlightResponseSchema, @@ -392,9 +401,15 @@ API.v1.get( }, }, async function action() { - const { query } = this.queryParams; + const { query, usernames, type, rid } = this.queryParams; - const result = await spotlightMethod({ text: query, userId: this.userId }); + const result = await spotlightMethod({ + text: query, + userId: this.userId, + usernames: parseSpotlightUsernames(usernames), + type: parseSpotlightType(type), + rid, + }); return API.v1.success(result); }, diff --git a/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts b/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts index eda1325d77335..e627b96368f47 100644 --- a/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts +++ b/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts @@ -3,6 +3,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { CustomSounds } 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 { @@ -12,6 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async listCustomSounds() { + methodDeprecationLogger.method('listCustomSounds', '9.0.0', '/v1/custom-sounds.list'); return CustomSounds.find({}).toArray(); }, }); diff --git a/apps/meteor/app/lib/server/methods/joinRoom.ts b/apps/meteor/app/lib/server/methods/joinRoom.ts index 787f729bd578b..36c862726f8ee 100644 --- a/apps/meteor/app/lib/server/methods/joinRoom.ts +++ b/apps/meteor/app/lib/server/methods/joinRoom.ts @@ -5,6 +5,8 @@ import { Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -14,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async joinRoom(rid, code) { + methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/channels.join'); check(rid, String); const user = await Meteor.userAsync(); diff --git a/apps/meteor/client/hooks/useJoinRoom.ts b/apps/meteor/client/hooks/useJoinRoom.ts index 7204d378ca36f..3685693222979 100644 --- a/apps/meteor/client/hooks/useJoinRoom.ts +++ b/apps/meteor/client/hooks/useJoinRoom.ts @@ -1,9 +1,7 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { sdk } from '../../app/utils/client/lib/SDKClient'; - type UseJoinRoomMutationFunctionProps = { rid: IRoom['_id']; reference: string; @@ -13,11 +11,15 @@ type UseJoinRoomMutationFunctionProps = { export const useJoinRoom = () => { const queryClient = useQueryClient(); const dispatchToastMessage = useToastMessageDispatch(); + // TODO(ddp-removal): /v1/channels.join only resolves public channels; non-`c` + // rooms will error here (same as DDP `joinRoom` would, just via REST). + // Replace with a unified `/v1/rooms.join` (or per-type endpoints) before + // the 9.0.0 sweep removes the DDP method. + const joinChannel = useEndpoint('POST', '/v1/channels.join'); return useMutation({ mutationFn: async ({ rid, reference, type }: UseJoinRoomMutationFunctionProps) => { - await sdk.call('joinRoom', rid); - + await joinChannel({ roomId: rid }); return { reference, type }; }, onSuccess: (data) => { diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index fe22df8d10646..75f4d457b36af 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -274,7 +274,10 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage const isSubscribedToRoom = async (): Promise => !!Subscriptions.state.find((record) => record.rid === rid); const joinRoom = async (): Promise => { - await sdk.call('joinRoom', rid); + // TODO(ddp-removal): only public channels resolve through this endpoint; + // private groups, DMs and livechat used to error via DDP too — REST keeps + // that behavior. Replace with a unified `/v1/rooms.join` when available. + await sdk.rest.post('/v1/channels.join', { roomId: rid }); }; const findDiscussionByID = async (drid: IRoom['_id']): Promise => diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index a44dceeb2f8f7..97b6b077d5b52 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -1,6 +1,6 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useMethod, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useUserSubscriptions } from '@rocket.chat/ui-contexts'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useMemo } from 'react'; @@ -47,7 +47,7 @@ export const useSearchItems = (filterText: string): UseQueryResult _id + name)], @@ -57,7 +57,11 @@ export const useSearchItems = (filterText: string): UseQueryResult index === arr.findIndex((user) => _id === user._id); diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index 9cda0dc4cb08f..1b90df8444fc2 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -1,11 +1,10 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { CustomSoundContext, useStream, useUserPreference } from '@rocket.chat/ui-contexts'; +import { CustomSoundContext, useEndpoint, useStream, useUserPreference } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useRef, type ReactNode } from 'react'; import { defaultSounds, getCustomSoundURL, formatVolume } from './lib'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { useUserSoundPreferences } from '../../hooks/useUserSoundPreferences'; type CustomSoundProviderProps = { @@ -17,6 +16,7 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const queryClient = useQueryClient(); const streamAll = useStream('notify-all'); + const getCustomSounds = useEndpoint('GET', '/v1/custom-sounds.list'); const newRoomNotification = useUserPreference('newRoomNotification') || 'door'; const newMessageNotification = useUserPreference('newMessageNotification') || 'chime'; @@ -24,11 +24,24 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const { data: list } = useQuery({ queryFn: async (): Promise[]> => { - const customSoundsList = await sdk.call('listCustomSounds'); - if (!customSoundsList.length) { + // `/v1/custom-sounds.list` is paginated, so we page through all results to + // load every custom sound into the provider (the legacy `listCustomSounds` + // method returned them all at once). + const sounds: Awaited>['sounds'] = []; + let total = Infinity; + while (sounds.length < total) { + const page = await getCustomSounds({ count: 100, offset: sounds.length }); + total = page.total; + sounds.push(...page.sounds); + if (!page.sounds.length) { + break; + } + } + + if (!sounds.length) { return defaultSounds; } - return [...customSoundsList.map((sound) => ({ ...sound, src: getCustomSoundURL(sound) })), ...defaultSounds]; + return [...sounds.map(({ _updatedAt: _, ...sound }) => ({ ...sound, src: getCustomSoundURL(sound) })), ...defaultSounds]; }, queryKey: ['listCustomSounds'], initialData: defaultSounds, diff --git a/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts b/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts index 5383ed6e9fe95..90446325eb005 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts +++ b/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts @@ -4,7 +4,7 @@ import { getURL } from '../../../../app/utils/client'; export const getAssetUrl = (asset: string, params?: Record) => getURL(asset, params, undefined, true); -export const getCustomSoundURL = (sound: ICustomSound) => { +export const getCustomSoundURL = (sound: Pick & Pick, 'random'>) => { return getAssetUrl(`/custom-sounds/${sound._id}.${sound.extension}`, { _dc: sound.random || 0 }); }; diff --git a/apps/meteor/client/startup/startup.ts b/apps/meteor/client/startup/startup.ts index b9bb756cffb26..f68f6fd51df9e 100644 --- a/apps/meteor/client/startup/startup.ts +++ b/apps/meteor/client/startup/startup.ts @@ -34,7 +34,7 @@ if (!sdkTransportEnabled) { const utcOffset = -new Date().getTimezoneOffset() / 60; if (user.utcOffset !== utcOffset) { - sdk.call('userSetUtcOffset', utcOffset); + void sdk.rest.post('/v1/users.setPreferences', { data: { utcOffset } }); } emitStatusChange(user.status); @@ -79,7 +79,7 @@ if (!sdkTransportEnabled) { const utcOffset = -new Date().getTimezoneOffset() / 60; if (user.utcOffset !== utcOffset) { - sdk.call('userSetUtcOffset', utcOffset); + void sdk.rest.post('/v1/users.setPreferences', { data: { utcOffset } }); } emitStatusChange(user.status); diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx index bde721a6b269e..5cbaa7bf40d6c 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx @@ -1,19 +1,19 @@ import type { IUpload } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; export const useDeleteFile = (reload: () => void) => { const { t } = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const deleteFile = useMethod('deleteFileMessage'); + const deleteMessage = useEndpoint('POST', '/v1/chat.delete'); const handleDelete = useStableCallback((_id: IUpload['_id']) => { const onConfirm = async () => { try { - await deleteFile(_id); + await deleteMessage({ fileId: _id }); dispatchToastMessage({ type: 'success', message: t('Deleted') }); reload(); } catch (error) { diff --git a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx index 6cf9eb4ee411f..7d9a6736fbcb6 100644 --- a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx +++ b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx @@ -3,7 +3,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useEndpoint, useMethod, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; @@ -70,7 +70,7 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = // and we are not using the data itself, we should find a better way to do this useCannedResponsesQuery(room); - const userSpotlight = useMethod('spotlight'); + const userSpotlight = useEndpoint('GET', '/v1/spotlight'); const suggestionsCount = useSetting('Number_of_users_autocomplete_suggestions', 5); const cannedResponseEnabled = useSetting('Canned_Responses_Enable', true); const [recentEmojis] = useLocalStorage('emoji.recent', []); @@ -139,7 +139,12 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = .slice(0, suggestionsCount ?? 5) .map((u) => u.username); - const { users = [] } = await userSpotlight(filter, usernames, { users: true, mentions: true }, rid); + const { users = [] } = await userSpotlight({ + query: filter, + usernames: usernames.join(','), + type: JSON.stringify({ users: true, mentions: true }), + rid, + }); return users.map(({ _id, username, nickname, name, status, avatarETag, outside }) => { return { @@ -178,7 +183,11 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = return records; }, getItemsFromServer: async (filter: string) => { - const { rooms = [] } = await userSpotlight(filter, [], { rooms: true, mentions: true }, rid); + const { rooms = [] } = await userSpotlight({ + query: filter, + type: JSON.stringify({ rooms: true, mentions: true }), + rid, + }); return rooms as unknown as ComposerBoxPopupRoomProps[]; }, getValue: (item) => `${item.name || item.fname}`, diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 341a7a59c68dd..631a7c107e0a9 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -3,7 +3,8 @@ import { useConnectionStatus } from '@rocket.chat/ui-contexts'; import { useEffect, useRef } from 'react'; import { LegacyRoomManager, upsertMessage } from '../../../../app/ui-utils/client'; -import { callWithErrorHandling } from '../../../lib/utils/callWithErrorHandling'; +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; import { Messages, Subscriptions } from '../../../stores'; /** @@ -21,11 +22,33 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { } try { - const result = await callWithErrorHandling('loadMissedMessages', rid, lastMessage.ts); - if (result) { + const { result } = await sdk.rest.get('/v1/chat.syncMessages', { + roomId: rid, + lastUpdate: lastMessage.ts.toISOString(), + }); + + if (result?.updated?.length) { const subscription = Subscriptions.state.find((record) => record.rid === rid); - await Promise.all(Array.from(result).map((msg) => upsertMessage({ msg, subscription }))); + // `/v1/chat.syncMessages` returns everything changed since `lastUpdate` by + // `_updatedAt`, which includes edits to older messages. We only want to + // upsert messages that are genuinely new (created after our newest loaded + // message) or that are already loaded (so edits stay in sync), otherwise we + // would inject stale messages into the room history. + await Promise.all( + result.updated + .map(mapMessageFromApi) + .filter((msg) => msg.ts.getTime() > lastMessage.ts.getTime() || Messages.state.has(msg._id)) + .map((msg) => upsertMessage({ msg, subscription })), + ); } + + // Drop messages that were deleted while the connection was down, but only if + // they are currently loaded. + result?.deleted?.forEach(({ _id }) => { + if (Messages.state.has(_id)) { + Messages.state.delete(_id); + } + }); } catch (error) { console.error('Error loading missed messages:', error); } diff --git a/apps/meteor/server/lib/spotlight.js b/apps/meteor/server/lib/spotlight.js index dc182ee9d3a6f..fc17f2ab641a8 100644 --- a/apps/meteor/server/lib/spotlight.js +++ b/apps/meteor/server/lib/spotlight.js @@ -226,6 +226,10 @@ export class Spotlight { }; // Exact match for username only + // TODO: these exact-match branches push the user without filtering against `usernames` + // (the exclusion list), so an exact username query bypasses the exclusion that the + // findByActiveUsersExcept paths below honor. Evaluate filtering exactMatch against + // `usernames` here so the exclusion applies uniformly. if (rid && canListInsiders) { const exactMatch = await Users.findOneByUsernameAndRoomIgnoringCase(text, rid, { projection: options.projection, diff --git a/apps/meteor/server/methods/deleteFileMessage.ts b/apps/meteor/server/methods/deleteFileMessage.ts index 39cfca1ae5dad..ca54a8e19490d 100644 --- a/apps/meteor/server/methods/deleteFileMessage.ts +++ b/apps/meteor/server/methods/deleteFileMessage.ts @@ -7,6 +7,7 @@ import type { DeleteResult } from 'mongodb'; import { FileUpload } from '../../app/file-upload/server'; import { deleteMessageValidatingPermission } from '../../app/lib/server/functions/deleteMessage'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -17,6 +18,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async deleteFileMessage(fileID) { + methodDeprecationLogger.method('deleteFileMessage', '9.0.0', '/v1/chat.delete'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/server/methods/loadMissedMessages.ts b/apps/meteor/server/methods/loadMissedMessages.ts index 4a4b535fec099..30f45f5fcefca 100644 --- a/apps/meteor/server/methods/loadMissedMessages.ts +++ b/apps/meteor/server/methods/loadMissedMessages.ts @@ -5,6 +5,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../app/authorization/server/functions/canAccessRoom'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async loadMissedMessages(rid, start) { + methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages'); check(rid, String); check(start, Date); diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index d7582df642a34..2645e35dd47a7 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -53,6 +53,7 @@ type UserPreferences = { notifyCalendarEvents: boolean; enableMobileRinging: boolean; mentionsWithSymbol?: boolean; + utcOffset?: number; }; declare module '@rocket.chat/ddp-client' { @@ -128,6 +129,7 @@ export const saveUserPreferences = async (settings: Partial, us notifyCalendarEvents: Match.Optional(Boolean), enableMobileRinging: Match.Optional(Boolean), mentionsWithSymbol: Match.Optional(Boolean), + utcOffset: Match.Optional(Number), }; check(settings, Match.ObjectIncluding(keys)); @@ -151,6 +153,12 @@ export const saveUserPreferences = async (settings: Partial, us await Users.setLanguage(user._id, settings.language); } + // utcOffset lives at the user-document root (not under settings.preferences) + if (settings.utcOffset != null) { + await Users.setUtcOffset(user._id, settings.utcOffset); + delete settings.utcOffset; + } + // Keep compatibility with old values if (settings.emailNotificationMode === 'all') { settings.emailNotificationMode = 'mentions'; diff --git a/apps/meteor/server/methods/userSetUtcOffset.ts b/apps/meteor/server/methods/userSetUtcOffset.ts index 819b9c1b92f1d..61357e794156b 100644 --- a/apps/meteor/server/methods/userSetUtcOffset.ts +++ b/apps/meteor/server/methods/userSetUtcOffset.ts @@ -4,6 +4,8 @@ import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -13,6 +15,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async userSetUtcOffset(utcOffset) { + methodDeprecationLogger.method('userSetUtcOffset', '9.0.0', '/v1/users.setPreferences'); check(utcOffset, Number); if (!this.userId) { diff --git a/apps/meteor/server/publications/spotlight.ts b/apps/meteor/server/publications/spotlight.ts index d32436e237744..b6c785775123a 100644 --- a/apps/meteor/server/publications/spotlight.ts +++ b/apps/meteor/server/publications/spotlight.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { Spotlight } from '../lib/spotlight'; type SpotlightType = { @@ -68,6 +69,7 @@ export const spotlightMethod = async ({ Meteor.methods({ async spotlight(text, usernames = [], type = { users: true, rooms: true, mentions: false, includeFederatedRooms: false }, rid) { + methodDeprecationLogger.method('spotlight', '9.0.0', '/v1/spotlight'); return spotlightMethod({ text, usernames, type, rid, userId: this.userId }); }, }); diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 8cad3dee79251..1792426f0b18c 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -9,6 +9,7 @@ import { retry } from './helpers/retry'; import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials, apiUrl } from '../../data/api-data'; import { followMessage, sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; +import { imgURL } from '../../data/interactions'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { addUserToRoom, createRoom, deleteRoom, getSubscriptionByRoomId } from '../../data/rooms.helper'; import { password } from '../../data/user'; @@ -2338,6 +2339,79 @@ describe('[Chat]', () => { .end(done); }); + describe('when deleting by fileId', () => { + const uploadFile = async (): Promise => { + const { body } = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + expect(body).to.have.property('success', true); + return body.file._id; + }; + + it('should delete the message associated with the provided fileId', async () => { + const fileId = await uploadFile(); + + let fileMsgId: string | undefined; + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + fileMsgId = res.body.message._id; + }); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ fileId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ msgId: fileMsgId }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail when the uploaded file has no associated message', async () => { + const fileId = await uploadFile(); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ fileId }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `No message found with the file id: "${fileId}".`); + }); + }); + + it('should fail when neither msgId/roomId nor fileId is provided', async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + }); + describe('when deleting a thread message', () => { let otherUser: TestUser; let otherUserCredentials: Credentials; diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 1196c95e24a49..83905e6fb4b2e 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -549,6 +549,57 @@ describe('miscellaneous', () => { }) .end(done); }); + it('should not return users when the type param disables user search', (done) => { + void request + .get(api('spotlight')) + .query({ + query: `${adminUsername}`, + type: JSON.stringify({ users: false, rooms: true }), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + }) + .end(done); + }); + it('should exclude usernames passed in the usernames param from the results', (done) => { + void request + .get(api('spotlight')) + .query({ + // Use a non-exact (prefix) query so the regex search path runs; the exact-username + // match branch in Spotlight.searchUsers does not honor the usernames exclusion list. + query: adminUsername.slice(0, -2), + usernames: adminUsername, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body.users.map((u: { username: string }) => u.username)).to.not.include(adminUsername); + }) + .end(done); + }); + it('should allow anonymous (unauthenticated) requests', (done) => { + void request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body).to.have.property('users').and.to.be.an('array'); + }) + .end(done); + }); }); describe('[/instances.get]', () => { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index cd407c02760b8..62550d952de6b 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -3906,6 +3906,40 @@ describe('[Users]', () => { .end(done); }); + it('should persist the utcOffset preference on the user document', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { utcOffset: 5 } }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('me')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('utcOffset', 5); + }); + }); + + it('should fail when utcOffset is not a number', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { utcOffset: 'not-a-number' } }) + .expect(400) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + it('should return 401 when not authenticated', async () => { await request .post(api('users.setPreferences')) diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 8e8d52479e504..575bf87ecadb8 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -218,11 +218,16 @@ const ChatSyncThreadsListSchema = { export const isChatSyncThreadsListProps = ajv.compile(ChatSyncThreadsListSchema); -type ChatDelete = { - msgId: IMessage['_id']; - roomId: IRoom['_id']; - asUser?: boolean; -}; +type ChatDelete = + | { + msgId: IMessage['_id']; + roomId: IRoom['_id']; + asUser?: boolean; + } + | { + fileId: string; + asUser?: boolean; + }; const ChatDeleteSchema = { type: 'object', @@ -233,12 +238,15 @@ const ChatDeleteSchema = { roomId: { type: 'string', }, + fileId: { + type: 'string', + }, asUser: { type: 'boolean', nullable: true, }, }, - required: ['msgId', 'roomId'], + anyOf: [{ required: ['msgId', 'roomId'] }, { required: ['fileId'] }], additionalProperties: false, }; @@ -922,9 +930,9 @@ export type ChatEndpoints = { }; '/v1/chat.delete': { POST: (params: ChatDelete) => { - _id: string; - ts: string; - message: Pick; + _id?: string; + ts?: string; + message?: Pick; }; }; '/v1/chat.react': { diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 5df161c0e1c33..7224e163fbaea 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -38,7 +38,19 @@ const ShieldSvgSchema = { export const isShieldSvgProps = ajv.compile(ShieldSvgSchema); -type Spotlight = { query: string }; +type SpotlightType = { + users?: boolean; + mentions?: boolean; + rooms?: boolean; + includeFederatedRooms?: boolean; +}; + +type Spotlight = { + query: string; + usernames?: string; + type?: string; + rid?: string; +}; const SpotlightSchema = { type: 'object', @@ -46,6 +58,18 @@ const SpotlightSchema = { query: { type: 'string', }, + usernames: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + nullable: true, + }, + rid: { + type: 'string', + nullable: true, + }, }, required: ['query'], additionalProperties: false, @@ -53,6 +77,21 @@ const SpotlightSchema = { export const isSpotlightProps = ajvQuery.compile(SpotlightSchema); +const parseSpotlightUsernames = (usernames?: string): string[] | undefined => + usernames ? usernames.split(',').filter(Boolean) : undefined; +const parseSpotlightType = (raw?: string): SpotlightType | undefined => { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as SpotlightType; + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +}; + +export { parseSpotlightUsernames, parseSpotlightType }; +export type { SpotlightType }; + type Directory = PaginatedRequest<{ text: string; type: string; @@ -185,8 +224,9 @@ export type MiscEndpoints = { '/v1/spotlight': { GET: (params: Spotlight) => { - users: (Pick, 'name' | 'status' | '_id' | 'username'> & Partial>)[]; - rooms: Pick, 't' | 'name' | 'lastMessage' | '_id'>[]; + users: (Pick, 'name' | '_id' | 'username'> & + Partial> & { nickname?: string; outside?: boolean })[]; + rooms: (Pick, 't' | 'name' | 'lastMessage' | '_id'> & { uids?: string[]; usernames?: string[]; fname?: string })[]; }; }; diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts index 6101dea65cbbc..18b6986f1aaec 100644 --- a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -53,6 +53,7 @@ export type UsersSetPreferencesParamsPOST = { enableMobileRinging?: boolean; mentionsWithSymbol?: boolean; desktopNotificationVoiceCalls?: boolean; + utcOffset?: number; }; }; @@ -268,6 +269,10 @@ const UsersSetPreferencesParamsPostSchema = { type: 'boolean', nullable: true, }, + utcOffset: { + type: 'number', + nullable: true, + }, }, required: [], additionalProperties: false,