Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { DeepWritable, IUser, RequiredField } from '@rocket.chat/core-typings';
import { isUserFederated } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { UserCreateParamsPOST, UsersUpdateParamsPOST } from '@rocket.chat/rest-typings';
import Gravatar from 'gravatar';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { UpdateFilter } from 'mongodb';
import _ from 'underscore';

import { callbacks } from '../../../../lib/callbacks';
Expand All @@ -25,6 +28,11 @@ import { setEmail } from './setEmail';
import { setStatusText } from './setStatusText';
import { setUserAvatar } from './setUserAvatar';

type ICreateUserParams = UserCreateParamsPOST;
type IUpdateUserParams = UsersUpdateParamsPOST['data'] & { _id: IUser['_id'] };
export type ISaveUserDataParams = ICreateUserParams | IUpdateUserParams;
const isUpdateUserParams = (params: ISaveUserDataParams): params is IUpdateUserParams => '_id' in params && !!params._id;

const MAX_BIO_LENGTH = 260;
const MAX_NICKNAME_LENGTH = 120;

Expand All @@ -40,50 +48,48 @@ Meteor.startup(() => {
});
});

async function _sendUserEmail(subject, html, userData) {
async function _sendUserEmail(subject: string, html: string, userData: RequiredField<ISaveUserDataParams, 'email'>) {
const email = {
to: userData.email,
from: settings.get('From_Email'),
from: settings.get<string>('From_Email'),
subject,
html,
data: {
email: userData.email,
password: userData.password,
...(userData.name && { name: userData.name }),
},
};

if (typeof userData.name !== 'undefined') {
email.data.name = userData.name;
}

try {
await Mailer.send(email);
} catch (error) {
} catch (error: any) {
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) {
async function validateUserData(userId: IUser['_id'], userData: ISaveUserDataParams) {
const existingRoles = _.pluck(await getRoles(), '_id');
const isUserUpdate = isUpdateUserParams(userData);

if (userData.verified && userData._id && userId === userData._id) {
if (isUserUpdate && userData.verified && 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'))) {
if (isUserUpdate && 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'))) {
if (!isUserUpdate && !(await hasPermissionAsync(userId, 'create-user'))) {
throw new Meteor.Error('error-action-not-allowed', 'Adding user is not allowed', {
method: 'insertOrUpdateUser',
action: 'Adding_user',
Expand All @@ -97,21 +103,21 @@ async function validateUserData(userId, userData) {
});
}

if (userData.roles && userData.roles.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) {
if (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)) {
if (settings.get('Accounts_RequireNameForSignUp') && !isUserUpdate && !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)) {
if (!isUserUpdate && !trim(userData.username)) {
throw new Meteor.Error('error-the-field-is-required', 'The field Username is required', {
method: 'insertOrUpdateUser',
field: 'Username',
Expand All @@ -134,15 +140,15 @@ async function validateUserData(userId, userData) {
});
}

if (!userData._id && !userData.password && !userData.setRandomPassword) {
if (!isUserUpdate && !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))) {
if (!isUserUpdate) {
if (userData.username && !(await checkUsernameAvailability(userData.username))) {
throw new Meteor.Error('error-field-unavailable', `${_.escape(userData.username)} is already in use :(`, {
method: 'insertOrUpdateUser',
field: userData.username,
Expand All @@ -158,22 +164,20 @@ async function validateUserData(userId, userData) {
}
}

/**
* 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) {
export async function validateUserEditing(userId: IUser['_id'], userData: IUpdateUserParams) {
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) =>
if (!user) {
throw new Error('error-invalid-user');
}

const isEditingUserRoles = (previousRoles: IUser['roles'], newRoles?: IUser['roles']) =>
typeof newRoles !== 'undefined' && !_.isEqual(_.sortBy(previousRoles), _.sortBy(newRoles));
const isEditingField = (previousValue, newValue) => typeof newValue !== 'undefined' && newValue !== previousValue;
const isEditingField = (previousValue?: string, newValue?: string) => 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', {
Expand Down Expand Up @@ -242,8 +246,8 @@ export async function validateUserEditing(userId, userData) {
}
}

const handleBio = (updateUser, bio) => {
if (bio && bio.trim()) {
const handleBio = (updateUser: DeepWritable<UpdateFilter<IUser>>, bio?: string) => {
if (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',
Expand All @@ -257,8 +261,8 @@ const handleBio = (updateUser, bio) => {
}
};

const handleNickname = (updateUser, nickname) => {
if (nickname && nickname.trim()) {
const handleNickname = (updateUser: DeepWritable<UpdateFilter<IUser>>, nickname?: string) => {
if (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',
Expand All @@ -272,7 +276,7 @@ const handleNickname = (updateUser, nickname) => {
}
};

const saveNewUser = async function (userData, sendPassword) {
const saveNewUser = async function (userData: ICreateUserParams, sendPassword: boolean) {
await validateEmailDomain(userData.email);

const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles();
Expand All @@ -283,21 +287,18 @@ const saveNewUser = async function (userData, sendPassword) {
username: userData.username,
password: userData.password,
joinDefaultChannels: userData.joinDefaultChannels,
...(userData.email && { email: userData.email }),
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 || {},
},
const updateUser: DeepWritable<UpdateFilter<IUser>> = {};
updateUser.$set = {
...(typeof userData.name !== 'undefined' && { name: userData.name }),
settings: userData.settings || {},
};

if (typeof userData.requirePasswordChange !== 'undefined') {
Expand All @@ -321,8 +322,6 @@ const saveNewUser = async function (userData, 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',
Expand All @@ -331,7 +330,7 @@ const saveNewUser = async function (userData, sendPassword) {
});

try {
await setUserAvatar(userData, gravatarUrl, '', 'url');
await setUserAvatar({ _id, username: userData.username }, gravatarUrl, '', 'url');
} catch (e) {
// Ignore this error for now, as it not being successful isn't bad
}
Expand All @@ -342,8 +341,8 @@ const saveNewUser = async function (userData, sendPassword) {
return _id;
};

export const saveUser = async function (userId, userData) {
const oldUserData = userData._id && (await Users.findOneById(userData._id));
export const saveUser = async function (userId: IUser['_id'], userData: ISaveUserDataParams) {
const oldUserData = '_id' in userData && 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');
}
Expand All @@ -367,8 +366,8 @@ export const saveUser = async function (userId, userData) {
delete userData.setRandomPassword;
}

if (!userData._id) {
return saveNewUser(userData, sendPassword);
if (!('_id' in userData) || !userData._id) {
return saveNewUser(userData as ICreateUserParams, sendPassword);
}

await validateUserEditing(userId, userData);
Expand Down Expand Up @@ -399,8 +398,7 @@ export const saveUser = async function (userId, userData) {
}

if (
userData.password &&
userData.password.trim() &&
userData.password?.trim() &&
(await hasPermissionAsync(userId, 'edit-other-user-password')) &&
passwordPolicy.validate(userData.password)
) {
Expand All @@ -409,10 +407,9 @@ export const saveUser = async function (userId, userData) {
sendPassword = false;
}

const updateUser = {
$set: {},
$unset: {},
};
const updateUser: DeepWritable<UpdateFilter<IUser>> = {};
updateUser.$set = {};
updateUser.$unset = {};

handleBio(updateUser, userData.bio);
handleNickname(updateUser, userData.nickname);
Expand Down Expand Up @@ -455,8 +452,13 @@ export const saveUser = async function (userId, userData) {
performedBy: await safeGetMeteorUser(),
});

if (sendPassword) {
await _sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData);
if (sendPassword && userUpdated?.emails && userUpdated.emails[0].address) {
userData.email = userUpdated.emails[0].address;
await _sendUserEmail(
settings.get('Password_Changed_Email_Subject'),
passwordChangedHtml,
userData as RequiredField<IUpdateUserParams, 'email'>,
);
}

if (typeof userData.verified === 'boolean') {
Expand All @@ -467,7 +469,7 @@ export const saveUser = async function (userId, userData) {
id: userData._id,
diff: {
...userData,
emails: userUpdated.emails,
emails: userUpdated?.emails,
},
});

Expand Down
6 changes: 4 additions & 2 deletions apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired';
import type { ISaveUserDataParams } from '../functions/saveUser';
import { saveUser } from '../functions/saveUser';
import { methodDeprecationLogger } from '../lib/deprecationWarningLogger';

Expand All @@ -18,13 +19,14 @@ Meteor.methods<ServerMethods>({
methodDeprecationLogger.method('insertOrUpdateUser', '8.0.0');

check(userData, Object);
const userId = Meteor.userId();

if (!Meteor.userId()) {
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'insertOrUpdateUser',
});
}

return saveUser(Meteor.userId(), userData);
return saveUser(userId, userData as ISaveUserDataParams);
}),
});
2 changes: 1 addition & 1 deletion packages/core-typings/src/IUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export interface IUserEmail {
}

export interface IUserSettings {
profile: any;
profile?: any;
preferences?: {
[key: string]: any;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IUserSettings } from '@rocket.chat/core-typings';
import Ajv from 'ajv';

const ajv = new Ajv({
Expand All @@ -20,6 +21,7 @@ export type UserCreateParamsPOST = {
sendWelcomeEmail?: boolean;
verified?: boolean;
customFields?: object;
settings?: IUserSettings;
/* @deprecated */
fields: string;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IUserSettings } from '@rocket.chat/core-typings';
import Ajv from 'ajv';

const ajv = new Ajv({
Expand All @@ -22,6 +23,8 @@ export type UsersUpdateParamsPOST = {
sendWelcomeEmail?: boolean;
verified?: boolean;
customFields?: Record<string, unknown>;
settings?: IUserSettings;
language?: string;
status?: string;
};
confirmRelinquish?: boolean;
Expand Down