snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
20
apps/meteor/server/lib/users/saveUser/handleBio.ts
Normal file
20
apps/meteor/server/lib/users/saveUser/handleBio.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/model-typings';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
|
||||
const MAX_BIO_LENGTH = 260;
|
||||
|
||||
export const handleBio = (userUpdater: Updater<IUser>, 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',
|
||||
});
|
||||
}
|
||||
userUpdater.set('bio', bio);
|
||||
} else {
|
||||
userUpdater.unset('bio');
|
||||
}
|
||||
};
|
||||
20
apps/meteor/server/lib/users/saveUser/handleNickname.ts
Normal file
20
apps/meteor/server/lib/users/saveUser/handleNickname.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/model-typings';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
|
||||
const MAX_NICKNAME_LENGTH = 120;
|
||||
|
||||
export const handleNickname = (userUpdater: Updater<IUser>, 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',
|
||||
});
|
||||
}
|
||||
userUpdater.set('nickname', nickname);
|
||||
} else {
|
||||
userUpdater.unset('nickname');
|
||||
}
|
||||
};
|
||||
2
apps/meteor/server/lib/users/saveUser/index.ts
Normal file
2
apps/meteor/server/lib/users/saveUser/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { saveUser } from './saveUser';
|
||||
export { validateUserEditing } from './validateUserEditing';
|
||||
96
apps/meteor/server/lib/users/saveUser/saveNewUser.ts
Normal file
96
apps/meteor/server/lib/users/saveUser/saveNewUser.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import Gravatar from 'gravatar';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { notifyOnUserChangeById } from '../../notifyListener';
|
||||
import { validateEmailDomain } from '../../validateEmailDomain';
|
||||
import { warnGravatarDeprecation } from '../gravatarDeprecation';
|
||||
import { setUserAvatar } from '../setUserAvatar';
|
||||
import { handleBio } from './handleBio';
|
||||
import { handleNickname } from './handleNickname';
|
||||
import type { SaveUserData } from './saveUser';
|
||||
import { sendPasswordEmail, sendWelcomeEmail } from './sendUserEmail';
|
||||
import { getNewUserRoles } from '../../../services/user/lib/getNewUserRoles';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean, performedBy: IUser) {
|
||||
await validateEmailDomain(userData.email);
|
||||
|
||||
const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles();
|
||||
const isGuest = roles?.length === 1 && roles.includes('guest');
|
||||
|
||||
// insert user
|
||||
const createUser: Record<string, any> = {
|
||||
username: userData.username,
|
||||
password: userData.password,
|
||||
...(userData.name && { name: userData.name }),
|
||||
joinDefaultChannels: userData.joinDefaultChannels,
|
||||
isGuest,
|
||||
globalRoles: roles,
|
||||
skipNewUserRolesSetting: true,
|
||||
performedBy,
|
||||
};
|
||||
if (userData.email) {
|
||||
createUser.email = userData.email;
|
||||
}
|
||||
|
||||
const _id = await Accounts.createUserAsync(createUser);
|
||||
|
||||
const updater = Users.getUpdater();
|
||||
|
||||
updater.set('settings', userData.settings || {});
|
||||
if (typeof userData.name !== 'undefined') {
|
||||
updater.set('name', userData.name);
|
||||
}
|
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') {
|
||||
updater.set('requirePasswordChange', userData.requirePasswordChange);
|
||||
}
|
||||
|
||||
if (typeof userData.verified === 'boolean') {
|
||||
updater.set('emails.0.verified', userData.verified);
|
||||
}
|
||||
|
||||
if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== '') {
|
||||
updater.set('freeSwitchExtension', userData.freeSwitchExtension);
|
||||
}
|
||||
|
||||
handleBio(updater, userData.bio);
|
||||
handleNickname(updater, userData.nickname);
|
||||
|
||||
await Users.updateFromUpdater({ _id }, updater);
|
||||
|
||||
if (userData.sendWelcomeEmail) {
|
||||
await sendWelcomeEmail(userData);
|
||||
}
|
||||
|
||||
if (sendPassword) {
|
||||
await sendPasswordEmail(userData);
|
||||
}
|
||||
|
||||
userData._id = _id;
|
||||
|
||||
// Offline (air-gapped) licenses suppress the default Gravatar fetch — a
|
||||
// default-on outbound call the workspace must never initiate on its own.
|
||||
if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email && !License.hasOfflineLicense()) {
|
||||
warnGravatarDeprecation();
|
||||
|
||||
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;
|
||||
};
|
||||
261
apps/meteor/server/lib/users/saveUser/saveUser.ts
Normal file
261
apps/meteor/server/lib/users/saveUser/saveUser.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
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 { Meteor } from 'meteor/meteor';
|
||||
import type { ClientSession } from 'mongodb';
|
||||
|
||||
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 { wrapInSessionTransaction, onceTransactionCommitedSuccessfully } from '../../../database/utils';
|
||||
import type { UserChangedAuditStore } from '../../auditServerEvents/userChanged';
|
||||
import { generatePassword } from '../../auth/generatePassword';
|
||||
import { passwordPolicy } from '../../auth/passwordPolicy';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { notifyOnUserChange } from '../../notifyListener';
|
||||
import { shouldBreakInVersion } from '../../shouldBreakInVersion';
|
||||
import { saveCustomFields } from '../saveCustomFields';
|
||||
import { saveUserIdentity } from '../saveUserIdentity';
|
||||
import { setEmail } from '../setEmail';
|
||||
import { setStatusText } from '../setStatusText';
|
||||
|
||||
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<IUserSettings>;
|
||||
language?: string;
|
||||
|
||||
joinDefaultChannels?: boolean;
|
||||
sendWelcomeEmail?: boolean;
|
||||
|
||||
customFields?: Record<string, any>;
|
||||
active?: boolean;
|
||||
|
||||
freeSwitchExtension?: string;
|
||||
};
|
||||
export type UpdateUserData = RequiredField<SaveUserData, '_id'>;
|
||||
export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id;
|
||||
|
||||
type SaveUserOptions = {
|
||||
auditStore?: UserChangedAuditStore;
|
||||
};
|
||||
|
||||
const findUserById = async (uid: IUser['_id']): Promise<IUser> => {
|
||||
const user = await Users.findOneById(uid);
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const _saveUser = (session?: ClientSession) =>
|
||||
async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) {
|
||||
const performedBy = await findUserById(userId);
|
||||
|
||||
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 (!isUpdateUserData(userData)) {
|
||||
// TODO audit new users
|
||||
return saveNewUser(userData, sendPassword, performedBy);
|
||||
}
|
||||
|
||||
if (!oldUserData) {
|
||||
throw new MeteorError('error-user-not-found', 'User not found', {
|
||||
method: 'saveUser',
|
||||
});
|
||||
}
|
||||
|
||||
options?.auditStore?.setOriginalUser(oldUserData);
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof userData.statusText === 'string') {
|
||||
await setStatusText(oldUserData, userData.statusText, { updater, session });
|
||||
}
|
||||
|
||||
if (userData.email) {
|
||||
const shouldSendVerificationEmailToUser = userData.verified !== true;
|
||||
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater);
|
||||
}
|
||||
|
||||
if (
|
||||
userData.password?.trim() &&
|
||||
(await hasPermissionAsync(userId, 'edit-other-user-password')) &&
|
||||
passwordPolicy.validate(userData.password)
|
||||
) {
|
||||
await setPasswordUpdater(updater, userData.password.trim());
|
||||
} else {
|
||||
sendPassword = false;
|
||||
}
|
||||
|
||||
handleBio(updater, userData.bio);
|
||||
handleNickname(updater, userData.nickname);
|
||||
|
||||
if (userData.roles) {
|
||||
updater.set('roles', userData.roles);
|
||||
}
|
||||
if (userData.settings) {
|
||||
updater.set('settings', { preferences: userData.settings.preferences });
|
||||
}
|
||||
|
||||
if (userData.language) {
|
||||
updater.set('language', userData.language);
|
||||
}
|
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') {
|
||||
updater.set('requirePasswordChange', userData.requirePasswordChange);
|
||||
if (!userData.requirePasswordChange) {
|
||||
updater.unset('requirePasswordChangeReason');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== (oldUserData?.freeSwitchExtension ?? '')) {
|
||||
if (userData.freeSwitchExtension.trim() === '') {
|
||||
updater.unset('freeSwitchExtension');
|
||||
} else {
|
||||
updater.set('freeSwitchExtension', userData.freeSwitchExtension);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof userData.verified === 'boolean') {
|
||||
if (oldUserData && 'emails' in oldUserData && oldUserData.emails?.some(({ address }) => address === userData.email)) {
|
||||
const index = oldUserData.emails.findIndex(({ address }) => address === userData.email);
|
||||
updater.set(`emails.${index}.verified`, userData.verified);
|
||||
}
|
||||
if (!userData.email) {
|
||||
updater.set(`emails.0.verified`, userData.verified);
|
||||
}
|
||||
}
|
||||
|
||||
if (userData.customFields) {
|
||||
await saveCustomFields(userData._id, userData.customFields, { _updater: updater, session });
|
||||
}
|
||||
|
||||
await Users.updateFromUpdater({ _id: userData._id }, updater, { session });
|
||||
|
||||
await onceTransactionCommitedSuccessfully(async () => {
|
||||
if (session && options?.auditStore) {
|
||||
// setting this inside here to avoid moving `executeSetUserActiveStatus` from the endpoint fn
|
||||
// updater will be commited by this point, so it won't affect the external user activation/deactivation
|
||||
if (userData.active !== undefined) {
|
||||
updater.set('active', userData.active);
|
||||
}
|
||||
options.auditStore.setUpdateFilter(updater.getRawUpdateFilter());
|
||||
void options.auditStore.commitAuditEvent();
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
await callbacks.run('afterSaveUser', {
|
||||
user: userUpdated,
|
||||
oldUser: oldUserData,
|
||||
});
|
||||
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, {
|
||||
user: userUpdated,
|
||||
previousUser: oldUserData,
|
||||
performedBy,
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const isBroken = shouldBreakInVersion('9.0.0');
|
||||
export const saveUser = (() => {
|
||||
if (!process.env.DEBUG_DISABLE_USER_AUDIT) {
|
||||
return wrapInSessionTransaction(_saveUser);
|
||||
}
|
||||
|
||||
if (isBroken) {
|
||||
throw new Error('DEBUG_DISABLE_USER_AUDIT flag is deprecated and should be removed');
|
||||
}
|
||||
const saveUserNoSession = _saveUser();
|
||||
return function saveUser(userId: IUser['_id'], userData: SaveUserData, _options?: any) {
|
||||
return saveUserNoSession(userId, userData);
|
||||
};
|
||||
})();
|
||||
55
apps/meteor/server/lib/users/saveUser/sendUserEmail.ts
Normal file
55
apps/meteor/server/lib/users/saveUser/sendUserEmail.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
import { settings } from '../../../settings';
|
||||
import * as Mailer from '../../notifications/email/api';
|
||||
|
||||
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<void> {
|
||||
if (!userData.email) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = {
|
||||
to: userData.email,
|
||||
from: settings.get<string>('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: Pick<SaveUserData, 'email' | 'name' | 'password'>) {
|
||||
return sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData);
|
||||
}
|
||||
|
||||
export async function sendPasswordEmail(userData: Pick<SaveUserData, 'email' | 'name' | 'password'>) {
|
||||
return sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData);
|
||||
}
|
||||
25
apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts
Normal file
25
apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import crypto from 'node: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<IUser>,
|
||||
newPlaintextPassword: string,
|
||||
options: { logout?: boolean } = { logout: true },
|
||||
) {
|
||||
updater.set('services.password.bcrypt', await hashPassword(newPlaintextPassword));
|
||||
|
||||
if (options.logout) {
|
||||
updater.unset('services.resume.loginTokens');
|
||||
}
|
||||
}
|
||||
108
apps/meteor/server/lib/users/saveUser/validateUserData.ts
Normal file
108
apps/meteor/server/lib/users/saveUser/validateUserData.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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 type { SaveUserData } from './saveUser';
|
||||
import { isUpdateUserData } from './saveUser';
|
||||
import { trim } from '../../../../lib/utils/stringUtils';
|
||||
import { settings } from '../../../settings';
|
||||
import { getRoleIds } from '../../authorization/getRoles';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { checkEmailAvailability } from '../checkEmailAvailability';
|
||||
import { checkUsernameAvailability } from '../checkUsernameAvailability';
|
||||
|
||||
export const validateUserData = makeFunction(async (userId: IUser['_id'], userData: SaveUserData): Promise<void> => {
|
||||
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 (isUpdateUserData(userData) && 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 (!isUpdateUserData(userData) && !(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') && !isUpdateUserData(userData) && !trim(userData.name)) {
|
||||
throw new MeteorError('error-the-field-is-required', 'The field Name is required', {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: 'Name',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData) && !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 (!isUpdateUserData(userData) && !userData.password && !userData.setRandomPassword) {
|
||||
throw new MeteorError('error-the-field-is-required', 'The field Password is required', {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: 'Password',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
123
apps/meteor/server/lib/users/saveUser/validateUserEditing.ts
Normal file
123
apps/meteor/server/lib/users/saveUser/validateUserEditing.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable complexity */
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { UpdateUserData } from './saveUser';
|
||||
import { settings } from '../../../settings';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
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;
|
||||
|
||||
export const canEditExtension = async (newExtension?: string) => {
|
||||
if (!settings.get('VoIP_TeamCollab_SIP_Integration_Enabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newExtension && (await Users.findOneByFreeSwitchExtension(newExtension, { projection: { _id: 1 } }))) {
|
||||
throw new MeteorError('error-extension-not-available', 'Extension is already assigned to another user');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: UpdateUserData): Promise<void> {
|
||||
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') &&
|
||||
(editingMyself ? user.username : !canEditOtherUserInfo)
|
||||
) {
|
||||
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') &&
|
||||
(editingMyself ? user.services?.password || !user.requirePasswordChange : !canEditOtherUserPassword)
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user password is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEditingField(user.freeSwitchExtension ?? '', userData.freeSwitchExtension) &&
|
||||
!(await canEditExtension(userData.freeSwitchExtension))
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user voice call extension is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user