diff --git a/.changeset/bump-patch-1697486167182.md b/.changeset/bump-patch-1697486167182.md new file mode 100644 index 000000000000..e1eaa7980afb --- /dev/null +++ b/.changeset/bump-patch-1697486167182.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/cool-zoos-move.md b/.changeset/cool-zoos-move.md new file mode 100644 index 000000000000..dda6fbe2b02e --- /dev/null +++ b/.changeset/cool-zoos-move.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed threads breaking when sending messages too fast diff --git a/.changeset/old-zoos-hang.md b/.changeset/old-zoos-hang.md new file mode 100644 index 000000000000..eb39a6c9d83c --- /dev/null +++ b/.changeset/old-zoos-hang.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: mobile ringing notification missing call id diff --git a/.changeset/tough-apples-turn.md b/.changeset/tough-apples-turn.md new file mode 100644 index 000000000000..056a0645186e --- /dev/null +++ b/.changeset/tough-apples-turn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Forward headers when using proxy for file uploads diff --git a/.changeset/wicked-jars-double.md b/.changeset/wicked-jars-double.md new file mode 100644 index 000000000000..23deffe8606f --- /dev/null +++ b/.changeset/wicked-jars-double.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Handle the username update in the background diff --git a/apps/meteor/app/cas/server/cas_server.js b/apps/meteor/app/cas/server/cas_server.js index 25d3b9fdd698..60880c77d4f4 100644 --- a/apps/meteor/app/cas/server/cas_server.js +++ b/apps/meteor/app/cas/server/cas_server.js @@ -257,7 +257,7 @@ Accounts.registerLoginHandler('cas', async (options) => { if (roomName) { let room = await Rooms.findOneByNameAndType(roomName, 'c'); if (!room) { - room = await createRoom('c', roomName, user.username); + room = await createRoom('c', roomName, user); } } } diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 8f929a17fe34..e512e5d09bfe 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -562,7 +562,32 @@ export const FileUpload = { ) { res.setHeader('Content-Disposition', `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(fileName)}"`); - request.get(fileUrl, (fileRes) => fileRes.pipe(res)); + request.get(fileUrl, (fileRes) => { + if (fileRes.statusCode !== 200) { + res.setHeader('x-rc-proxyfile-status', String(fileRes.statusCode)); + res.setHeader('content-length', 0); + res.writeHead(500); + res.end(); + return; + } + + // eslint-disable-next-line prettier/prettier + const headersToProxy = [ + 'age', + 'cache-control', + 'content-length', + 'content-type', + 'date', + 'expired', + 'last-modified', + ]; + + headersToProxy.forEach((header) => { + fileRes.headers[header] && res.setHeader(header, String(fileRes.headers[header])); + }); + + fileRes.pipe(res); + }); }, generateJWTToFileUrls({ rid, userId, fileId }: { rid: string; userId: string; fileId: string }) { diff --git a/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js b/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js index 0968eacc5340..bb5053ffdd71 100644 --- a/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js +++ b/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js @@ -16,7 +16,7 @@ export default async function handleJoinedChannel(args) { let room = await Rooms.findOneByName(args.roomName); if (!room) { - const createdRoom = await createRoom('c', args.roomName, user.username, []); + const createdRoom = await createRoom('c', args.roomName, user, []); room = await Rooms.findOne({ _id: createdRoom.rid }); this.log(`${user.username} created room ${args.roomName}`); diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index f912626c833e..946c0cd6b41e 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -365,6 +365,7 @@ export const saveUser = async function (userId, userData) { _id: userData._id, username: userData.username, name: userData.name, + updateUsernameInBackground: true, })) ) { throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 2eb360e150c6..34ca0ca246db 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -1,5 +1,7 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { SystemLogger } from '../../../../server/lib/logger/system'; import { FileUpload } from '../../../file-upload/server'; import { _setRealName } from './setRealName'; import { _setUsername } from './setUsername'; @@ -11,7 +13,17 @@ import { validateName } from './validateName'; * @param {object} changes changes to the user */ -export async function saveUserIdentity({ _id, name: rawName, username: rawUsername }: { _id: string; name?: string; username?: string }) { +export async function saveUserIdentity({ + _id, + name: rawName, + username: rawUsername, + updateUsernameInBackground = false, +}: { + _id: string; + name?: string; + username?: string; + updateUsernameInBackground?: boolean; // TODO: remove this +}) { if (!_id) { return false; } @@ -48,46 +60,91 @@ export async function saveUserIdentity({ _id, name: rawName, username: rawUserna // if coming from old username, update all references if (previousUsername) { - if (usernameChanged && typeof rawUsername !== 'undefined') { - const fileStore = FileUpload.getStore('Avatars'); - const previousFile = await fileStore.model.findOneByName(previousUsername); - const file = await fileStore.model.findOneByName(username); - if (file) { - await fileStore.model.deleteFile(file._id); - } - if (previousFile) { - await fileStore.model.updateFileNameById(previousFile._id, username); - } - - await Messages.updateAllUsernamesByUserId(user._id, username); - await Messages.updateUsernameOfEditByUserId(user._id, username); - - const cursor = Messages.findByMention(previousUsername); - for await (const msg of cursor) { - const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); - await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); - } - - await Rooms.replaceUsername(previousUsername, username); - await Rooms.replaceMutedUsername(previousUsername, username); - await Rooms.replaceUsernameOfUserByUserId(user._id, username); - await Subscriptions.setUserUsernameByUserId(user._id, username); - - await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); + const handleUpdateParams = { + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, + }; + if (updateUsernameInBackground) { + setImmediate(async () => { + try { + await updateUsernameReferences(handleUpdateParams); + } catch (err) { + SystemLogger.error(err); + } + }); + } else { + await updateUsernameReferences(handleUpdateParams); } + } + + return true; +} - // update other references if either the name or username has changed - if (usernameChanged || nameChanged) { - // update name and fname of 1-on-1 direct messages - await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); +async function updateUsernameReferences({ + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, +}: { + username: string; + previousUsername: string; + rawUsername?: string; + usernameChanged: boolean; + user: IUser; + name: string; + previousName: string | undefined; + rawName?: string; + nameChanged: boolean; +}): Promise { + if (usernameChanged && typeof rawUsername !== 'undefined') { + const fileStore = FileUpload.getStore('Avatars'); + const previousFile = await fileStore.model.findOneByName(previousUsername); + const file = await fileStore.model.findOneByName(username); + if (file) { + await fileStore.model.deleteFile(file._id); + } + if (previousFile) { + await fileStore.model.updateFileNameById(previousFile._id, username); + } - // update name and fname of group direct messages - await updateGroupDMsName(user); + await Messages.updateAllUsernamesByUserId(user._id, username); + await Messages.updateUsernameOfEditByUserId(user._id, username); - // update name and username of users on video conferences - await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + const cursor = Messages.findByMention(previousUsername); + for await (const msg of cursor) { + const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); + await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); } + + await Rooms.replaceUsername(previousUsername, username); + await Rooms.replaceMutedUsername(previousUsername, username); + await Rooms.replaceUsernameOfUserByUserId(user._id, username); + await Subscriptions.setUserUsernameByUserId(user._id, username); + + await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); } - return true; + // update other references if either the name or username has changed + if (usernameChanged || nameChanged) { + // update name and fname of 1-on-1 direct messages + await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); + + // update name and fname of group direct messages + await updateGroupDMsName(user); + + // update name and username of users on video conferences + await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + } } diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.js b/apps/meteor/app/slackbridge/server/RocketAdapter.js index d0ef8157137d..f76c33fa1f81 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.js +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.js @@ -295,7 +295,7 @@ export default class RocketAdapter { try { const isPrivate = slackChannel.is_private; - const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator.username, rocketUsers); + const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator, rocketUsers); slackChannel.rocketId = rocketChannel.rid; } catch (e) { if (!hasRetried) { diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts index 8b3bef03f793..aca714549cf1 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts @@ -1,13 +1,12 @@ -import { isThreadMainMessage } from '@rocket.chat/core-typings'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { useStream } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQueryClient, useQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useRef } from 'react'; +import { withDebouncing } from '../../../../../../lib/utils/highOrderFunctions'; import type { FieldExpression, Query } from '../../../../../lib/minimongo'; import { createFilterFromQuery } from '../../../../../lib/minimongo'; -import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived'; import { useRoom } from '../../../contexts/RoomContext'; import { useGetMessageByID } from './useGetMessageByID'; @@ -87,19 +86,22 @@ export const useThreadMainMessageQuery = ( }, [tmid]); return useQuery(['rooms', room._id, 'threads', tmid, 'main-message'] as const, async ({ queryKey }) => { - const message = await getMessage(tmid); + const mainMessage = await getMessage(tmid); - const mainMessage = (await onClientMessageReceived(message)) || message; - - if (!mainMessage && !isThreadMainMessage(mainMessage)) { + if (!mainMessage) { throw new Error('Invalid main message'); } + const debouncedInvalidate = withDebouncing({ wait: 10000 })(() => { + queryClient.invalidateQueries(queryKey, { exact: true }); + }); + unsubscribeRef.current = unsubscribeRef.current || subscribeToMessage(mainMessage, { - onMutate: () => { - queryClient.invalidateQueries(queryKey, { exact: true }); + onMutate: (message) => { + queryClient.setQueryData(queryKey, () => message); + debouncedInvalidate(); }, onDelete: () => { onDelete?.(); diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 77cdc1cbd8e0..c9079b0a2bfb 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -618,6 +618,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf caller: call.createdBy, avatar: getUserAvatarURL(call.createdBy.username), status: call.status, + callId: call._id, }, userId: calleeId, notId: PushNotification.getNotificationId(`${call.rid}|${call._id}`), diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index 5a534fe2674d..ed3c7eefb15b 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -4,6 +4,7 @@ import path from 'path'; import { expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; +import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; import { sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; import { imgURL } from '../../data/interactions.js'; @@ -1543,29 +1544,30 @@ describe('[Rooms]', function () { roomId = result.body.room.rid; }); - it('should update group name if user changes username', (done) => { - updateSetting('UI_Use_Real_Name', false).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: testUser._id, - data: { - username: `changed.username.${testUser.username}`, - }, - }) - .end(() => { - request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ roomId }) - .end((err, res) => { - const { subscription } = res.body; - expect(subscription.name).to.equal(`rocket.cat,changed.username.${testUser.username}`); - done(); - }); - }); - }); + it('should update group name if user changes username', async () => { + await updateSetting('UI_Use_Real_Name', false); + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + username: `changed.username.${testUser.username}`, + }, + }); + + // need to wait for the username update finish + await sleep(300); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId }) + .send() + .expect((res) => { + const { subscription } = res.body; + expect(subscription.name).to.equal(`rocket.cat,changed.username.${testUser.username}`); + }); }); it('should update group name if user changes name', (done) => { diff --git a/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts new file mode 100644 index 000000000000..b91165fb3ca9 --- /dev/null +++ b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// Create stubs for dependencies +const stubs = { + findOneUserById: sinon.stub(), + updateUsernameAndMessageOfMentionByIdAndOldUsername: sinon.stub(), + updateUsernameOfEditByUserId: sinon.stub(), + updateAllUsernamesByUserId: sinon.stub(), + updateDirectNameAndFnameByName: sinon.stub(), + updateUserReferences: sinon.stub(), + setUsername: sinon.stub(), + setRealName: sinon.stub(), + validateName: sinon.stub(), + FileUpload: sinon.stub(), +}; + +const { saveUserIdentity } = proxyquire.noCallThru().load('../../../../app/lib/server/functions/saveUserIdentity', { + '@rocket.chat/models': { + Users: { + findOneById: stubs.findOneUserById, + }, + Messages: { + updateUsernameAndMessageOfMentionByIdAndOldUsername: stubs.updateUsernameAndMessageOfMentionByIdAndOldUsername, + updateUsernameOfEditByUserId: stubs.updateUsernameOfEditByUserId, + updateAllUsernamesByUserId: stubs.updateAllUsernamesByUserId, + }, + Subscriptions: { + updateDirectNameAndFnameByName: stubs.updateDirectNameAndFnameByName, + }, + VideoConference: { + updateUserReferences: stubs.updateUserReferences, + }, + }, + 'meteor/meteor': { + 'Meteor': sinon.stub(), + '@global': true, + }, + '../../../../app/file-upload/server': { + FileUpload: stubs.FileUpload, + }, + '../../../../app/lib/server/functions/setRealName': { + _setRealName: stubs.setRealName, + }, + '../../../../app/lib/server/functions/setUsername': { + _setUsername: stubs.setUsername, + }, + '../../../../app/lib/server/functions/updateGroupDMsName': { + updateGroupDMsName: sinon.stub(), + }, + '../../../../app/lib/server/functions/validateName': { + validateName: stubs.validateName, + }, +}); + +describe('Users - saveUserIdentity', () => { + beforeEach(() => { + // Reset stubs before each test + Object.values(stubs).forEach((stub) => stub.reset()); + }); + + it('should return false if _id is not provided', async () => { + const result = await saveUserIdentity({ _id: undefined }); + + expect(stubs.findOneUserById.called).to.be.false; + expect(result).to.be.false; + }); + + it('should return false if user does not exist', async () => { + stubs.findOneUserById.returns(undefined); + const result = await saveUserIdentity({ _id: 'valid_id' }); + + expect(stubs.findOneUserById.calledWith('valid_id')).to.be.true; + expect(result).to.be.false; + }); + + it('should return false if username is not allowed', async () => { + stubs.findOneUserById.returns({ username: 'oldUsername' }); + stubs.validateName.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', username: 'admin' }); + + expect(stubs.validateName.calledWith('admin')).to.be.true; + expect(result).to.be.false; + }); + + it('should return false if username is invalid or unavailable', async () => { + stubs.findOneUserById.returns({ username: 'oldUsername' }); + stubs.validateName.returns(true); + stubs.setUsername.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', username: 'invalidUsername' }); + + expect(stubs.validateName.calledWith('invalidUsername')).to.be.true; + expect(stubs.setUsername.calledWith('valid_id', 'invalidUsername', { username: 'oldUsername' })).to.be.true; + expect(result).to.be.false; + }); + + it("should not update the username if it's not changed", async () => { + stubs.findOneUserById.returns({ username: 'oldUsername', name: 'oldName' }); + stubs.validateName.returns(true); + stubs.setUsername.returns(true); + await saveUserIdentity({ _id: 'valid_id', username: 'oldUsername', name: 'oldName' }); + + expect(stubs.validateName.called).to.be.false; + expect(stubs.setUsername.called).to.be.false; + expect(stubs.updateUsernameOfEditByUserId.called).to.be.false; + expect(stubs.updateAllUsernamesByUserId.called).to.be.false; + expect(stubs.updateUsernameAndMessageOfMentionByIdAndOldUsername.called).to.be.false; + expect(stubs.updateDirectNameAndFnameByName.called).to.be.false; + expect(stubs.updateUserReferences.called).to.be.false; + }); + + it('should return false if _setName fails', async () => { + stubs.findOneUserById.returns({ name: 'oldName' }); + stubs.setRealName.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', name: 'invalidName' }); + + expect(stubs.setRealName.calledWith('valid_id', 'invalidName', { name: 'oldName' })).to.be.true; + expect(result).to.be.false; + }); + + it('should update Subscriptions and VideoConference if name changes', async () => { + stubs.findOneUserById.returns({ name: 'oldName', username: 'oldUsername' }); + stubs.setRealName.returns(true); + const result = await saveUserIdentity({ _id: 'valid_id', name: 'name', username: 'oldUsername' }); + + expect(stubs.setUsername.called).to.be.false; + expect(stubs.setRealName.called).to.be.true; + expect(stubs.updateUsernameOfEditByUserId.called).to.be.false; + expect(stubs.updateDirectNameAndFnameByName.called).to.be.true; + expect(stubs.updateUserReferences.called).to.be.true; + expect(result).to.be.true; + }); +}); diff --git a/yarn.lock b/yarn.lock index 84c187b685cc..44aa82d9764d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8233,9 +8233,9 @@ __metadata: "@rocket.chat/icons": "*" "@rocket.chat/prettier-config": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-contexts": 2.0.0 + "@rocket.chat/ui-contexts": 2.0.1 "@rocket.chat/ui-kit": "*" - "@rocket.chat/ui-video-conf": 2.0.0 + "@rocket.chat/ui-video-conf": 2.0.1 "@tanstack/react-query": "*" react: "*" react-dom: "*" @@ -8317,14 +8317,14 @@ __metadata: ts-jest: ~29.0.5 typescript: ~5.2.2 peerDependencies: - "@rocket.chat/core-typings": 6.4.0 + "@rocket.chat/core-typings": 6.4.1 "@rocket.chat/css-in-js": "*" "@rocket.chat/fuselage": "*" "@rocket.chat/fuselage-tokens": "*" "@rocket.chat/message-parser": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-client": 2.0.0 - "@rocket.chat/ui-contexts": 2.0.0 + "@rocket.chat/ui-client": 2.0.1 + "@rocket.chat/ui-contexts": 2.0.1 katex: "*" react: "*" languageName: unknown @@ -9447,7 +9447,7 @@ __metadata: "@rocket.chat/fuselage": "*" "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/icons": "*" - "@rocket.chat/ui-contexts": 2.0.0 + "@rocket.chat/ui-contexts": 2.0.1 react: ~17.0.2 languageName: unknown linkType: soft @@ -9599,7 +9599,7 @@ __metadata: "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/icons": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-contexts": 2.0.0 + "@rocket.chat/ui-contexts": 2.0.1 react: ^17.0.2 react-dom: ^17.0.2 languageName: unknown @@ -9683,7 +9683,7 @@ __metadata: typescript: ~5.2.2 peerDependencies: "@rocket.chat/layout": "*" - "@rocket.chat/ui-contexts": 2.0.0 + "@rocket.chat/ui-contexts": 2.0.1 "@tanstack/react-query": "*" react: "*" react-hook-form: "*"