diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index 1b2741d8d7384..3678e85e48b09 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -15,7 +15,6 @@ import { Meteor } from 'meteor/meteor'; import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit'; import { RateLimiter } from 'meteor/rate-limit'; import { WebApp } from 'meteor/webapp'; -import semver from 'semver'; import _ from 'underscore'; import type { PermissionsPayload } from './api.helpers'; @@ -45,12 +44,12 @@ import type { Route } from './router'; import { Router } from './router'; import { isObject } from '../../../lib/utils/isObject'; import { getNestedProp } from '../../../server/lib/getNestedProp'; +import { shouldBreakInVersion } from '../../../server/lib/shouldBreakInVersion'; import { checkCodeForUser } from '../../2fa/server/code'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { notifyOnUserChangeAsync } from '../../lib/server/lib/notifyListener'; import { metrics } from '../../metrics/server'; import { settings } from '../../settings/server'; -import { Info } from '../../utils/rocketchat.info'; import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields'; const logger = new Logger('API'); @@ -58,7 +57,7 @@ const logger = new Logger('API'); // We have some breaking changes planned to the API. // To avoid conflicts or missing something during the period we are adopting a 'feature flag approach' // TODO: MAJOR check if this is still needed -const applyBreakingChanges = semver.gte(Info.version, '8.0.0'); +const applyBreakingChanges = shouldBreakInVersion('8.0.0'); interface IAPIProperties { useDefaultAuth: boolean; diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 920040bbdf9b9..244d522d2b49b 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -116,10 +116,6 @@ API.v1.addRoute( await saveUser(this.userId, userData); - if (this.bodyParams.data.customFields) { - await saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); - } - if (typeof this.bodyParams.data.active !== 'undefined') { const { userId, diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 7d4dfff289276..226cbeb64a144 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -17,7 +17,7 @@ import filesize from 'filesize'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { Cookies } from 'meteor/ostrio:cookies'; -import type { OptionalId } from 'mongodb'; +import type { ClientSession, OptionalId } from 'mongodb'; import sharp from 'sharp'; import type { WritableStreamBuffer } from 'stream-buffers'; import streamBuffers from 'stream-buffers'; @@ -228,7 +228,7 @@ export const FileUpload = { defaults, - async avatarsOnValidate(this: Store, file: IUpload) { + async avatarsOnValidate(this: Store, file: IUpload, options?: { session?: ClientSession }) { if (settings.get('Accounts_AvatarResize') !== true) { return; } @@ -277,6 +277,7 @@ export const FileUpload = { ...(['gif', 'svg'].includes(metadata.format || '') ? { type: 'image/png' } : {}), }, }, + options, ); }, @@ -359,7 +360,7 @@ export const FileUpload = { return store.insert(details, buffer); }, - async uploadsOnValidate(this: Store, file: IUpload) { + async uploadsOnValidate(this: Store, file: IUpload, options?: { session?: ClientSession }) { if (!file.type || !/^image\/((x-windows-)?bmp|p?jpeg|png|gif|webp)$/.test(file.type)) { return; } @@ -409,6 +410,7 @@ export const FileUpload = { { $set: { size, identify }, }, + options, ); }, @@ -721,13 +723,13 @@ export class FileUploadClass { return modelsAvailable[modelName]; } - async delete(fileId: string) { + async delete(fileId: string, options?: { session?: ClientSession }) { // TODO: Remove this method if (this.store?.delete) { - await this.store.delete(fileId); + await this.store.delete(fileId, { session: options?.session }); } - return this.model.deleteFile(fileId); + return this.model.deleteFile(fileId, { session: options?.session }); } async deleteById(fileId: string) { @@ -742,8 +744,8 @@ export class FileUploadClass { return store.delete(file._id); } - async deleteByName(fileName: string) { - const file = await this.model.findOneByName(fileName); + async deleteByName(fileName: string, options?: { session?: ClientSession }) { + const file = await this.model.findOneByName(fileName, { session: options?.session }); if (!file) { return; @@ -766,8 +768,12 @@ export class FileUploadClass { return store.delete(file._id); } - async _doInsert(fileData: OptionalId, streamOrBuffer: ReadableStream | stream | Buffer): Promise { - const fileId = await this.store.create(fileData); + async _doInsert( + fileData: OptionalId, + streamOrBuffer: ReadableStream | stream | Buffer, + options?: { session?: ClientSession }, + ): Promise { + const fileId = await this.store.create(fileData, { session: options?.session }); const tmpFile = UploadFS.getTempFilePath(fileId); try { @@ -779,7 +785,7 @@ export class FileUploadClass { throw new Error('Invalid file type'); } - const file = await ufsComplete(fileId, this.name); + const file = await ufsComplete(fileId, this.name, { session: options?.session }); return file; } catch (e: any) { @@ -787,7 +793,11 @@ export class FileUploadClass { } } - async insert(fileData: OptionalId, streamOrBuffer: ReadableStream | stream.Readable | Buffer) { + async insert( + fileData: OptionalId, + streamOrBuffer: ReadableStream | stream.Readable | Buffer, + options?: { session?: ClientSession }, + ) { if (streamOrBuffer instanceof stream) { streamOrBuffer = await streamToBuffer(streamOrBuffer); } @@ -803,6 +813,6 @@ export class FileUploadClass { await filter.check(fileData, streamOrBuffer); } - return this._doInsert(fileData, streamOrBuffer); + return this._doInsert(fileData, streamOrBuffer, { session: options?.session }); } } diff --git a/apps/meteor/app/lib/server/functions/saveCustomFields.ts b/apps/meteor/app/lib/server/functions/saveCustomFields.ts index 8d171aea9ee31..d420cd3dbd061 100644 --- a/apps/meteor/app/lib/server/functions/saveCustomFields.ts +++ b/apps/meteor/app/lib/server/functions/saveCustomFields.ts @@ -1,13 +1,21 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + import { saveCustomFieldsWithoutValidation } from './saveCustomFieldsWithoutValidation'; import { validateCustomFields } from './validateCustomFields'; import { trim } from '../../../../lib/utils/stringUtils'; import { settings } from '../../../settings/server'; -export const saveCustomFields = async function (userId: string, formData: Record): Promise { +export const saveCustomFields = async function ( + userId: string, + formData: Record, + options?: { _updater?: Updater; session?: ClientSession }, +): Promise { if (trim(settings.get('Accounts_CustomFields')).length === 0) { return; } validateCustomFields(formData); - return saveCustomFieldsWithoutValidation(userId, formData); + return saveCustomFieldsWithoutValidation(userId, formData, options); }; diff --git a/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts b/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts index 5a4f3a6096a0d..e3b19b01cfc16 100644 --- a/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts +++ b/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts @@ -1,7 +1,11 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; import { Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; import { trim } from '../../../../lib/utils/stringUtils'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { settings } from '../../../settings/server'; import { notifyOnSubscriptionChangedByUserIdAndRoomType } from '../lib/notifyListener'; @@ -12,7 +16,14 @@ const getCustomFieldsMeta = function (customFieldsMeta: string) { throw new Meteor.Error('error-invalid-customfield-json', 'Invalid JSON for Custom Fields'); } }; -export const saveCustomFieldsWithoutValidation = async function (userId: string, formData: Record): Promise { +export const saveCustomFieldsWithoutValidation = async function ( + userId: string, + formData: Record, + options?: { + _updater?: Updater; + session?: ClientSession; + }, +): Promise { const customFieldsSetting = settings.get('Accounts_CustomFields'); if (!customFieldsSetting || trim(customFieldsSetting).length === 0) { return; @@ -29,7 +40,9 @@ export const saveCustomFieldsWithoutValidation = async function (userId: string, {}, ); - const updater = Users.getUpdater(); + const { _updater, session } = options || {}; + + const updater = _updater || Users.getUpdater(); updater.set('customFields', customFields); @@ -48,11 +61,14 @@ export const saveCustomFieldsWithoutValidation = async function (userId: string, } }); - await Users.updateFromUpdater({ _id: userId }, updater); - - // Update customFields of all Direct Messages' Rooms for userId - const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); - if (setCustomFieldsResponse.modifiedCount) { - void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd'); + if (!_updater) { + await Users.updateFromUpdater({ _id: userId }, updater, { session }); } + + await onceTransactionCommitedSuccessfully(async () => { + const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); + if (setCustomFieldsResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd'); + } + }, session); }; diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts index 307a0ac038a7b..e4a10c4c5a57f 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts @@ -2,15 +2,17 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { isUserFederated } from '@rocket.chat/core-typings'; import type { IUser, IRole, IUserSettings, RequiredField } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; import { callbacks } from '../../../../../lib/callbacks'; +import { wrapInSessionTransaction, onceTransactionCommitedSuccessfully } from '../../../../../server/database/utils'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { safeGetMeteorUser } from '../../../../utils/server/functions/safeGetMeteorUser'; import { generatePassword } from '../../lib/generatePassword'; import { notifyOnUserChange } from '../../lib/notifyListener'; import { passwordPolicy } from '../../lib/passwordPolicy'; +import { saveCustomFields } from '../saveCustomFields'; import { saveUserIdentity } from '../saveUserIdentity'; import { setEmail } from '../setEmail'; import { setStatusText } from '../setStatusText'; @@ -18,8 +20,10 @@ import { handleBio } from './handleBio'; import { handleNickname } from './handleNickname'; import { saveNewUser } from './saveNewUser'; import { sendPasswordEmail } from './sendUserEmail'; +import { setPasswordUpdater } from './setPasswordUpdater'; import { validateUserData } from './validateUserData'; import { validateUserEditing } from './validateUserEditing'; +import { shouldBreakInVersion } from '../../../../../server/lib/shouldBreakInVersion'; export type SaveUserData = { _id?: IUser['_id']; @@ -44,135 +48,160 @@ export type SaveUserData = { joinDefaultChannels?: boolean; sendWelcomeEmail?: boolean; + + customFields?: Record; }; export type UpdateUserData = RequiredField; export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id; -export const saveUser = async function (userId: IUser['_id'], userData: SaveUserData) { - const oldUserData = userData._id && (await Users.findOneById(userData._id)); - if (oldUserData && isUserFederated(oldUserData)) { - throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); - } +const _saveUser = (session?: ClientSession) => + async function (userId: IUser['_id'], userData: SaveUserData) { + const oldUserData = userData._id && (await Users.findOneById(userData._id)); + if (oldUserData && isUserFederated(oldUserData)) { + throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); + } + + await validateUserData(userId, userData); - await validateUserData(userId, userData); + await callbacks.run('beforeSaveUser', { + user: userData, + oldUser: oldUserData, + }); - await callbacks.run('beforeSaveUser', { - user: userData, - oldUser: oldUserData, - }); + let sendPassword = false; - let sendPassword = false; + if (userData.hasOwnProperty('setRandomPassword')) { + if (userData.setRandomPassword) { + userData.password = generatePassword(); + userData.requirePasswordChange = true; + sendPassword = true; + } - if (userData.hasOwnProperty('setRandomPassword')) { - if (userData.setRandomPassword) { - userData.password = generatePassword(); - userData.requirePasswordChange = true; - sendPassword = true; + delete userData.setRandomPassword; } - delete userData.setRandomPassword; - } + if (!isUpdateUserData(userData)) { + // pass session? + return saveNewUser(userData, sendPassword); + } - if (!isUpdateUserData(userData)) { - return saveNewUser(userData, sendPassword); - } + await validateUserEditing(userId, userData); + + // update user + const updater = Users.getUpdater(); + + if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { + if ( + !(await saveUserIdentity({ + _id: userData._id, + username: userData.username, + name: userData.name, + updateUsernameInBackground: true, + updater, + session, + })) + ) { + throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { + method: 'saveUser', + }); + } + } - await validateUserEditing(userId, userData); + if (typeof userData.statusText === 'string') { + await setStatusText(userData._id, userData.statusText, updater, session); + } - // update user - const updater = Users.getUpdater(); + if (userData.email) { + const shouldSendVerificationEmailToUser = userData.verified !== true; + await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater); + } - if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { if ( - !(await saveUserIdentity({ - _id: userData._id, - username: userData.username, - name: userData.name, - updateUsernameInBackground: true, - updater, - })) + userData.password?.trim() && + (await hasPermissionAsync(userId, 'edit-other-user-password')) && + passwordPolicy.validate(userData.password) ) { - throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { - method: 'saveUser', - }); + await setPasswordUpdater(updater, userData.password.trim()); + } else { + sendPassword = false; } - } - if (typeof userData.statusText === 'string') { - await setStatusText(userData._id, userData.statusText, updater); - } - - if (userData.email) { - const shouldSendVerificationEmailToUser = userData.verified !== true; - await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater); - } + handleBio(updater, userData.bio); + handleNickname(updater, userData.nickname); - if ( - userData.password?.trim() && - (await hasPermissionAsync(userId, 'edit-other-user-password')) && - passwordPolicy.validate(userData.password) - ) { - await Accounts.setPasswordAsync(userData._id, userData.password.trim()); - } else { - sendPassword = false; - } + if (userData.roles) { + updater.set('roles', userData.roles); + } + if (userData.settings) { + updater.set('settings', { preferences: userData.settings.preferences }); + } - handleBio(updater, userData.bio); - handleNickname(updater, userData.nickname); + if (userData.language) { + updater.set('language', userData.language); + } - if (userData.roles) { - updater.set('roles', userData.roles); - } - if (userData.settings) { - updater.set('settings', { preferences: userData.settings.preferences }); - } + if (typeof userData.requirePasswordChange !== 'undefined') { + updater.set('requirePasswordChange', userData.requirePasswordChange); + if (!userData.requirePasswordChange) { + updater.unset('requirePasswordChangeReason'); + } + } - if (userData.language) { - updater.set('language', userData.language); - } + if (typeof userData.verified === 'boolean' && !userData.email) { + updater.set('emails.0.verified', userData.verified); + } - if (typeof userData.requirePasswordChange !== 'undefined') { - updater.set('requirePasswordChange', userData.requirePasswordChange); - if (!userData.requirePasswordChange) { - updater.unset('requirePasswordChangeReason'); + if (userData.customFields) { + await saveCustomFields(userData._id, userData.customFields, { _updater: updater, session }); } - } - if (typeof userData.verified === 'boolean' && !userData.email) { - updater.set('emails.0.verified', userData.verified); - } + await Users.updateFromUpdater({ _id: userData._id }, updater, { session }); - await Users.updateFromUpdater({ _id: userData._id }, updater); + await onceTransactionCommitedSuccessfully(async () => { + // App IPostUserUpdated event hook + // We need to pass the session here to ensure this record is fetched + // with the uncommited transaction data. + const userUpdated = await Users.findOneById(userData._id); - // App IPostUserUpdated event hook - const userUpdated = await Users.findOneById(userData._id); + await callbacks.run('afterSaveUser', { + user: userUpdated, + oldUser: oldUserData, + }); - await callbacks.run('afterSaveUser', { - user: userUpdated, - oldUser: oldUserData, - }); + await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { + user: userUpdated, + previousUser: oldUserData, + performedBy: await safeGetMeteorUser(), + }); - await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { - user: userUpdated, - previousUser: oldUserData, - performedBy: await safeGetMeteorUser(), - }); + if (sendPassword) { + await sendPasswordEmail(userData); + } + + if (typeof userData.verified === 'boolean') { + delete userData.verified; + } + void notifyOnUserChange({ + clientAction: 'updated', + id: userData._id, + diff: { + ...userData, + emails: userUpdated?.emails, + }, + }); + }, session); + + return true; + }; - if (sendPassword) { - await sendPasswordEmail(userData); +const isBroken = shouldBreakInVersion('8.0.0'); +export const saveUser = (() => { + if (isBroken) { + throw new Error('DEBUG_DISABLE_USER_AUDIT flag is deprecated and should be removed'); } - if (typeof userData.verified === 'boolean') { - delete userData.verified; + if (!process.env.DEBUG_DISABLE_USER_AUDIT) { + return wrapInSessionTransaction(_saveUser); } - void notifyOnUserChange({ - clientAction: 'updated', - id: userData._id, - diff: { - ...userData, - emails: userUpdated?.emails, - }, - }); - - return true; -}; + return _saveUser(); +})(); diff --git a/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts b/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts new file mode 100644 index 0000000000000..f9218d64449b3 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/setPasswordUpdater.ts @@ -0,0 +1,25 @@ +import crypto from 'crypto'; + +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/model-typings'; +import bcrypt from 'bcrypt'; +import { Accounts } from 'meteor/accounts-base'; + +const hashPassword = async (password: string) => { + const hash = crypto.createHash('sha256'); + hash.update(password); + const hashedPassword = hash.digest('hex'); + return bcrypt.hash(hashedPassword, Accounts._bcryptRounds()); +}; + +export async function setPasswordUpdater( + updater: Updater, + newPlaintextPassword: string, + options: { logout?: boolean } = { logout: true }, +) { + updater.set('services.password.bcrypt', await hashPassword(newPlaintextPassword)); + + if (options.logout) { + updater.unset('services.resume.loginTokens'); + } +} diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 5f7aa27a427af..62c127b46325d 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -1,11 +1,13 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; import { _setRealName } from './setRealName'; import { _setUsername } from './setUsername'; import { updateGroupDMsName } from './updateGroupDMsName'; import { validateName } from './validateName'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { FileUpload } from '../../../file-upload/server'; import { @@ -25,12 +27,14 @@ export async function saveUserIdentity({ username: rawUsername, updateUsernameInBackground = false, updater, + session, }: { _id: string; name?: string; username?: string; updateUsernameInBackground?: boolean; // TODO: remove this updater?: Updater; + session?: ClientSession; }) { if (!_id) { return false; @@ -39,7 +43,7 @@ export async function saveUserIdentity({ const name = String(rawName).trim(); const username = String(rawUsername).trim(); - const user = await Users.findOneById(_id); + const user = await Users.findOneById(_id, { session }); if (!user) { return false; } @@ -54,43 +58,46 @@ export async function saveUserIdentity({ return false; } - if (!(await _setUsername(_id, username, user, updater))) { + if (!(await _setUsername(_id, username, user, updater, session))) { return false; } user.username = username; } if (typeof rawName !== 'undefined' && nameChanged) { - if (!(await _setRealName(_id, name, user, updater))) { + if (!(await _setRealName(_id, name, user, updater, session))) { return false; } } - // if coming from old username, update all references - if (previousUsername) { - 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); + const updateReferences = async () => { + if (previousUsername) { + 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); + } } - } + }; + + await onceTransactionCommitedSuccessfully(updateReferences, session); return true; } diff --git a/apps/meteor/app/lib/server/functions/setEmail.ts b/apps/meteor/app/lib/server/functions/setEmail.ts index 6acac66cd3ffe..174b3893a9c3f 100644 --- a/apps/meteor/app/lib/server/functions/setEmail.ts +++ b/apps/meteor/app/lib/server/functions/setEmail.ts @@ -3,7 +3,9 @@ import type { Updater } from '@rocket.chat/models'; import { Users } from '@rocket.chat/models'; import { escapeHTML } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; @@ -46,6 +48,7 @@ const _setEmail = async function ( shouldSendVerificationEmail = true, verified = false, updater?: Updater, + session?: ClientSession, ) { email = email.trim(); if (!userId) { @@ -58,7 +61,7 @@ const _setEmail = async function ( await validateEmailDomain(email); - const user = await Users.findOneById(userId); + const user = await Users.findOneById(userId, { session }); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: '_setEmail' }); } @@ -79,14 +82,16 @@ const _setEmail = async function ( const oldEmail = user?.emails?.[0]; if (oldEmail) { - await _sendEmailChangeNotification(oldEmail.address, email); + await onceTransactionCommitedSuccessfully(async () => { + await _sendEmailChangeNotification(oldEmail.address, email); + }, session); } // Set new email if (updater) { updater.set('emails', [{ address: email, verified }]); } else { - await Users.setEmail(user?._id, email, verified); + await Users.setEmail(user?._id, email, verified, { session }); } const result = { @@ -94,7 +99,9 @@ const _setEmail = async function ( email, }; if (shouldSendVerificationEmail === true) { - await sendConfirmationEmail(result.email); + await onceTransactionCommitedSuccessfully(async () => { + await sendConfirmationEmail(result.email); + }, session); } return result; }; diff --git a/apps/meteor/app/lib/server/functions/setRealName.ts b/apps/meteor/app/lib/server/functions/setRealName.ts index e0138894357d6..530f828b2cf5e 100644 --- a/apps/meteor/app/lib/server/functions/setRealName.ts +++ b/apps/meteor/app/lib/server/functions/setRealName.ts @@ -3,7 +3,9 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; import { RateLimiter } from '../lib'; @@ -13,6 +15,7 @@ export const _setRealName = async function ( name: string, fullUser?: IUser, updater?: Updater, + session?: ClientSession, ): Promise { name = name.trim(); @@ -20,7 +23,7 @@ export const _setRealName = async function ( return; } - const user = fullUser || (await Users.findOneById(userId)); + const user = fullUser || (await Users.findOneById(userId, { session })); if (!user) { return; @@ -36,27 +39,29 @@ export const _setRealName = async function ( if (updater) { updater.set('name', name); } else { - await Users.setName(user._id, name); + await Users.setName(user._id, name, { session }); } } else if (updater) { updater.unset('name'); } else { - await Users.unsetName(user._id); + await Users.unsetName(user._id, { session }); } user.name = name; - if (settings.get('UI_Use_Real_Name') === true) { - void api.broadcast('user.nameChanged', { + await onceTransactionCommitedSuccessfully(() => { + if (settings.get('UI_Use_Real_Name') === true) { + void api.broadcast('user.nameChanged', { + _id: user._id, + name: user.name, + username: user.username, + }); + } + void api.broadcast('user.realNameChanged', { _id: user._id, - name: user.name, + name, username: user.username, }); - } - void api.broadcast('user.realNameChanged', { - _id: user._id, - name, - username: user.username, - }); + }, session); return user; }; diff --git a/apps/meteor/app/lib/server/functions/setStatusText.ts b/apps/meteor/app/lib/server/functions/setStatusText.ts index 0184fde9070e8..7c81bae0112ed 100644 --- a/apps/meteor/app/lib/server/functions/setStatusText.ts +++ b/apps/meteor/app/lib/server/functions/setStatusText.ts @@ -3,11 +3,13 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { RateLimiter } from '../lib'; -async function _setStatusText(userId: string, statusText: string, updater?: Updater): Promise { +async function _setStatusText(userId: string, statusText: string, updater?: Updater, session?: ClientSession): Promise { if (!userId) { return false; } @@ -16,6 +18,7 @@ async function _setStatusText(userId: string, statusText: string, updater?: Upda const user = await Users.findOneById>(userId, { projection: { username: 1, name: 1, status: 1, roles: 1, statusText: 1 }, + session, }); if (!user) { @@ -29,14 +32,16 @@ async function _setStatusText(userId: string, statusText: string, updater?: Upda if (updater) { updater.set('statusText', statusText); } else { - await Users.updateStatusText(user._id, statusText); + await Users.updateStatusText(user._id, statusText, { session }); } const { _id, username, status, name, roles } = user; - void api.broadcast('presence.status', { - user: { _id, username, status, statusText, name, roles }, - previousStatus: status, - }); + await onceTransactionCommitedSuccessfully(() => { + void api.broadcast('presence.status', { + user: { _id, username, status, statusText, name, roles }, + previousStatus: status, + }); + }, session); return true; } diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index 3b0f0406d6da3..e5a12b110e2b9 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -5,8 +5,10 @@ import { Users } from '@rocket.chat/models'; import type { Response } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; import { checkUrlForSsrf } from './checkUrlForSsrf'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { RocketChatFile } from '../../../file/server'; @@ -68,6 +70,7 @@ export function setUserAvatar( service: 'rest', etag?: string, updater?: Updater, + session?: ClientSession, ): Promise; export function setUserAvatar( user: Pick, @@ -76,6 +79,7 @@ export function setUserAvatar( service?: 'initials' | 'url' | 'rest' | string, etag?: string, updater?: Updater, + session?: ClientSession, ): Promise; export async function setUserAvatar( user: Pick, @@ -84,12 +88,13 @@ export async function setUserAvatar( service?: 'initials' | 'url' | 'rest' | string, etag?: string, updater?: Updater, + session?: ClientSession, ): Promise { if (service === 'initials') { if (updater) { updater.set('avatarOrigin', origin); } else { - await Users.setAvatarData(user._id, service, null); + await Users.setAvatarData(user._id, service, null, { session }); } return; } @@ -171,7 +176,7 @@ export async function setUserAvatar( })(); const fileStore = FileUpload.getStore('Avatars'); - user.username && (await fileStore.deleteByName(user.username)); + user.username && (await fileStore.deleteByName(user.username, { session })); const file = { userId: user._id, @@ -179,23 +184,24 @@ export async function setUserAvatar( size: buffer.length, }; - const result = await fileStore.insert(file, buffer); + const result = await fileStore.insert(file, buffer, { session }); const avatarETag = etag || result?.etag || ''; - setTimeout(async () => { - if (service) { - if (updater) { - updater.set('avatarOrigin', origin); - updater.set('avatarETag', avatarETag); - } else { - await Users.setAvatarData(user._id, service, avatarETag); - } + if (service) { + if (updater) { + updater.set('avatarOrigin', origin); + updater.set('avatarETag', avatarETag); + } else { + // TODO: Why was this timeout added? + setTimeout(async () => Users.setAvatarData(user._id, service, avatarETag, { session }), 500); + } + await onceTransactionCommitedSuccessfully(async () => { void api.broadcast('user.avatarUpdate', { username: user.username, avatarETag, }); - } - }, 500); + }, session); + } } diff --git a/apps/meteor/app/lib/server/functions/setUsername.ts b/apps/meteor/app/lib/server/functions/setUsername.ts index 6c63ecaee45cc..fe2d3cb41200d 100644 --- a/apps/meteor/app/lib/server/functions/setUsername.ts +++ b/apps/meteor/app/lib/server/functions/setUsername.ts @@ -4,6 +4,7 @@ import type { Updater } from '@rocket.chat/models'; import { Invites, Users } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; import _ from 'underscore'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -17,6 +18,7 @@ import { saveUserIdentity } from './saveUserIdentity'; import { setUserAvatar } from './setUserAvatar'; import { validateUsername } from './validateUsername'; import { callbacks } from '../../../../lib/callbacks'; +import { onceTransactionCommitedSuccessfully } from '../../../../server/database/utils'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { notifyOnUserChange } from '../lib/notifyListener'; @@ -67,7 +69,13 @@ export const setUsernameWithValidation = async (userId: string, username: string void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { username } }); }; -export const _setUsername = async function (userId: string, u: string, fullUser: IUser, updater?: Updater): Promise { +export const _setUsername = async function ( + userId: string, + u: string, + fullUser: IUser, + updater?: Updater, + session?: ClientSession, +): Promise { const username = u.trim(); if (!userId || !username) { @@ -78,7 +86,7 @@ export const _setUsername = async function (userId: string, u: string, fullUser: return false; } - const user = fullUser || (await Users.findOneById(userId)); + const user = fullUser || (await Users.findOneById(userId, { session })); // User already has desired username, return if (user.username === username) { return user; @@ -91,18 +99,20 @@ export const _setUsername = async function (userId: string, u: string, fullUser: } } // If first time setting username, send Enrollment Email - try { - if (!previousUsername && user.emails && user.emails.length > 0 && settings.get('Accounts_Enrollment_Email')) { - setImmediate(() => { - Accounts.sendEnrollmentEmail(user._id); - }); - } - } catch (e: any) { - SystemLogger.error(e); + if (!previousUsername && user.emails && user.emails.length > 0 && settings.get('Accounts_Enrollment_Email')) { + await onceTransactionCommitedSuccessfully(() => { + try { + setImmediate(() => { + Accounts.sendEnrollmentEmail(user._id); + }); + } catch (e: any) { + SystemLogger.error(e); + } + }, session); } // Set new username* // TODO: use updater for setting the username and handle possible side effects in addUserToRoom - await Users.setUsername(user._id, username); + await Users.setUsername(user._id, username, { session }); user.username = username; if (!previousUsername && settings.get('Accounts_SetDefaultAvatar') === true) { @@ -119,23 +129,25 @@ export const _setUsername = async function (userId: string, u: string, fullUser: } if (avatarData) { - await setUserAvatar(user, avatarData.blob, avatarData.contentType, serviceName, undefined, updater); + await setUserAvatar(user, avatarData.blob, avatarData.contentType, serviceName, undefined, updater, session); } } - // If it's the first username and the user has an invite Token, then join the invite room - if (!previousUsername && user.inviteToken) { - const inviteData = await Invites.findOneById(user.inviteToken); - if (inviteData?.rid) { - await addUserToRoom(inviteData.rid, user); + await onceTransactionCommitedSuccessfully(async () => { + // If it's the first username and the user has an invite Token, then join the invite room + if (!previousUsername && user.inviteToken) { + const inviteData = await Invites.findOneById(user.inviteToken); + if (inviteData?.rid) { + await addUserToRoom(inviteData.rid, user); + } } - } - void api.broadcast('user.nameChanged', { - _id: user._id, - name: user.name, - username: user.username, - }); + void api.broadcast('user.nameChanged', { + _id: user._id, + name: user.name, + username: user.username, + }); + }, session); return user; }; diff --git a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts index ac204af51439c..0f83cfe1c0880 100644 --- a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts +++ b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts @@ -1,5 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; import { notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; @@ -36,7 +37,12 @@ function sortUsersAlphabetically(u1: IUser, u2: IUser): number { return (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!); } -export const updateGroupDMsName = async (userThatChangedName: IUser): Promise => { +export const updateGroupDMsName = async ( + userThatChangedName: IUser, + options?: { + session?: ClientSession; + }, +): Promise => { if (!userThatChangedName.username) { return; } @@ -48,7 +54,9 @@ export const updateGroupDMsName = async (userThatChangedName: IUser): Promise uids.map((uid) => users.get(uid)).filter(Boolean); @@ -62,10 +70,12 @@ export const updateGroupDMsName = async (userThatChangedName: IUser): Promise _id !== sub.u._id); - const updateNameRespose = await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers)); + const updateNameRespose = await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers), { + session, + }); if (updateNameRespose.modifiedCount) { void notifyOnSubscriptionChangedByRoomId(room._id); } diff --git a/apps/meteor/server/database/utils.ts b/apps/meteor/server/database/utils.ts index 37c5770dce410..714d54606d6a9 100644 --- a/apps/meteor/server/database/utils.ts +++ b/apps/meteor/server/database/utils.ts @@ -1,5 +1,9 @@ +import type { OffCallbackHandler } from '@rocket.chat/emitter'; +import { Emitter } from '@rocket.chat/emitter'; import { MongoInternals } from 'meteor/mongo'; -import type { MongoError } from 'mongodb'; +import type { ClientSession, MongoError } from 'mongodb'; + +import { SystemLogger } from '../lib/logger/system'; export const { db, client } = MongoInternals.defaultRemoteCollectionDriver().mongo; @@ -18,3 +22,69 @@ export const { db, client } = MongoInternals.defaultRemoteCollectionDriver().mon export const shouldRetryTransaction = (e: unknown): boolean => (e as MongoError)?.errorLabels?.includes('UnknownTransactionCommitResult') || (e as MongoError)?.errorLabels?.includes('TransientTransactionError'); + +const isExtendedSession = (session: any): session is ExtendedSession => { + return 'onceSuccesfulCommit' in session; +}; +export const onceTransactionCommitedSuccessfully = async (cb: () => Promise | void, session?: T) => { + if (!session) { + await cb(); + return; + } + if (session?.inTransaction() && isExtendedSession(session)) { + const withError = async () => { + try { + await cb(); + } catch (error) { + SystemLogger.error(error); + } + }; + + session.onceSuccesfulCommit(() => { + void withError(); + }); + } +}; + +type ExtendedSession = ClientSession & { + onceSuccesfulCommit: (cb: (session: ClientSession) => void) => OffCallbackHandler; +}; + +const getExtendedSession = (session: ClientSession, onceSuccesfulCommit: ExtendedSession['onceSuccesfulCommit']): ExtendedSession => { + return Object.assign(session, { onceSuccesfulCommit }); +}; + +class UnsuccessfulTransactionError extends Error { + name = UnsuccessfulTransactionError.name; + + constructor(message?: string) { + super(message || 'Something went wrong while trying to commit changes. Please try again.'); + } +} + +export const wrapInSessionTransaction = + , U>(curriedCallback: (session: ClientSession) => (...args: T) => U) => + async (...args: T): Promise> => { + const ee = new Emitter<{ success: ClientSession }>(); + + const extendedSession = getExtendedSession(client.startSession(), (cb) => ee.once('success', cb)); + + const dispatch = (session: ClientSession) => ee.emit('success', session); + try { + extendedSession.startTransaction(); + extendedSession.once('ended', dispatch); + + const result = await curriedCallback(extendedSession).apply(this, args); + await extendedSession.commitTransaction(); + return result; + } catch (error) { + await extendedSession.abortTransaction(); + extendedSession.removeListener('ended', dispatch); + if (shouldRetryTransaction(error)) { + throw new UnsuccessfulTransactionError(''); + } + throw error; + } finally { + await extendedSession.endSession(); + } + }; diff --git a/apps/meteor/server/lib/shouldBreakInVersion.ts b/apps/meteor/server/lib/shouldBreakInVersion.ts new file mode 100644 index 0000000000000..2ba8c71c31ee4 --- /dev/null +++ b/apps/meteor/server/lib/shouldBreakInVersion.ts @@ -0,0 +1,5 @@ +import semver from 'semver'; + +import { Info } from '../../app/utils/rocketchat.info'; + +export const shouldBreakInVersion = (version: string) => semver.gte(Info.version, version); diff --git a/apps/meteor/server/ufs/ufs-local.ts b/apps/meteor/server/ufs/ufs-local.ts index 4ce2067e3e616..e054cc54d8728 100644 --- a/apps/meteor/server/ufs/ufs-local.ts +++ b/apps/meteor/server/ufs/ufs-local.ts @@ -66,7 +66,7 @@ export class LocalStore extends Store { return path + (file ? `/${file}` : ''); }; - this.delete = async (fileId) => { + this.delete = async (fileId, options) => { const path = await this.getFilePath(fileId); try { @@ -79,7 +79,7 @@ export class LocalStore extends Store { } await unlink(path); - await this.removeById(fileId); + await this.removeById(fileId, { session: options?.session }); }; this.getReadStream = async (fileId: string, file: IUpload, options?: { start?: number; end?: number }) => { diff --git a/apps/meteor/server/ufs/ufs-methods.ts b/apps/meteor/server/ufs/ufs-methods.ts index 23a6048fda45d..768aefdcee3dc 100644 --- a/apps/meteor/server/ufs/ufs-methods.ts +++ b/apps/meteor/server/ufs/ufs-methods.ts @@ -3,10 +3,11 @@ import fs from 'fs'; import type { IUpload } from '@rocket.chat/core-typings'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; import { UploadFS } from './ufs'; -export async function ufsComplete(fileId: string, storeName: string): Promise { +export async function ufsComplete(fileId: string, storeName: string, options?: { session?: ClientSession }): Promise { check(fileId, String); check(storeName, String); @@ -32,14 +33,14 @@ export async function ufsComplete(fileId: string, storeName: string): Promise({ _id: fileId }, { session: options?.session }); if (!file) { throw new Meteor.Error('invalid-file', 'File is not valid'); } // Validate file before moving to the store - await store.validate(file); + await store.validate(file, { session: options?.session }); // Get the temp file const rs = fs.createReadStream(tmpFile, { @@ -51,25 +52,30 @@ export async function ufsComplete(fileId: string, storeName: string): Promise { console.error(err); - void store.removeById(fileId); + void store.removeById(fileId, { session: options?.session }); reject(err); }); // Save file in the store - await store.write(rs, fileId, (err, file) => { - removeTempFile(); + await store.write( + rs, + fileId, + (err, file) => { + removeTempFile(); - if (err) { - return reject(err); - } - if (!file) { - return reject(new Error('Unknown error writing file')); - } - resolve(file); - }); + if (err) { + return reject(err); + } + if (!file) { + return reject(new Error('Unknown error writing file')); + } + resolve(file); + }, + { session: options?.session }, + ); } catch (err: any) { // If write failed, remove the file - await store.removeById(fileId); + await store.removeById(fileId, { session: options?.session }); // removeTempFile(); // todo remove temp file on error or try again ? reject(new Meteor.Error('ufs: cannot upload file', err)); } diff --git a/apps/meteor/server/ufs/ufs-store.ts b/apps/meteor/server/ufs/ufs-store.ts index d800f8d1f46d4..32caadd4a9db8 100644 --- a/apps/meteor/server/ufs/ufs-store.ts +++ b/apps/meteor/server/ufs/ufs-store.ts @@ -7,7 +7,7 @@ import type { IBaseUploadsModel } from '@rocket.chat/model-typings'; import type createServer from 'connect'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { OptionalId } from 'mongodb'; +import type { ClientSession, OptionalId } from 'mongodb'; import { UploadFS } from '.'; import { Filter } from './ufs-filter'; @@ -20,7 +20,7 @@ export type StoreOptions = { onFinishUpload?: (file: IUpload) => Promise; onRead?: (fileId: string, file: IUpload, request: any, response: any) => Promise; onReadError?: (err: any, fileId: string, file: IUpload) => void; - onValidate?: (file: IUpload) => Promise; + onValidate?: (file: IUpload, options?: { session?: ClientSession }) => Promise; onWriteError?: (err: any, fileId: string, file: IUpload) => void; transformRead?: ( readStream: stream.Readable, @@ -42,9 +42,14 @@ export class Store { callback?: (err?: Error, copyId?: string, copy?: OptionalId, store?: Store) => void, ) => Promise; - public create: (file: OptionalId) => Promise; + public create: (file: OptionalId, options?: { session?: ClientSession }) => Promise; - public write: (rs: stream.Readable, fileId: string, callback: (err?: Error, file?: IUpload) => void) => Promise; + public write: ( + rs: stream.Readable, + fileId: string, + callback: (err?: Error, file?: IUpload) => void, + options?: { session?: ClientSession }, + ) => Promise; constructor(options: StoreOptions) { options = { @@ -143,14 +148,14 @@ export class Store { }); }; - this.create = async (file) => { + this.create = async (file, options) => { check(file, Object); file.store = this.options.name; // assign store to file - return (await this.getCollection().insertOne(file)).insertedId; + return (await this.getCollection().insertOne(file, { session: options?.session })).insertedId; }; - this.write = async (rs, fileId, callback) => { - const file = await this.getCollection().findOne({ _id: fileId }); + this.write = async (rs, fileId, callback, options) => { + const file = await this.getCollection().findOne({ _id: fileId }, { session: options?.session }); if (!file) { return callback(new Error('File not found')); } @@ -207,6 +212,7 @@ export class Store { url: file.url, }, }, + { session: options?.session }, ); // Return file info @@ -223,7 +229,7 @@ export class Store { }; } - async removeById(fileId: string) { + async removeById(fileId: string, options?: { session?: ClientSession }) { // Delete the physical file in the store await this.delete(fileId); @@ -237,10 +243,10 @@ export class Store { }); }); - await this.getCollection().removeById(fileId); + await this.getCollection().removeById(fileId, { session: options?.session }); } - async delete(_fileId: string): Promise { + async delete(_fileId: string, _options?: { session?: ClientSession }): Promise { throw new Error('delete is not implemented'); } @@ -324,7 +330,7 @@ export class Store { console.error(`ufs: cannot read file "${fileId}" (${err.message})`, err); } - async onValidate(_file: IUpload) { + async onValidate(_file: IUpload, _options?: { session?: ClientSession }) { // } @@ -355,9 +361,9 @@ export class Store { } } - async validate(file: IUpload) { + async validate(file: IUpload, options?: { session?: ClientSession }) { if (typeof this.onValidate === 'function') { - await this.onValidate(file); + await this.onValidate(file, options); } } } diff --git a/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts index c6b6f9a26faee..02bad9d841dee 100644 --- a/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts +++ b/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts @@ -46,6 +46,7 @@ describe('setUsername', () => { const { setUsernameWithValidation, _setUsername } = proxyquire .noCallThru() .load('../../../../../../app/lib/server/functions/setUsername', { + '../../../../server/database/utils': { onceTransactionCommitedSuccessfully: async (cb: any, _sess: any) => cb() }, 'meteor/meteor': { Meteor: { Error } }, '@rocket.chat/core-services': { api: stubs.api }, '@rocket.chat/models': { Users: stubs.Users, Invites: stubs.Invites }, diff --git a/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts index b91165fb3ca90..51ede78a6e403 100644 --- a/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts +++ b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts @@ -37,6 +37,7 @@ const { saveUserIdentity } = proxyquire.noCallThru().load('../../../../app/lib/s 'Meteor': sinon.stub(), '@global': true, }, + '../../../../server/database/utils': { onceTransactionCommitedSuccessfully: async (cb: any, _sess: any) => cb() }, '../../../../app/file-upload/server': { FileUpload: stubs.FileUpload, }, @@ -45,6 +46,7 @@ const { saveUserIdentity } = proxyquire.noCallThru().load('../../../../app/lib/s }, '../../../../app/lib/server/functions/setUsername': { _setUsername: stubs.setUsername, + _setUsernameWithSession: () => stubs.setUsername, }, '../../../../app/lib/server/functions/updateGroupDMsName': { updateGroupDMsName: sinon.stub(), diff --git a/packages/model-typings/src/models/IBaseModel.ts b/packages/model-typings/src/models/IBaseModel.ts index 9c40721d7501c..c3a541a1d1a75 100644 --- a/packages/model-typings/src/models/IBaseModel.ts +++ b/packages/model-typings/src/models/IBaseModel.ts @@ -2,6 +2,7 @@ import type { RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { BulkWriteOptions, ChangeStream, + ClientSession, Collection, DeleteOptions, DeleteResult, @@ -51,7 +52,7 @@ export interface IBaseModel< getCollectionName(): string; getUpdater(): Updater; - updateFromUpdater(query: Filter, updater: Updater): Promise; + updateFromUpdater(query: Filter, updater: Updater, options?: UpdateOptions): Promise; findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise>; findOneAndUpdate(query: Filter, update: UpdateFilter | T, options?: FindOneAndUpdateOptions): Promise>; @@ -88,7 +89,7 @@ export interface IBaseModel< insertOne(doc: InsertionModel, options?: InsertOneOptions): Promise>; - removeById(_id: T['_id']): Promise; + removeById(_id: T['_id'], options?: { session?: ClientSession }): Promise; removeByIds(ids: T['_id'][]): Promise; diff --git a/packages/model-typings/src/models/IBaseUploadsModel.ts b/packages/model-typings/src/models/IBaseUploadsModel.ts index 12db0ee25d6b6..332801dd50546 100644 --- a/packages/model-typings/src/models/IBaseUploadsModel.ts +++ b/packages/model-typings/src/models/IBaseUploadsModel.ts @@ -1,5 +1,5 @@ import type { IUpload } from '@rocket.chat/core-typings'; -import type { DeleteResult, UpdateResult, Document, InsertOneResult, WithId, FindCursor, FindOptions } from 'mongodb'; +import type { DeleteResult, UpdateResult, ClientSession, Document, InsertOneResult, WithId, FindCursor, FindOptions } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -10,7 +10,7 @@ export interface IBaseUploadsModel extends IBaseModel { confirmTemporaryFile(fileId: string, userId: string): Promise | undefined; - findOneByName(name: string): Promise; + findOneByName(name: string, options?: { session?: ClientSession }): Promise; findOneByRoomId(rid: string): Promise; @@ -18,5 +18,5 @@ export interface IBaseUploadsModel extends IBaseModel { updateFileNameById(fileId: string, name: string): Promise; - deleteFile(fileId: string): Promise; + deleteFile(fileId: string, options?: { session?: ClientSession }): Promise; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index e17831d282d18..6ca771ef2d84c 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -13,6 +13,7 @@ import type { DeleteOptions, CountDocumentsOptions, WithId, + ClientSession, } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -238,7 +239,7 @@ export interface ISubscriptionsModel extends IBaseModel { setFavoriteByRoomIdAndUserId(roomId: string, userId: string, favorite?: boolean): Promise; hideByRoomIdAndUserId(roomId: string, userId: string): Promise; findByRoomIdWhenUserIdExists(rid: string, options?: FindOptions): FindCursor; - updateNameAndFnameById(_id: string, name: string, fname: string): Promise; + updateNameAndFnameById(_id: string, name: string, fname: string, options?: { session?: ClientSession }): Promise; setUserUsernameByUserId(userId: string, username: string): Promise; updateFnameByRoomId(rid: string, fname: string): Promise; updateDisplayNameByRoomId(roomId: string, fname: string): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 6a638ea2e2fda..75f4cdf67e8bf 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -19,6 +19,7 @@ import type { DeleteResult, WithId, UpdateOptions, + ClientSession, } from 'mongodb'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -136,7 +137,7 @@ export interface IUsersModel extends IBaseModel { getUserLanguages(): any; - updateStatusText(_id: any, statusText: any): any; + updateStatusText(_id: any, statusText: any, options?: { session?: ClientSession }): any; updateStatusByAppId(appId: any, status: any): any; @@ -358,13 +359,13 @@ export interface IUsersModel extends IBaseModel { updateLastLoginById(userId: string): Promise; addPasswordToHistory(userId: string, password: string, passwordHistoryAmount: number): Promise; setServiceId(userId: string, serviceName: string, serviceId: string): Promise; - setUsername(userId: string, username: string): Promise; - setEmail(userId: string, email: string, verified?: boolean): Promise; + setUsername(userId: string, username: string, options?: { session?: ClientSession }): Promise; + setEmail(userId: string, email: string, verified?: boolean, options?: { session?: ClientSession }): Promise; setEmailVerified(userId: string, email: string): Promise; - setName(userId: string, name: string): Promise; - unsetName(userId: string): Promise; + setName(userId: string, name: string, options?: { session?: ClientSession }): Promise; + unsetName(userId: string, options?: { session?: ClientSession }): Promise; setCustomFields(userId: string, customFields: Record): Promise; - setAvatarData(userId: string, origin: string, etag?: Date | null | string): Promise; + setAvatarData(userId: string, origin: string, etag?: Date | null | string, options?: { session?: ClientSession }): Promise; unsetAvatarData(userId: string): Promise; setUserActive(userId: string, active: boolean): Promise; setAllUsersActive(active: boolean): Promise; diff --git a/packages/models/src/models/BaseRaw.ts b/packages/models/src/models/BaseRaw.ts index b3045c9cc2a0a..047c889ed1611 100644 --- a/packages/models/src/models/BaseRaw.ts +++ b/packages/models/src/models/BaseRaw.ts @@ -26,6 +26,7 @@ import type { DeleteOptions, FindOneAndDeleteOptions, CountDocumentsOptions, + ClientSession, } from 'mongodb'; import { getCollectionName, UpdaterImpl } from '..'; @@ -285,8 +286,8 @@ export abstract class BaseRaw< return this.col.insertOne(doc as unknown as OptionalUnlessRequiredId, options || {}); } - removeById(_id: T['_id']): Promise { - return this.deleteOne({ _id } as Filter); + removeById(_id: T['_id'], options?: { session?: ClientSession }): Promise { + return this.deleteOne({ _id } as Filter, { session: options?.session }); } removeByIds(ids: T['_id'][]): Promise { diff --git a/packages/models/src/models/BaseUploadModel.ts b/packages/models/src/models/BaseUploadModel.ts index 4037566272b9f..d0a5ad9b705e5 100644 --- a/packages/models/src/models/BaseUploadModel.ts +++ b/packages/models/src/models/BaseUploadModel.ts @@ -10,6 +10,7 @@ import type { Filter, FindOptions, FindCursor, + ClientSession, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -91,8 +92,8 @@ export abstract class BaseUploadModelRaw extends BaseRaw implements IBaseUplo return this.updateOne(filter, update); } - async findOneByName(name: string): Promise { - return this.findOne({ name }); + async findOneByName(name: string, options?: { session?: ClientSession }): Promise { + return this.findOne({ name }, { session: options?.session }); } async findOneByRoomId(rid: string): Promise { @@ -120,7 +121,7 @@ export abstract class BaseUploadModelRaw extends BaseRaw implements IBaseUplo return this.updateOne(filter, update); } - async deleteFile(fileId: string): Promise { - return this.deleteOne({ _id: fileId }); + async deleteFile(fileId: string, options?: { session?: ClientSession }): Promise { + return this.deleteOne({ _id: fileId }, { session: options?.session }); } } diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 7ce4ae15c5489..d8b2f1a90b3dd 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -30,6 +30,7 @@ import type { CountDocumentsOptions, DeleteOptions, WithId, + ClientSession, } from 'mongodb'; import { Rooms, Users } from '../index'; @@ -1444,7 +1445,12 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } - updateNameAndFnameById(_id: string, name: string, fname: string): Promise { + updateNameAndFnameById( + _id: string, + name: string, + fname: string, + options?: { session?: ClientSession }, + ): Promise { const query = { _id }; const update: UpdateFilter = { @@ -1454,7 +1460,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri }, }; - return this.updateMany(query, update); + return this.updateMany(query, update, { session: options?.session }); } setUserUsernameByUserId(userId: string, username: string): Promise { diff --git a/packages/models/src/models/Users.js b/packages/models/src/models/Users.js index 54aaa4539108c..108963273ee7f 100644 --- a/packages/models/src/models/Users.js +++ b/packages/models/src/models/Users.js @@ -875,14 +875,22 @@ export class UsersRaw extends BaseRaw { return this.col.aggregate(pipeline).toArray(); } - updateStatusText(_id, statusText) { + /** + * + * @param {string} _id + * @param {string} statusText + * @param {Object} options + * @param {ClientSession} options.session + * @returns {Promise} + */ + updateStatusText(_id, statusText, options) { const update = { $set: { statusText, }, }; - return this.updateOne({ _id }, update); + return this.updateOne({ _id }, update, { session: options?.session }); } updateStatusByAppId(appId, status) { @@ -2617,13 +2625,30 @@ export class UsersRaw extends BaseRaw { return this.updateOne({ _id }, update); } - setUsername(_id, username) { + /** + * + * @param {string} _id + * @param {string} username + * @param {Object} options + * @param {ClientSession} options.session + * @returns {Promise} + */ + setUsername(_id, username, options) { const update = { $set: { username } }; - return this.updateOne({ _id }, update); + return this.updateOne({ _id }, update, { session: options?.session }); } - setEmail(_id, email, verified = false) { + /** + * + * @param {string} _id + * @param {string} email + * @param {boolean} verified + * @param {Object} options + * @param {ClientSession} options.session + * @returns {Promise} + */ + setEmail(_id, email, verified = false, options) { const update = { $set: { emails: [ @@ -2635,7 +2660,7 @@ export class UsersRaw extends BaseRaw { }, }; - return this.updateOne({ _id }, update); + return this.updateOne({ _id }, update, { session: options?.session }); } // 5 @@ -2659,24 +2684,39 @@ export class UsersRaw extends BaseRaw { return this.updateOne(query, update); } - setName(_id, name) { + /** + * + * @param {string} _id + * @param {string} name + * @param {Object} options + * @param {ClientSession} options.session + * @returns {Promise} + */ + setName(_id, name, options) { const update = { $set: { name, }, }; - return this.updateOne({ _id }, update); + return this.updateOne({ _id }, update, { session: options?.session }); } - unsetName(_id) { + /** + * + * @param {string} _id + * @param {Object} options + * @param {ClientSession} options.session + * @returns {Promise} + */ + unsetName(_id, options) { const update = { $unset: { name, }, }; - return this.updateOne({ _id }, update); + return this.updateOne({ _id }, update, { session: options?.session }); } setCustomFields(_id, fields) { @@ -2690,7 +2730,16 @@ export class UsersRaw extends BaseRaw { return this.updateOne({ _id }, update); } - setAvatarData(_id, origin, etag) { + /** + * + * @param {string} _id + * @param {string} origin + * @param {string} etag + * @param {Object} options + * @param {ClientSession} options.session + * @returns {Promise} + */ + setAvatarData(_id, origin, etag, options) { const update = { $set: { avatarOrigin: origin, @@ -2698,7 +2747,7 @@ export class UsersRaw extends BaseRaw { }, }; - return this.updateOne({ _id }, update); + return this.updateOne({ _id }, update, { session: options?.session }); } unsetAvatarData(_id) {