From 8b944a17709d528cf7e3ec608074659a1892d47d Mon Sep 17 00:00:00 2001 From: Pierre Date: Thu, 24 Oct 2024 18:42:07 -0300 Subject: [PATCH] chore: convert saveUser to ts --- .../server/functions/getRoles.ts | 3 + .../app/lib/server/functions/saveUser.js | 475 ------------------ .../server/functions/saveUser/handleBio.ts | 22 + .../functions/saveUser/handleNickname.ts | 22 + .../lib/server/functions/saveUser/index.ts | 2 + .../server/functions/saveUser/saveNewUser.ts | 84 ++++ .../lib/server/functions/saveUser/saveUser.ts | 179 +++++++ .../functions/saveUser/sendUserEmail.ts | 54 ++ .../functions/saveUser/validateUserData.ts | 107 ++++ .../functions/saveUser/validateUserEditing.ts | 96 ++++ .../lib/server/methods/insertOrUpdateUser.ts | 6 +- apps/meteor/package.json | 2 + packages/core-typings/src/utils.ts | 8 + packages/tools/package.json | 1 + packages/tools/src/deepGet.spec.ts | 161 ++++++ packages/tools/src/deepGet.ts | 57 +++ packages/tools/src/index.ts | 2 + packages/tools/src/pluck.spec.ts | 66 +++ packages/tools/src/pluck.ts | 28 ++ yarn.lock | 12 + 20 files changed, 910 insertions(+), 477 deletions(-) delete mode 100644 apps/meteor/app/lib/server/functions/saveUser.js create mode 100644 apps/meteor/app/lib/server/functions/saveUser/handleBio.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/handleNickname.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/index.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/saveUser.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/sendUserEmail.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/validateUserData.ts create mode 100644 apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts create mode 100644 packages/tools/src/deepGet.spec.ts create mode 100644 packages/tools/src/deepGet.ts create mode 100644 packages/tools/src/pluck.spec.ts create mode 100644 packages/tools/src/pluck.ts diff --git a/apps/meteor/app/authorization/server/functions/getRoles.ts b/apps/meteor/app/authorization/server/functions/getRoles.ts index 59ab1ef53732..bee995f885d3 100644 --- a/apps/meteor/app/authorization/server/functions/getRoles.ts +++ b/apps/meteor/app/authorization/server/functions/getRoles.ts @@ -2,3 +2,6 @@ import type { IRole } from '@rocket.chat/core-typings'; import { Roles } from '@rocket.chat/models'; export const getRoles = async (): Promise => Roles.find().toArray(); + +export const getRoleIds = async (): Promise => + (await Roles.find({}, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js deleted file mode 100644 index ef6a7e9fe7bd..000000000000 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ /dev/null @@ -1,475 +0,0 @@ -import { Apps, AppEvents } from '@rocket.chat/apps'; -import { isUserFederated } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; -import Gravatar from 'gravatar'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; - -import { callbacks } from '../../../../lib/callbacks'; -import { trim } from '../../../../lib/utils/stringUtils'; -import { getNewUserRoles } from '../../../../server/services/user/lib/getNewUserRoles'; -import { getRoles } from '../../../authorization/server'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import * as Mailer from '../../../mailer/server/api'; -import { settings } from '../../../settings/server'; -import { safeGetMeteorUser } from '../../../utils/server/functions/safeGetMeteorUser'; -import { validateEmailDomain } from '../lib'; -import { generatePassword } from '../lib/generatePassword'; -import { notifyOnUserChangeById, notifyOnUserChange } from '../lib/notifyListener'; -import { passwordPolicy } from '../lib/passwordPolicy'; -import { checkEmailAvailability } from './checkEmailAvailability'; -import { checkUsernameAvailability } from './checkUsernameAvailability'; -import { saveUserIdentity } from './saveUserIdentity'; -import { setEmail } from './setEmail'; -import { setStatusText } from './setStatusText'; -import { setUserAvatar } from './setUserAvatar'; - -const MAX_BIO_LENGTH = 260; -const MAX_NICKNAME_LENGTH = 120; - -let html = ''; -let passwordChangedHtml = ''; -Meteor.startup(() => { - Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { - html = template; - }); - - Mailer.getTemplate('Password_Changed_Email', (template) => { - passwordChangedHtml = template; - }); -}); - -async function _sendUserEmail(subject, html, userData) { - const email = { - to: userData.email, - from: settings.get('From_Email'), - subject, - html, - data: { - email: userData.email, - password: userData.password, - }, - }; - - if (typeof userData.name !== 'undefined') { - email.data.name = userData.name; - } - - try { - await Mailer.send(email); - } catch (error) { - throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, { - function: 'RocketChat.saveUser', - message: error.message, - }); - } -} - -async function validateUserData(userId, userData) { - const existingRoles = _.pluck(await getRoles(), '_id'); - - if (userData.verified && userData._id && userId === userData._id) { - throw new Meteor.Error('error-action-not-allowed', 'Editing email verification is not allowed', { - method: 'insertOrUpdateUser', - action: 'Editing_user', - }); - } - - if (userData._id && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { - throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { - method: 'insertOrUpdateUser', - action: 'Editing_user', - }); - } - - if (!userData._id && !(await hasPermissionAsync(userId, 'create-user'))) { - throw new Meteor.Error('error-action-not-allowed', 'Adding user is not allowed', { - method: 'insertOrUpdateUser', - action: 'Adding_user', - }); - } - - if (userData.roles && _.difference(userData.roles, existingRoles).length > 0) { - throw new Meteor.Error('error-action-not-allowed', 'The field Roles consist invalid role id', { - method: 'insertOrUpdateUser', - action: 'Assign_role', - }); - } - - if (userData.roles && userData.roles.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) { - throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { - method: 'insertOrUpdateUser', - action: 'Assign_admin', - }); - } - - if (settings.get('Accounts_RequireNameForSignUp') && !userData._id && !trim(userData.name)) { - throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { - method: 'insertOrUpdateUser', - field: 'Name', - }); - } - - if (!userData._id && !trim(userData.username)) { - throw new Meteor.Error('error-the-field-is-required', 'The field Username is required', { - method: 'insertOrUpdateUser', - field: 'Username', - }); - } - - let nameValidation; - - try { - nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); - } catch (e) { - nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); - } - - if (userData.username && !nameValidation.test(userData.username)) { - throw new Meteor.Error('error-input-is-not-a-valid-field', `${_.escape(userData.username)} is not a valid username`, { - method: 'insertOrUpdateUser', - input: userData.username, - field: 'Username', - }); - } - - if (!userData._id && !userData.password && !userData.setRandomPassword) { - throw new Meteor.Error('error-the-field-is-required', 'The field Password is required', { - method: 'insertOrUpdateUser', - field: 'Password', - }); - } - - if (!userData._id) { - if (!(await checkUsernameAvailability(userData.username))) { - throw new Meteor.Error('error-field-unavailable', `${_.escape(userData.username)} is already in use :(`, { - method: 'insertOrUpdateUser', - field: userData.username, - }); - } - - if (userData.email && !(await checkEmailAvailability(userData.email))) { - throw new Meteor.Error('error-field-unavailable', `${_.escape(userData.email)} is already in use :(`, { - method: 'insertOrUpdateUser', - field: userData.email, - }); - } - } -} - -/** - * Validate permissions to edit user fields - * - * @param {string} userId - * @param {{ _id: string, roles?: string[], username?: string, name?: string, statusText?: string, email?: string, password?: string}} userData - */ -export async function validateUserEditing(userId, userData) { - const editingMyself = userData._id && userId === userData._id; - - const canEditOtherUserInfo = await hasPermissionAsync(userId, 'edit-other-user-info'); - const canEditOtherUserPassword = await hasPermissionAsync(userId, 'edit-other-user-password'); - const user = await Users.findOneById(userData._id); - - const isEditingUserRoles = (previousRoles, newRoles) => - typeof newRoles !== 'undefined' && !_.isEqual(_.sortBy(previousRoles), _.sortBy(newRoles)); - const isEditingField = (previousValue, newValue) => typeof newValue !== 'undefined' && newValue !== previousValue; - - if (isEditingUserRoles(user.roles, userData.roles) && !(await hasPermissionAsync(userId, 'assign-roles'))) { - throw new Meteor.Error('error-action-not-allowed', 'Assign roles is not allowed', { - method: 'insertOrUpdateUser', - action: 'Assign_role', - }); - } - - if (!settings.get('Accounts_AllowUserProfileChange') && !canEditOtherUserInfo && !canEditOtherUserPassword) { - throw new Meteor.Error('error-action-not-allowed', 'Edit user profile is not allowed', { - method: 'insertOrUpdateUser', - action: 'Update_user', - }); - } - - if ( - isEditingField(user.username, userData.username) && - !settings.get('Accounts_AllowUsernameChange') && - (!canEditOtherUserInfo || editingMyself) - ) { - throw new Meteor.Error('error-action-not-allowed', 'Edit username is not allowed', { - method: 'insertOrUpdateUser', - action: 'Update_user', - }); - } - - if ( - isEditingField(user.statusText, userData.statusText) && - !settings.get('Accounts_AllowUserStatusMessageChange') && - (!canEditOtherUserInfo || editingMyself) - ) { - throw new Meteor.Error('error-action-not-allowed', 'Edit user status is not allowed', { - method: 'insertOrUpdateUser', - action: 'Update_user', - }); - } - - if ( - isEditingField(user.name, userData.name) && - !settings.get('Accounts_AllowRealNameChange') && - (!canEditOtherUserInfo || editingMyself) - ) { - throw new Meteor.Error('error-action-not-allowed', 'Edit user real name is not allowed', { - method: 'insertOrUpdateUser', - action: 'Update_user', - }); - } - - if ( - user.emails?.[0] && - isEditingField(user.emails[0].address, userData.email) && - !settings.get('Accounts_AllowEmailChange') && - (!canEditOtherUserInfo || editingMyself) - ) { - throw new Meteor.Error('error-action-not-allowed', 'Edit user email is not allowed', { - method: 'insertOrUpdateUser', - action: 'Update_user', - }); - } - - if (userData.password && !settings.get('Accounts_AllowPasswordChange') && (!canEditOtherUserPassword || editingMyself)) { - throw new Meteor.Error('error-action-not-allowed', 'Edit user password is not allowed', { - method: 'insertOrUpdateUser', - action: 'Update_user', - }); - } -} - -const handleBio = (updateUser, bio) => { - if (bio && bio.trim()) { - if (bio.length > MAX_BIO_LENGTH) { - throw new Meteor.Error('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { - method: 'saveUserProfile', - }); - } - updateUser.$set = updateUser.$set || {}; - updateUser.$set.bio = bio; - } else { - updateUser.$unset = updateUser.$unset || {}; - updateUser.$unset.bio = 1; - } -}; - -const handleNickname = (updateUser, nickname) => { - if (nickname && nickname.trim()) { - if (nickname.length > MAX_NICKNAME_LENGTH) { - throw new Meteor.Error('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { - method: 'saveUserProfile', - }); - } - updateUser.$set = updateUser.$set || {}; - updateUser.$set.nickname = nickname; - } else { - updateUser.$unset = updateUser.$unset || {}; - updateUser.$unset.nickname = 1; - } -}; - -const saveNewUser = async function (userData, sendPassword) { - await validateEmailDomain(userData.email); - - const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); - const isGuest = roles && roles.length === 1 && roles.includes('guest'); - - // insert user - const createUser = { - username: userData.username, - password: userData.password, - joinDefaultChannels: userData.joinDefaultChannels, - isGuest, - globalRoles: roles, - skipNewUserRolesSetting: true, - }; - if (userData.email) { - createUser.email = userData.email; - } - - const _id = await Accounts.createUserAsync(createUser); - - const updateUser = { - $set: { - ...(typeof userData.name !== 'undefined' && { name: userData.name }), - settings: userData.settings || {}, - }, - }; - - if (typeof userData.requirePasswordChange !== 'undefined') { - updateUser.$set.requirePasswordChange = userData.requirePasswordChange; - } - - if (typeof userData.verified === 'boolean') { - updateUser.$set['emails.0.verified'] = userData.verified; - } - - handleBio(updateUser, userData.bio); - handleNickname(updateUser, userData.nickname); - - await Users.updateOne({ _id }, updateUser); - - if (userData.sendWelcomeEmail) { - await _sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); - } - - if (sendPassword) { - await _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); - } - - userData._id = _id; - - if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { - const gravatarUrl = Gravatar.url(userData.email, { - default: '404', - size: '200', - protocol: 'https', - }); - - try { - await setUserAvatar(userData, gravatarUrl, '', 'url'); - } catch (e) { - // Ignore this error for now, as it not being successful isn't bad - } - } - - void notifyOnUserChangeById({ clientAction: 'inserted', id: _id }); - - return _id; -}; - -export const saveUser = async function (userId, userData) { - 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 callbacks.run('beforeSaveUser', { - user: userData, - oldUser: oldUserData, - }); - - let sendPassword = false; - - if (userData.hasOwnProperty('setRandomPassword')) { - if (userData.setRandomPassword) { - userData.password = generatePassword(); - userData.requirePasswordChange = true; - sendPassword = true; - } - - delete userData.setRandomPassword; - } - - if (!userData._id) { - return saveNewUser(userData, sendPassword); - } - - await validateUserEditing(userId, userData); - - // update user - if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { - if ( - !(await saveUserIdentity({ - _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', { - method: 'saveUser', - }); - } - } - - if (typeof userData.statusText === 'string') { - await setStatusText(userData._id, userData.statusText); - } - - if (userData.email) { - const shouldSendVerificationEmailToUser = userData.verified !== true; - await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); - } - - if ( - userData.password && - 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; - } - - const updateUser = { - $set: {}, - $unset: {}, - }; - - handleBio(updateUser, userData.bio); - handleNickname(updateUser, userData.nickname); - - if (userData.roles) { - updateUser.$set.roles = userData.roles; - } - if (userData.settings) { - updateUser.$set.settings = { preferences: userData.settings.preferences }; - } - - if (userData.language) { - updateUser.$set.language = userData.language; - } - - if (typeof userData.requirePasswordChange !== 'undefined') { - updateUser.$set.requirePasswordChange = userData.requirePasswordChange; - if (!userData.requirePasswordChange) { - updateUser.$unset.requirePasswordChangeReason = 1; - } - } - - if (typeof userData.verified === 'boolean') { - updateUser.$set['emails.0.verified'] = userData.verified; - } - - await Users.updateOne({ _id: userData._id }, updateUser); - - // App IPostUserUpdated event hook - const userUpdated = await Users.findOneById(userData._id); - - await callbacks.run('afterSaveUser', { - user: userUpdated, - oldUser: oldUserData, - }); - - await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { - user: userUpdated, - previousUser: oldUserData, - performedBy: await safeGetMeteorUser(), - }); - - if (sendPassword) { - await _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); - } - - if (typeof userData.verified === 'boolean') { - delete userData.verified; - } - void notifyOnUserChange({ - clientAction: 'updated', - id: userData._id, - diff: { - ...userData, - emails: userUpdated.emails, - }, - }); - - return true; -}; diff --git a/apps/meteor/app/lib/server/functions/saveUser/handleBio.ts b/apps/meteor/app/lib/server/functions/saveUser/handleBio.ts new file mode 100644 index 000000000000..1d2f572f5cd9 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/handleBio.ts @@ -0,0 +1,22 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { DeepPartial, DeepWritable, IUser } from '@rocket.chat/core-typings'; +import type { UpdateFilter } from 'mongodb'; + +import type { SaveUserData } from './saveUser'; + +const MAX_BIO_LENGTH = 260; + +export const handleBio = (updateUser: DeepWritable>>, bio: SaveUserData['bio']) => { + if (bio?.trim()) { + if (bio.length > MAX_BIO_LENGTH) { + throw new MeteorError('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { + method: 'saveUserProfile', + }); + } + updateUser.$set = updateUser.$set || {}; + updateUser.$set.bio = bio; + } else { + updateUser.$unset = updateUser.$unset || {}; + updateUser.$unset.bio = 1; + } +}; diff --git a/apps/meteor/app/lib/server/functions/saveUser/handleNickname.ts b/apps/meteor/app/lib/server/functions/saveUser/handleNickname.ts new file mode 100644 index 000000000000..4a37ec9e1518 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/handleNickname.ts @@ -0,0 +1,22 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { DeepPartial, DeepWritable, IUser } from '@rocket.chat/core-typings'; +import type { UpdateFilter } from 'mongodb'; + +import type { SaveUserData } from './saveUser'; + +const MAX_NICKNAME_LENGTH = 120; + +export const handleNickname = (updateUser: DeepWritable>>, nickname: SaveUserData['nickname']) => { + if (nickname?.trim()) { + if (nickname.length > MAX_NICKNAME_LENGTH) { + throw new MeteorError('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { + method: 'saveUserProfile', + }); + } + updateUser.$set = updateUser.$set || {}; + updateUser.$set.nickname = nickname; + } else { + updateUser.$unset = updateUser.$unset || {}; + updateUser.$unset.nickname = 1; + } +}; diff --git a/apps/meteor/app/lib/server/functions/saveUser/index.ts b/apps/meteor/app/lib/server/functions/saveUser/index.ts new file mode 100644 index 000000000000..3fd0668e6e47 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/index.ts @@ -0,0 +1,2 @@ +export { saveUser } from './saveUser'; +export { validateUserEditing } from './validateUserEditing'; diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts new file mode 100644 index 000000000000..18e2858e81c0 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts @@ -0,0 +1,84 @@ +import type { DeepPartial, DeepWritable, IUser, RequiredField } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import Gravatar from 'gravatar'; +import type { UpdateFilter } from 'mongodb'; + +import { getNewUserRoles } from '../../../../../server/services/user/lib/getNewUserRoles'; +import { settings } from '../../../../settings/server'; +import { notifyOnUserChangeById } from '../../lib/notifyListener'; +import { validateEmailDomain } from '../../lib/validateEmailDomain'; +import { setUserAvatar } from '../setUserAvatar'; +import { handleBio } from './handleBio'; +import { handleNickname } from './handleNickname'; +import type { SaveUserData } from './saveUser'; +import { sendPasswordEmail, sendWelcomeEmail } from './sendUserEmail'; + +export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean) { + await validateEmailDomain(userData.email); + + const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); + const isGuest = roles && roles.length === 1 && roles.includes('guest'); + + // insert user + const createUser: Record = { + username: userData.username, + password: userData.password, + joinDefaultChannels: userData.joinDefaultChannels, + isGuest, + globalRoles: roles, + skipNewUserRolesSetting: true, + }; + if (userData.email) { + createUser.email = userData.email; + } + + const _id = await Accounts.createUserAsync(createUser); + + const updateUser: RequiredField>>, '$set'> = { + $set: { + ...(typeof userData.name !== 'undefined' && { name: userData.name }), + settings: userData.settings || {}, + }, + }; + + if (typeof userData.requirePasswordChange !== 'undefined') { + updateUser.$set.requirePasswordChange = userData.requirePasswordChange; + } + + if (typeof userData.verified === 'boolean') { + updateUser.$set['emails.0.verified'] = userData.verified; + } + + handleBio(updateUser, userData.bio); + handleNickname(updateUser, userData.nickname); + + await Users.updateOne({ _id }, updateUser as UpdateFilter); + + if (userData.sendWelcomeEmail) { + await sendWelcomeEmail(userData); + } + + if (sendPassword) { + await sendPasswordEmail(userData); + } + + userData._id = _id; + + if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { + const gravatarUrl = Gravatar.url(userData.email, { + default: '404', + size: '200', + protocol: 'https', + }); + + try { + await setUserAvatar({ ...userData, _id }, gravatarUrl, '', 'url'); + } catch (e) { + // Ignore this error for now, as it not being successful isn't bad + } + } + + void notifyOnUserChangeById({ clientAction: 'inserted', id: _id }); + + return _id; +}; diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts new file mode 100644 index 000000000000..047a417e94a1 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts @@ -0,0 +1,179 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { DeepWritable, DeepPartial } from '@rocket.chat/core-typings'; +import { isUserFederated, type IUser, type IRole, type IUserSettings, type 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 { UpdateFilter } from 'mongodb'; + +import { callbacks } from '../../../../../lib/callbacks'; +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 { saveUserIdentity } from '../saveUserIdentity'; +import { setEmail } from '../setEmail'; +import { setStatusText } from '../setStatusText'; +import { handleBio } from './handleBio'; +import { handleNickname } from './handleNickname'; +import { saveNewUser } from './saveNewUser'; +import { sendPasswordEmail } from './sendUserEmail'; +import { validateUserData } from './validateUserData'; +import { validateUserEditing } from './validateUserEditing'; + +export type SaveUserData = { + _id?: IUser['_id']; + setRandomPassword?: boolean; + + password?: string; + requirePasswordChange?: boolean; + + username?: string; + name?: string; + + statusText?: string; + email?: string; + verified?: boolean; + + bio?: string; + nickname?: string; + + roles?: IRole['_id'][]; + settings?: Partial; + language?: string; + + joinDefaultChannels?: boolean; + sendWelcomeEmail?: boolean; +}; + +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'); + } + + await validateUserData(userId, userData); + + await callbacks.run('beforeSaveUser', { + user: userData, + oldUser: oldUserData, + }); + + let sendPassword = false; + + if (userData.hasOwnProperty('setRandomPassword')) { + if (userData.setRandomPassword) { + userData.password = generatePassword(); + userData.requirePasswordChange = true; + sendPassword = true; + } + + delete userData.setRandomPassword; + } + + if (!userData._id) { + return saveNewUser(userData, sendPassword); + } + + await validateUserEditing(userId, userData as RequiredField); + + // update user + if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { + if ( + !(await saveUserIdentity({ + _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', { + method: 'saveUser', + }); + } + } + + if (typeof userData.statusText === 'string') { + await setStatusText(userData._id, userData.statusText); + } + + if (userData.email) { + const shouldSendVerificationEmailToUser = userData.verified !== true; + await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); + } + + 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; + } + + const updateUser: RequiredField>>, '$set' | '$unset'> = { + $set: {}, + $unset: {}, + }; + + handleBio(updateUser, userData.bio); + handleNickname(updateUser, userData.nickname); + + if (userData.roles) { + updateUser.$set.roles = userData.roles; + } + if (userData.settings) { + updateUser.$set.settings = { preferences: userData.settings.preferences }; + } + + if (userData.language) { + updateUser.$set.language = userData.language; + } + + if (typeof userData.requirePasswordChange !== 'undefined') { + updateUser.$set.requirePasswordChange = userData.requirePasswordChange; + if (!userData.requirePasswordChange) { + updateUser.$unset.requirePasswordChangeReason = 1; + } + } + + if (typeof userData.verified === 'boolean') { + updateUser.$set['emails.0.verified'] = userData.verified; + } + + await Users.updateOne({ _id: userData._id }, updateUser as UpdateFilter); + + // App IPostUserUpdated event hook + const userUpdated = await Users.findOneById(userData._id); + + await callbacks.run('afterSaveUser', { + user: userUpdated, + oldUser: oldUserData, + }); + + 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, + }, + }); + + return true; +}; diff --git a/apps/meteor/app/lib/server/functions/saveUser/sendUserEmail.ts b/apps/meteor/app/lib/server/functions/saveUser/sendUserEmail.ts new file mode 100644 index 000000000000..babe985dbd4b --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/sendUserEmail.ts @@ -0,0 +1,54 @@ +import { MeteorError } from '@rocket.chat/core-services'; + +import * as Mailer from '../../../../mailer/server/api'; +import { settings } from '../../../../settings/server'; +import type { SaveUserData } from './saveUser'; + +let html = ''; +let passwordChangedHtml = ''; +Meteor.startup(() => { + Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { + html = template; + }); + + Mailer.getTemplate('Password_Changed_Email', (template) => { + passwordChangedHtml = template; + }); +}); + +export async function sendUserEmail(subject: string, html: string, userData: SaveUserData): Promise { + if (!userData.email) { + return; + } + + const email = { + to: userData.email, + from: settings.get('From_Email'), + subject, + html, + data: { + email: userData.email, + password: userData.password, + ...(typeof userData.name !== 'undefined' ? { name: userData.name } : {}), + }, + }; + + try { + await Mailer.send(email); + } catch (error) { + const errorMessage = typeof error === 'object' && error && 'message' in error ? error.message : ''; + + throw new MeteorError('error-email-send-failed', `Error trying to send email: ${errorMessage}`, { + function: 'RocketChat.saveUser', + message: errorMessage, + }); + } +} + +export async function sendWelcomeEmail(userData: SaveUserData) { + return sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); +} + +export async function sendPasswordEmail(userData: SaveUserData) { + return sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); +} diff --git a/apps/meteor/app/lib/server/functions/saveUser/validateUserData.ts b/apps/meteor/app/lib/server/functions/saveUser/validateUserData.ts new file mode 100644 index 000000000000..52652a6c47b4 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/validateUserData.ts @@ -0,0 +1,107 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import escape from 'lodash.escape'; + +import { trim } from '../../../../../lib/utils/stringUtils'; +import { getRoleIds } from '../../../../authorization/server/functions/getRoles'; +import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { settings } from '../../../../settings/server'; +import { checkEmailAvailability } from '../checkEmailAvailability'; +import { checkUsernameAvailability } from '../checkUsernameAvailability'; +import type { SaveUserData } from './saveUser'; + +export const validateUserData = makeFunction(async (userId: IUser['_id'], userData: SaveUserData): Promise => { + const existingRoles = await getRoleIds(); + + if (userData.verified && userData._id && userId === userData._id) { + throw new MeteorError('error-action-not-allowed', 'Editing email verification is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + + if (userData._id && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { + throw new MeteorError('error-action-not-allowed', 'Editing user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + + if (!userData._id && !(await hasPermissionAsync(userId, 'create-user'))) { + throw new MeteorError('error-action-not-allowed', 'Adding user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Adding_user', + }); + } + + if (userData.roles) { + const newRoles = userData.roles.filter((roleId) => !existingRoles.includes(roleId)); + if (newRoles.length > 0) { + throw new MeteorError('error-action-not-allowed', 'The field Roles consist invalid role id', { + method: 'insertOrUpdateUser', + action: 'Assign_role', + }); + } + } + + if (userData.roles?.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) { + throw new MeteorError('error-action-not-allowed', 'Assigning admin is not allowed', { + method: 'insertOrUpdateUser', + action: 'Assign_admin', + }); + } + + if (settings.get('Accounts_RequireNameForSignUp') && !userData._id && !trim(userData.name)) { + throw new MeteorError('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateUser', + field: 'Name', + }); + } + + if (!userData._id && !trim(userData.username)) { + throw new MeteorError('error-the-field-is-required', 'The field Username is required', { + method: 'insertOrUpdateUser', + field: 'Username', + }); + } + + let nameValidation; + + try { + nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); + } catch (e) { + nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + } + + if (userData.username && !nameValidation.test(userData.username)) { + throw new MeteorError('error-input-is-not-a-valid-field', `${escape(userData.username)} is not a valid username`, { + method: 'insertOrUpdateUser', + input: userData.username, + field: 'Username', + }); + } + + if (!userData._id && !userData.password && !userData.setRandomPassword) { + throw new MeteorError('error-the-field-is-required', 'The field Password is required', { + method: 'insertOrUpdateUser', + field: 'Password', + }); + } + + if (!userData._id) { + if (userData.username && !(await checkUsernameAvailability(userData.username))) { + throw new MeteorError('error-field-unavailable', `${escape(userData.username)} is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.username, + }); + } + + if (userData.email && !(await checkEmailAvailability(userData.email))) { + throw new MeteorError('error-field-unavailable', `${escape(userData.email)} is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.email, + }); + } + } +}); diff --git a/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts b/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts new file mode 100644 index 000000000000..78b8910361cd --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts @@ -0,0 +1,96 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IUser, RequiredField } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { settings } from '../../../../settings/server'; +import type { SaveUserData } from './saveUser'; + +const isEditingUserRoles = (previousRoles: IUser['roles'], newRoles?: IUser['roles']) => + newRoles !== undefined && + (newRoles.some((item) => !previousRoles.includes(item)) || previousRoles.some((item) => !newRoles.includes(item))); +const isEditingField = (previousValue?: string, newValue?: string) => typeof newValue !== 'undefined' && newValue !== previousValue; + +/** + * Validate permissions to edit user fields + * + * @param {string} userId + * @param {{ _id: string, roles?: string[], username?: string, name?: string, statusText?: string, email?: string, password?: string}} userData + */ +export async function validateUserEditing(userId: IUser['_id'], userData: RequiredField): Promise { + const editingMyself = userData._id && userId === userData._id; + + const canEditOtherUserInfo = await hasPermissionAsync(userId, 'edit-other-user-info'); + const canEditOtherUserPassword = await hasPermissionAsync(userId, 'edit-other-user-password'); + const user = await Users.findOneById(userData._id); + + if (!user) { + throw new MeteorError('error-invalid-user', 'Invalid user'); + } + + if (isEditingUserRoles(user.roles, userData.roles) && !(await hasPermissionAsync(userId, 'assign-roles'))) { + throw new MeteorError('error-action-not-allowed', 'Assign roles is not allowed', { + method: 'insertOrUpdateUser', + action: 'Assign_role', + }); + } + + if (!settings.get('Accounts_AllowUserProfileChange') && !canEditOtherUserInfo && !canEditOtherUserPassword) { + throw new MeteorError('error-action-not-allowed', 'Edit user profile is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.username, userData.username) && + !settings.get('Accounts_AllowUsernameChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit username is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.statusText, userData.statusText) && + !settings.get('Accounts_AllowUserStatusMessageChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user status is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.name, userData.name) && + !settings.get('Accounts_AllowRealNameChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user real name is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + user.emails?.[0] && + isEditingField(user.emails[0].address, userData.email) && + !settings.get('Accounts_AllowEmailChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user email is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if (userData.password && !settings.get('Accounts_AllowPasswordChange') && (!canEditOtherUserPassword || editingMyself)) { + throw new MeteorError('error-action-not-allowed', 'Edit user password is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } +} diff --git a/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts b/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts index 122b11172d57..3c5f07f624b3 100644 --- a/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts +++ b/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts @@ -19,12 +19,14 @@ Meteor.methods({ check(userData, Object); - if (!Meteor.userId()) { + const userId = Meteor.userId(); + + if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'insertOrUpdateUser', }); } - return saveUser(Meteor.userId(), userData); + return saveUser(userId, userData); }), }); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 6d48ccde9994..6b6cf6a9dd2b 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -122,6 +122,7 @@ "@types/lodash": "^4.14.200", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.escape": "^4.0.9", "@types/lodash.get": "^4.4.9", "@types/mailparser": "^3.4.4", "@types/marked": "^4.0.8", @@ -370,6 +371,7 @@ "localforage": "^1.10.0", "lodash.clonedeep": "^4.5.0", "lodash.debounce": "^4.0.8", + "lodash.escape": "^4.0.1", "lodash.get": "^4.4.2", "mailparser": "^3.4.0", "marked": "^4.2.5", diff --git a/packages/core-typings/src/utils.ts b/packages/core-typings/src/utils.ts index e739257c070b..e6d4bfd4dace 100644 --- a/packages/core-typings/src/utils.ts +++ b/packages/core-typings/src/utils.ts @@ -34,3 +34,11 @@ export type DeepWritable = T extends (...args: any) => any }; export type DistributiveOmit = T extends any ? Omit : never; + +export type ValueOfUnion> = T extends any ? (K extends keyof T ? T[K] : undefined) : undefined; + +export type ValueOfOptional> = T extends undefined ? undefined : T extends object ? ValueOfUnion : null; + +export type DeepPartial = { + [P in keyof T]?: T[P] extends (infer U)[] ? DeepPartial[] : T[P] extends object | undefined ? DeepPartial : T[P]; +}; diff --git a/packages/tools/package.json b/packages/tools/package.json index 23555ff93b57..e7d232134a8e 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -3,6 +3,7 @@ "version": "0.2.2", "private": true, "devDependencies": { + "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", "@types/jest": "~29.5.13", "eslint": "~8.45.0", diff --git a/packages/tools/src/deepGet.spec.ts b/packages/tools/src/deepGet.spec.ts new file mode 100644 index 000000000000..79f969899e37 --- /dev/null +++ b/packages/tools/src/deepGet.spec.ts @@ -0,0 +1,161 @@ +import { deepGet } from './deepGet'; + +describe('deepGet', () => { + const object = { + aValue: 1, + aArray: [10, 11], + a: { + bValue: 2, + bArray: [20, 21], + b: { + cValue: 3, + cArray: [30, 31], + c: { + dValue: 4, + dArray: [40, 41], + d: { + e: { + f: { + g: { + deeplyNested: 'deeplyNestedValue', + }, + }, + }, + }, + }, + }, + }, + }; + + it('should return array values using a numeric key', () => { + const list = [10, 20, 30, 40]; + + expect(deepGet(list, [0])).toBe(10); + expect(deepGet(list, [1])).toBe(20); + expect(deepGet(list, [5])).toBe(undefined); + }); + + it('should return first level attributes using a string key', () => { + expect(deepGet(object, 'aValue')).toBe(1); + expect(deepGet(object, 'aArray')).toBe(object.aArray); + }); + it('should return first level attributes using an array key', () => { + expect(deepGet(object, ['aValue'])).toBe(1); + expect(deepGet(object, ['aArray'])).toBe(object.aArray); + }); + it('should return first level array values using a numeric key', () => { + expect(deepGet(object, ['aArray', 0])).toBe(10); + expect(deepGet(object, ['aArray', 1])).toBe(11); + }); + + it('should return second level attributes using a string key', () => { + expect(deepGet(object, 'a.bValue')).toBe(2); + expect(deepGet(object, 'a.bArray')).toBe(object.a.bArray); + }); + it('should return second level attributes using an array key', () => { + expect(deepGet(object, ['a', 'bValue'])).toBe(2); + expect(deepGet(object, ['a', 'bArray'])).toBe(object.a.bArray); + }); + it('should return second level array values using a numeric key', () => { + expect(deepGet(object, ['a', 'bArray', 0])).toBe(20); + expect(deepGet(object, ['a', 'bArray', 1])).toBe(21); + }); + + it('should return third level attributes using a string key', () => { + expect(deepGet(object, 'a.b.cValue')).toBe(3); + expect(deepGet(object, 'a.b.cArray')).toBe(object.a.b.cArray); + }); + it('should return third level attributes using an array key', () => { + expect(deepGet(object, ['a', 'b', 'cValue'])).toBe(3); + expect(deepGet(object, ['a', 'b', 'cArray'])).toBe(object.a.b.cArray); + }); + it('should return third level array values using a numeric key', () => { + expect(deepGet(object, ['a', 'b', 'cArray', 0])).toBe(30); + expect(deepGet(object, ['a', 'b', 'cArray', 1])).toBe(31); + }); + + it('should return fourth level attributes using a string key', () => { + expect(deepGet(object, 'a.b.c.dValue')).toBe(4); + expect(deepGet(object, 'a.b.c.dArray')).toBe(object.a.b.c.dArray); + }); + it('should return fourth level attributes using an array key', () => { + expect(deepGet(object, ['a', 'b', 'c', 'dValue'])).toBe(4); + expect(deepGet(object, ['a', 'b', 'c', 'dArray'])).toBe(object.a.b.c.dArray); + }); + it('should return fourth level array values using a numeric key', () => { + expect(deepGet(object, ['a', 'b', 'c', 'dArray', 0])).toBe(40); + expect(deepGet(object, ['a', 'b', 'c', 'dArray', 1])).toBe(41); + }); + + it('should return deep nested values using a string key', () => { + expect(deepGet(object, 'a.b.c.d.e.f.g.deeplyNested')).toBe('deeplyNestedValue'); + }); + + it('should return deep nested values using an array key', () => { + expect(deepGet(object, ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'deeplyNested'])).toBe('deeplyNestedValue'); + }); + + it('should return undefined when trying to access an invalid attribute using a string key', () => { + expect(deepGet(object as any, 'c')).toBeUndefined(); + expect(deepGet(object as any, 'c.c')).toBeUndefined(); + + expect(deepGet(object, 'a')).toBeDefined(); + expect(deepGet(object as any, 'a.c')).toBeUndefined(); + expect(deepGet(object as any, 'a.c.c')).toBeUndefined(); + + expect(deepGet(object, 'a.b')).toBeDefined(); + expect(deepGet(object as any, 'a.b.a')).toBeUndefined(); + expect(deepGet(object as any, 'a.b.a.c')).toBeUndefined(); + + expect(deepGet(object, 'a.b.c')).toBeDefined(); + // After the third level there's no more type validation + expect(deepGet(object, 'a.b.c.a')).toBeUndefined(); + expect(deepGet(object, 'a.b.c.a.c')).toBeUndefined(); + + expect(deepGet(object, 'a.b.c.d')).toBeDefined(); + expect(deepGet(object, 'a.b.c.d.a')).toBeUndefined(); + expect(deepGet(object, 'a.b.c.d.a.c')).toBeUndefined(); + }); + + it('should return undefined when trying to access an invalid attribute using an array key', () => { + expect(deepGet(object as any, ['c'])).toBeUndefined(); + expect(deepGet(object as any, ['c', 'c'])).toBeUndefined(); + + expect(deepGet(object, ['a'])).toBeDefined(); + expect(deepGet(object as any, ['a', 'c'])).toBeUndefined(); + expect(deepGet(object as any, ['a', 'c', 'c'])).toBeUndefined(); + + expect(deepGet(object, 'a.b')).toBeDefined(); + expect(deepGet(object as any, ['a', 'b', 'a'])).toBeUndefined(); + expect(deepGet(object as any, ['a', 'b', 'a', 'c'])).toBeUndefined(); + + expect(deepGet(object, ['a', 'b', 'c'])).toBeDefined(); + // After the third level there's no more type validation + expect(deepGet(object, ['a', 'b', 'c', 'a'])).toBeUndefined(); + expect(deepGet(object, ['a', 'b', 'c', 'a', 'c'])).toBeUndefined(); + + expect(deepGet(object, ['a', 'b', 'c', 'd'])).toBeDefined(); + expect(deepGet(object, ['a', 'b', 'c', 'd', 'a'])).toBeUndefined(); + expect(deepGet(object, ['a', 'b', 'c', 'd', 'a', 'c'])).toBeUndefined(); + }); + + it('should return null when accessing an attribute of a non-object', () => { + expect(deepGet('hi' as any, 'value')).toBeNull(); + expect(deepGet({ a: 'hi' } as any, 'a.value')).toBeNull(); + expect(deepGet({ a: { b: 'hi' } } as any, 'a.b.value')).toBeNull(); + expect(deepGet({ a: { b: { c: 'hi' } } } as any, 'a.b.c.value')).toBeNull(); + expect(deepGet({ a: { b: { c: { d: 'hi' } } } } as any, 'a.b.c.d.value')).toBeNull(); + + expect(deepGet(10 as any, 'value')).toBeNull(); + expect(deepGet({ a: 10 } as any, 'a.value')).toBeNull(); + expect(deepGet({ a: { b: 10 } } as any, 'a.b.value')).toBeNull(); + expect(deepGet({ a: { b: { c: 10 } } } as any, 'a.b.c.value')).toBeNull(); + expect(deepGet({ a: { b: { c: { d: 10 } } } } as any, 'a.b.c.d.value')).toBeNull(); + + expect(deepGet(false as any, 'value')).toBeNull(); + expect(deepGet({ a: false } as any, 'a.value')).toBeNull(); + expect(deepGet({ a: { b: false } } as any, 'a.b.value')).toBeNull(); + expect(deepGet({ a: { b: { c: false } } } as any, 'a.b.c.value')).toBeNull(); + expect(deepGet({ a: { b: { c: { d: false } } } } as any, 'a.b.c.d.value')).toBeNull(); + }); +}); diff --git a/packages/tools/src/deepGet.ts b/packages/tools/src/deepGet.ts new file mode 100644 index 000000000000..784755a1b15a --- /dev/null +++ b/packages/tools/src/deepGet.ts @@ -0,0 +1,57 @@ +import type { KeyOfEach, ValueOfOptional } from '@rocket.chat/core-typings'; + +// The recursive function doesn't infer any types, otherwise it would not be able to call itself beyond the level that we validate +function _deepGet(object: Record, path: string[]): any { + if (object === undefined) { + return undefined; + } + + if (!object || typeof object !== 'object') { + return null; + } + + const [nextProp, ...pathList] = path; + const value = object[nextProp]; + + if (pathList.length) { + return _deepGet(value, pathList); + } + + return value; +} + +// Simple attribute with no recursion +export function deepGet, K extends KeyOfEach>( + object: TObject, + path: K | [K], +): ValueOfOptional; + +// One recursion level +export function deepGet, K extends KeyOfEach, L extends KeyOfEach[K]>>( + object: TObject, + path: `${string & K}.${string & L}` | [K, L], +): ValueOfOptional, L>; + +// Two recursion levels +export function deepGet< + TObject extends Record, + K extends KeyOfEach, + L extends KeyOfEach[K]>, + M extends KeyOfEach[K]>[L]>, +>( + object: TObject, + path: `${string & K}.${string & L}.${string & M}` | [K, L, M], +): ValueOfOptional, L>, M>; + +// Three or more recursion levels, returns any +export function deepGet< + TObject extends Record, + K extends KeyOfEach, + L extends KeyOfEach[K]>, + M extends KeyOfEach[K]>[L]>, +>(object: TObject, path: `${string & K}.${string & L}.${string & M}.${string}` | [K, L, M, ...(string | number | symbol)[]]): any; + +// Implementation matches all overloads +export function deepGet>(object: TObject, path: string | string[]): any { + return _deepGet(object, typeof path === 'string' ? path.split('.') : path); +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 96faa4d55969..9b63bb95a1d8 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,6 +1,8 @@ +export * from './deepGet'; export * from './getObjectKeys'; export * from './normalizeLanguage'; export * from './pick'; +export * from './pluck'; export * from './stream'; export * from './timezone'; export * from './wrapExceptions'; diff --git a/packages/tools/src/pluck.spec.ts b/packages/tools/src/pluck.spec.ts new file mode 100644 index 000000000000..6d4132622293 --- /dev/null +++ b/packages/tools/src/pluck.spec.ts @@ -0,0 +1,66 @@ +import { pluck } from './pluck'; + +function makeObject(index: number) { + return { + value: 10 * index, + a: { + value: 20 * index, + b: { + value: 30 * index, + c: { + value: 40 * index, + d: { + e: { + f: { + g: { + value: 50 * index, + }, + }, + }, + }, + }, + }, + }, + }; +} + +function multiplyBy(multiplier: number) { + return (value: number) => { + return value * multiplier; + }; +} + +describe('pluck', () => { + const indexes = [1, 2, 3, 4]; + const list = indexes.map(makeObject); + + it('should return a list of first level attributes', () => { + expect(pluck(list, 'value')).toMatchObject(indexes.map(multiplyBy(10))); + }); + + it('should return a list of second level attributes', () => { + expect(pluck(list, 'a.value')).toMatchObject(indexes.map(multiplyBy(20))); + }); + + it('should return a list of third level attributes', () => { + expect(pluck(list, 'a.b.value')).toMatchObject(indexes.map(multiplyBy(30))); + }); + + it('should return a list of fourth level attributes', () => { + expect(pluck(list, 'a.b.c.value')).toMatchObject(indexes.map(multiplyBy(40))); + }); + + it('should return a list of deep nested attributes', () => { + expect(pluck(list, 'a.b.c.d.e.f.g.value')).toMatchObject(indexes.map(multiplyBy(50))); + }); + + it('should return a list of undefined when acessing attributes that do not exists', () => { + expect(pluck(list as any, 'b')).toMatchObject(indexes.map(() => undefined)); + expect(pluck(list as any, 'a.c')).toMatchObject(indexes.map(() => undefined)); + expect(pluck(list as any, 'a.b.d')).toMatchObject(indexes.map(() => undefined)); + }); + + it('should return a list of nulls when trying to access attributes of a non-object', () => { + expect(pluck(list as any, 'value.value')).toMatchObject(indexes.map(() => null)); + }); +}); diff --git a/packages/tools/src/pluck.ts b/packages/tools/src/pluck.ts new file mode 100644 index 000000000000..39ad3981b84f --- /dev/null +++ b/packages/tools/src/pluck.ts @@ -0,0 +1,28 @@ +import type { KeyOfEach, ValueOfOptional } from '@rocket.chat/core-typings'; + +import { deepGet } from './deepGet'; + +export function pluck, K extends KeyOfEach>( + list: TObject[], + key: K, +): ValueOfOptional[]; +export function pluck, K extends KeyOfEach, L extends KeyOfEach>>( + list: TObject[], + key: `${string & K}.${string & L}`, +): ValueOfOptional, L>[]; +export function pluck< + TObject extends Record, + K extends KeyOfEach, + L extends KeyOfEach>, + M extends KeyOfEach, L>>, +>(list: TObject[], key: `${string & K}.${string & L}.${string & M}`): ValueOfOptional, L>, M>[]; +export function pluck< + TObject extends Record, + K extends KeyOfEach, + L extends KeyOfEach>, + M extends KeyOfEach, L>>, +>(list: TObject[], key: `${string & K}.${string & L}.${string & M}.${string}`): any[]; + +export function pluck>(list: TObject[], key: string) { + return list.map((item) => deepGet(item as any, key)); +} diff --git a/yarn.lock b/yarn.lock index b4ed3718e3db..9055b6ed28a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9031,6 +9031,7 @@ __metadata: "@types/lodash": "npm:^4.14.200" "@types/lodash.clonedeep": "npm:^4.5.9" "@types/lodash.debounce": "npm:^4.0.9" + "@types/lodash.escape": "npm:^4.0.9" "@types/lodash.get": "npm:^4.4.9" "@types/mailparser": "npm:^3.4.4" "@types/marked": "npm:^4.0.8" @@ -9172,6 +9173,7 @@ __metadata: localforage: "npm:^1.10.0" lodash.clonedeep: "npm:^4.5.0" lodash.debounce: "npm:^4.0.8" + lodash.escape: "npm:^4.0.1" lodash.get: "npm:^4.4.2" mailparser: "npm:^3.4.0" marked: "npm:^4.2.5" @@ -9817,6 +9819,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/tools@workspace:packages/tools" dependencies: + "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" "@types/jest": "npm:~29.5.13" eslint: "npm:~8.45.0" @@ -12524,6 +12527,15 @@ __metadata: languageName: node linkType: hard +"@types/lodash.escape@npm:^4.0.9": + version: 4.0.9 + resolution: "@types/lodash.escape@npm:4.0.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/bae0be4bddefaacfb8e96afec895138643cb59d1d5d8c29fda0d61ed77468a8a2d9f913d51cd67ab08afc7a09f2602fec1a289abd9aab335821671a4b3c1245f + languageName: node + linkType: hard + "@types/lodash.get@npm:^4.4.9": version: 4.4.9 resolution: "@types/lodash.get@npm:4.4.9"