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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { RoomMemberActions } from '../../../definition/IRoomTypeConfig';
import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../notifyListener';
import { roomCoordinator } from '../rooms/roomCoordinator';
export const blockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise<void> => {
const room = await Rooms.findOne({ _id: rid });
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' });
}
if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, userId))) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' });
}
const [blockedUser, blockerUser] = await Promise.all([
Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }),
Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }),
]);
if (!blockedUser || !blockerUser) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' });
}
const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId);
const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])];
if (listenerUsers.length) {
void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers);
}
};

View File

@@ -0,0 +1,5 @@
import { Users } from '@rocket.chat/models';
export const checkEmailAvailability = async function (email: string): Promise<boolean> {
return !(await Users.findOneByEmailAddress(email));
};

View File

@@ -0,0 +1,73 @@
import { Team } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import { settings } from '../../settings';
import type { UsernameAvailabilityCheckType } from '../callbacks/checkUsernameAvailabilityCallback';
import { checkUsernameAvailabilityCallback } from '../callbacks/checkUsernameAvailabilityCallback';
import { validateName } from '../shared/validateName';
let usernameBlackList: RegExp[] = [];
const toRegExp = (username: string): RegExp => new RegExp(`^${escapeRegExp(username).trim()}$`, 'i');
settings.watch('Accounts_BlockedUsernameList', (value: string) => {
usernameBlackList = ['all', 'here'].concat(value.split(',')).map(toRegExp);
});
const usernameIsBlocked = (username: string, usernameBlackList: RegExp[]): boolean | number =>
usernameBlackList.length && usernameBlackList.some((restrictedUsername) => restrictedUsername.test(escapeRegExp(username).trim()));
export const checkUsernameAvailabilityWithValidation = async function (userId: string, username: string): Promise<boolean> {
if (!username) {
throw new Meteor.Error('error-invalid-username', 'Invalid username', { method: 'setUsername' });
}
const user = await Users.findOneById(userId, { projection: { username: 1 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUsername' });
}
if (user.username && !settings.get('Accounts_AllowUsernameChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setUsername' });
}
if (user.username === username) {
return true;
}
return checkUsernameAvailability(username);
};
export const checkUsernameAvailability = async function (username: string, type: UsernameAvailabilityCheckType = 'user'): Promise<boolean> {
if (usernameIsBlocked(username, usernameBlackList) || !validateName(username)) {
throw new Meteor.Error('error-blocked-username', `${_.escape(username)} is blocked and can't be used!`, {
method: 'checkUsernameAvailability',
field: username,
});
}
// Make sure no users are using this username
const existingUser = await Users.findOneByUsernameIgnoringCase(username, {
projection: { _id: 1 },
});
if (existingUser) {
return false;
}
// Make sure no teams are using this username
const existingTeam = await Team.getOneByName(toRegExp(username), { projection: { _id: 1 } });
if (existingTeam) {
return false;
}
try {
await checkUsernameAvailabilityCallback.run(username, type);
return true;
} catch (error) {
return false;
}
};

View File

@@ -0,0 +1,186 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { api } from '@rocket.chat/core-services';
import { isUserFederated, type IUser } from '@rocket.chat/core-typings';
import {
Integrations,
LivechatVisitors,
LivechatDepartmentAgents,
Messages,
Rooms,
Subscriptions,
Users,
ReadReceipts,
ReadReceiptsArchive,
LivechatUnitMonitors,
ModerationReports,
} from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { i18n } from '../i18n';
import { FileUpload } from '../media/file-upload';
import {
notifyOnRoomChangedById,
notifyOnIntegrationChangedByUserId,
notifyOnLivechatDepartmentAgentChanged,
notifyOnUserChange,
} from '../notifyListener';
import { getSubscribedRoomsForUserWithDetails, shouldRemoveOrChangeOwner } from '../rooms/getRoomsWithSingleOwner';
import { relinquishRoomOwnerships } from '../rooms/relinquishRoomOwnerships';
import { updateGroupDMsName } from '../rooms/updateGroupDMsName';
export async function deleteUser(userId: string, confirmRelinquish = false, deletedBy?: IUser['_id']): Promise<{ deletedRooms: string[] }> {
if (userId === 'rocket.cat') {
throw new Meteor.Error('error-action-not-allowed', 'Deleting the rocket.cat user is not allowed', {
method: 'deleteUser',
action: 'Delete_user',
});
}
const user = await Users.findOneById(userId, {
projection: { username: 1, avatarOrigin: 1, roles: 1, federated: 1 },
});
if (!user) {
return { deletedRooms: [] };
}
if (isUserFederated(user)) {
throw new Meteor.Error('error-not-allowed', 'User participated in federation, this user can only be deactivated permanently', {
method: 'deleteUser',
});
}
const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId);
if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) {
const rooms = await getUserSingleOwnedRooms(subscribedRooms);
throw new Meteor.Error('user-last-owner', '', rooms);
}
let deletedRooms: string[] = [];
// Users without username can't do anything, so there is nothing to remove
if (user.username != null) {
let userToReplaceWhenUnlinking: IUser | null = null;
const nameAlias = i18n.t('Removed_User');
deletedRooms = await relinquishRoomOwnerships(userId, subscribedRooms, true);
const messageErasureType = settings.get<'Delete' | 'Unlink' | 'Keep'>('Message_ErasureType');
switch (messageErasureType) {
case 'Delete':
const store = FileUpload.getStore('Uploads');
const cursor = Messages.findFilesByUserId(userId);
for await (const { file, files } of cursor) {
const fileIds = files?.map(({ _id }) => _id) || [];
for await (const fileId of fileIds) {
if (fileId) {
await store.deleteById(fileId);
}
}
if (file?._id && !fileIds.includes(file._id)) {
await store.deleteById(file._id);
}
}
await Messages.removeByUserId(userId);
await ReadReceipts.removeByUserId(userId);
await ReadReceiptsArchive.removeByUserId(userId);
await ModerationReports.hideMessageReportsByUserId(
userId,
deletedBy || userId,
deletedBy === userId ? 'user deleted own account' : 'user account deleted',
'DELETE_USER',
);
break;
case 'Unlink':
userToReplaceWhenUnlinking = await Users.findOneById('rocket.cat');
if (!userToReplaceWhenUnlinking?._id || !userToReplaceWhenUnlinking?.username) {
break;
}
await Messages.unlinkUserId(userId, userToReplaceWhenUnlinking?._id, userToReplaceWhenUnlinking?.username, nameAlias);
break;
}
await Rooms.updateGroupDMsRemovingUsernamesByUsername(user.username, userId); // Remove direct rooms with the user
await Rooms.removeDirectRoomContainingUsername(user.username); // Remove direct rooms with the user
const rids = subscribedRooms.map((room) => room.rid);
void notifyOnRoomChangedById(rids);
await Subscriptions.removeByUserId(userId);
// Remove user as livechat agent
if (user.roles.includes('livechat-agent')) {
const departmentAgents = await LivechatDepartmentAgents.findByAgentId(userId).toArray();
const { deletedCount } = await LivechatDepartmentAgents.removeByAgentId(userId);
if (deletedCount > 0) {
departmentAgents.forEach((depAgent) => {
void notifyOnLivechatDepartmentAgentChanged(
{
_id: depAgent._id,
agentId: userId,
departmentId: depAgent.departmentId,
},
'removed',
);
});
}
}
if (user.roles.includes('livechat-monitor')) {
// Remove user as Unit Monitor
await LivechatUnitMonitors.removeByMonitorId(userId);
}
// This is for compatibility. Since we allowed any user to be contact manager b4, we need to have the same logic
// for deletion.
await LivechatVisitors.removeContactManagerByUsername(user.username);
// removes user's avatar
if (user.avatarOrigin === 'upload' || user.avatarOrigin === 'url' || user.avatarOrigin === 'rest') {
await FileUpload.getStore('Avatars').deleteByName(user.username);
}
// Disables all the integrations which rely on the user being deleted.
await Integrations.disableByUserId(userId);
void notifyOnIntegrationChangedByUserId(userId);
// Don't broadcast user.deleted for Erasure Type of 'Keep' so that messages don't disappear from logged in sessions
if (messageErasureType === 'Delete') {
void api.broadcast('user.deleted', user, {
messageErasureType,
});
}
if (messageErasureType === 'Unlink' && userToReplaceWhenUnlinking) {
void api.broadcast('user.deleted', user, {
messageErasureType,
replaceByUser: { _id: userToReplaceWhenUnlinking._id, username: userToReplaceWhenUnlinking?.username, alias: nameAlias },
});
}
}
// Remove user from users database
await Users.removeById(userId);
// App IPostUserDeleted event hook
if (deletedBy) {
await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Users.findOneById(deletedBy) });
}
// update name and fname of group direct messages
await updateGroupDMsName(user);
void notifyOnUserChange({ clientAction: 'removed', id: user._id });
await callbacks.run('afterDeleteUser', user);
return { deletedRooms };
}

View File

@@ -0,0 +1,193 @@
import type { IUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import Gravatar from 'gravatar';
import { check } from 'meteor/check';
import { ServiceConfiguration } from 'meteor/service-configuration';
import { warnGravatarDeprecation } from './gravatarDeprecation';
import { settings } from '../../settings';
const avatarProviders = {
facebook(user: IUser) {
if (user.services?.facebook?.id && settings.get('Accounts_OAuth_Facebook')) {
return {
service: 'facebook',
url: `https://graph.facebook.com/${user.services.facebook.id}/picture?type=large`,
};
}
},
google(user: IUser) {
if (
user.services?.google?.picture &&
user.services.google.picture !== 'https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg' &&
settings.get('Accounts_OAuth_Google')
) {
return {
service: 'google',
url: user.services.google.picture,
};
}
},
github(user: IUser) {
if (user.services?.github?.username && settings.get('Accounts_OAuth_Github')) {
return {
service: 'github',
url: `https://avatars.githubusercontent.com/${user.services.github.username}?s=200`,
};
}
},
linkedin(user: IUser) {
if (
user.services?.linkedin?.profilePicture?.identifiersUrl &&
user.services.linkedin.profilePicture.identifiersUrl.length > 0 &&
settings.get('Accounts_OAuth_Linkedin')
) {
const total = user.services.linkedin.profilePicture.identifiersUrl.length;
return {
service: 'linkedin',
url: user.services.linkedin.profilePicture.identifiersUrl[total - 1],
};
}
},
twitter(user: IUser) {
if (user.services?.twitter?.profile_image_url_https && settings.get('Accounts_OAuth_Twitter')) {
return {
service: 'twitter',
url: user.services.twitter.profile_image_url_https.replace(/_normal|_bigger/, ''),
};
}
},
gitlab(user: IUser) {
if (user.services?.gitlab?.avatar_url && settings.get('Accounts_OAuth_Gitlab')) {
return {
service: 'gitlab',
url: user.services.gitlab.avatar_url,
};
}
},
async customOAuth(user: IUser) {
const avatars: { service: string; url: string }[] = [];
if (!user.services) {
return avatars;
}
await Promise.all(
Object.keys(user.services).map(async (service) => {
if (!user.services) {
return;
}
if (user.services[service as keyof typeof user.services]._OAuthCustom) {
const services = await ServiceConfiguration.configurations.find({ service }, { fields: { secret: 0 } }).fetchAsync();
if (services.length > 0) {
if (user.services[service as keyof typeof user.services].avatarUrl) {
avatars.push({
service,
url: user.services[service as keyof typeof user.services].avatarUrl,
});
}
}
}
}),
);
return avatars;
},
emails(user: IUser) {
const avatars: { service: string; url: string }[] = [];
// Offline (air-gapped) licenses suppress Gravatar lookups: every suggested
// URL is fetched server-side below, and gravatar.com is not admin-configured
// infrastructure (unlike OAuth provider avatars, which keep working).
if (License.hasOfflineLicense()) {
return avatars;
}
if (user.emails && user.emails.length > 0) {
warnGravatarDeprecation();
for (const email of user.emails) {
if (email.verified === true) {
avatars.push({
service: 'gravatar',
url: Gravatar.url(email.address, {
default: '404',
size: '200',
protocol: 'https',
}),
});
}
if (email.verified !== true) {
avatars.push({
service: 'gravatar',
url: Gravatar.url(email.address, {
default: '404',
size: '200',
protocol: 'https',
}),
});
}
}
}
return avatars;
},
};
/**
* @return {Object}
*/
export async function getAvatarSuggestionForUser(
user: IUser,
): Promise<Record<string, { blob: string; contentType: string; service: string; url: string }>> {
check(user, Object);
const avatars = [];
for (const avatarProvider of Object.values(avatarProviders)) {
const avatar = await avatarProvider(user);
if (avatar) {
if (Array.isArray(avatar)) {
avatars.push(...avatar);
} else {
avatars.push(avatar);
}
}
}
const validAvatars: Record<string, { blob: string; contentType: string; service: string; url: string }> = {};
for (const avatar of avatars) {
try {
const response = await fetch(avatar.url, {
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
const newAvatar: { service: string; url: string; blob: string; contentType: string } = {
service: avatar.service,
url: avatar.url,
blob: '',
contentType: '',
};
if (response.status === 200) {
let blob = `data:${response.headers.get('content-type')};base64,`;
blob += Buffer.from(await response.arrayBuffer()).toString('base64');
newAvatar.blob = blob;
newAvatar.contentType = response.headers.get('content-type') as string;
validAvatars[avatar.service] = newAvatar;
}
} catch (error) {
// error;
}
}
return validAvatars;
}

View File

@@ -0,0 +1,158 @@
import type { IUser, IUserEmail } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { settings } from '../../settings';
import { hasPermissionAsync } from '../authorization/hasPermission';
import { getUsersHiddenFrom } from '../statusVisibility/hiddenUsers';
import { redactStatus } from '../statusVisibility/redactStatus';
import { resolveUsersByIds } from '../statusVisibility/resolveUsers';
const logger = new Logger('getFullUserData');
export const defaultFields = {
name: 1,
username: 1,
nickname: 1,
status: 1,
utcOffset: 1,
type: 1,
active: 1,
bio: 1,
reason: 1,
statusText: 1,
statusSource: 1,
statusExpiresAt: 1,
avatarETag: 1,
federated: 1,
statusLivechat: 1,
abacAttributes: 1,
freeSwitchExtension: 1,
} as const;
export const fullFields = {
emails: 1,
phone: 1,
statusConnection: 1,
bio: 1,
createdAt: 1,
lastLogin: 1,
requirePasswordChange: 1,
requirePasswordChangeReason: 1,
roles: 1,
importIds: 1,
} as const;
let publicCustomFields: Record<string, 0 | 1> = {};
let customFields: Record<string, 0 | 1> = {};
settings.watch<string>('Accounts_CustomFields', (settingValue) => {
publicCustomFields = {};
customFields = {};
const value = settingValue?.trim();
if (!value) {
return;
}
try {
const customFieldsOnServer = JSON.parse(value);
Object.keys(customFieldsOnServer).forEach((key) => {
const element = customFieldsOnServer[key];
if (element.public) {
publicCustomFields[`customFields.${key}`] = 1;
}
customFields[`customFields.${key}`] = 1;
});
} catch (e) {
logger.warn({
msg: 'The JSON specified for "Accounts_CustomFields" is invalid. The following error was thrown',
err: e,
});
}
});
const getCustomFields = (canViewAllInfo: boolean): Record<string, 0 | 1> => (canViewAllInfo ? customFields : publicCustomFields);
const getFields = (canViewAllInfo: boolean): Record<string, 0 | 1> => ({
...defaultFields,
...(canViewAllInfo && fullFields),
...getCustomFields(canViewAllInfo),
});
const findTargetUser = (type: string, value: string, opts: any) => {
if (type === 'importId') return Users.findOneByImportId(value, opts);
if (type === 'email') return Users.findOneByEmailAddress(value, opts);
if (type === 'freeSwitchExtension') return Users.findOneByFreeSwitchExtension(value, opts);
return Users.findOneByIdOrUsername(value, opts);
};
export async function getFullUserDataByUniqueSearchTerm(
userId: string,
searchValue: string,
searchType: 'id' | 'username' | 'importId' | 'email' | 'freeSwitchExtension',
): Promise<IUser | null> {
const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1, freeSwitchExtension: 1 } });
if (!caller) {
return null;
}
const myself =
(searchType === 'id' && searchValue === userId) ||
(searchType === 'username' && searchValue === caller.username) ||
(searchType === 'importId' && caller.importIds?.includes(searchValue)) ||
(searchType === 'freeSwitchExtension' && caller.freeSwitchExtension === searchValue) ||
(searchType === 'email' &&
caller.emails?.some((email: IUserEmail) => email.address.trim().toLowerCase() === searchValue.trim().toLowerCase()));
const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info'));
// Only search for importId/email if the user has permission to view them
if (['importId', 'email'].includes(searchType) && !canViewAllInfo) {
return null;
}
const fields = getFields(canViewAllInfo);
// When PDP type is not local, Rocket.Chat doesn't manage ABAC attributes, so there's no point in fetching them
if (settings.get('ABAC_PDP_Type') !== 'local') {
delete fields.abacAttributes;
}
const options = {
projection: {
...fields,
...(myself && {
services: 1,
...(settings.get<boolean>('Accounts_StatusVisibility_Enabled') && { 'settings.preferences.statusVisibilityDenied': 1 }),
}),
},
};
const user = await findTargetUser(searchType, searchValue, options);
if (!user) {
return null;
}
user.canViewAllInfo = canViewAllInfo;
if (user?.services?.password) {
(user.services.password as any) = true;
}
delete user?.services?.passwordHistory;
delete user?.services?.resume;
delete user?.services?.email;
const ownBlockList = myself ? user.settings?.preferences?.statusVisibilityDenied : undefined;
if (settings.get<boolean>('Accounts_StatusVisibility_Enabled') && ownBlockList?.length && user.settings?.preferences) {
user.settings.preferences.statusVisibilityDenied = (await resolveUsersByIds(ownBlockList)).usernames;
}
const hidden = await getUsersHiddenFrom(userId);
return hidden?.has(user._id) ? redactStatus(user) : user;
}

View File

@@ -0,0 +1,19 @@
import { Users } from '@rocket.chat/models';
export const getStatusText = async function (userId: string): Promise<string | undefined> {
if (!userId) {
return;
}
const projection = {
statusText: 1,
};
const options = {
projection,
limit: 1,
};
const data = await Users.findOneById(userId, options);
return data?.statusText;
};

View File

@@ -0,0 +1,13 @@
import type { UserType } from '@rocket.chat/apps-engine/definition/users';
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { FindOptions } from 'mongodb';
export async function getUserCreatedByApp(
appId: string,
type: UserType.BOT | UserType.APP,
options?: FindOptions<IUser>,
): Promise<Array<IUser>> {
const users = await Users.find({ appId, type }, options).toArray();
return users ?? [];
}

View File

@@ -0,0 +1,31 @@
import { Rooms } from '@rocket.chat/models';
import type { SubscribedRoomsForUserWithDetails } from '../rooms/getRoomsWithSingleOwner';
export const getUserSingleOwnedRooms = async function (subscribedRooms: SubscribedRoomsForUserWithDetails[]) {
const roomsThatWillChangeOwner = subscribedRooms
.filter(({ shouldChangeOwner }) => shouldChangeOwner)
.map(({ rid }: { rid: string }) => rid);
const roomsThatWillBeRemoved = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }: { rid: string }) => rid);
const roomIds = roomsThatWillBeRemoved.concat(roomsThatWillChangeOwner);
const rooms = Rooms.findByIds(roomIds, { projection: { _id: 1, name: 1, fname: 1 } });
const result = {
shouldBeRemoved: [] as string[],
shouldChangeOwner: [] as string[],
};
await rooms.forEach((room) => {
const name = room.fname || room.name;
if (roomsThatWillBeRemoved.includes(room._id)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result.shouldBeRemoved.push(name!);
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result.shouldChangeOwner.push(name!);
}
});
return result;
};

View File

@@ -0,0 +1,79 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import limax from 'limax';
import { settings } from '../../settings';
function slug(text: string): string {
return limax(text, { replacement: '.' }).replace(/[^0-9a-z-_.]/g, '');
}
async function usernameIsAvailable(username: string): Promise<boolean> {
if (username.length === 0) {
return false;
}
if (username === 'all') {
return false;
}
return !(await Users.findOneByUsernameIgnoringCase(username, {}));
}
const name = (username: string): string => (settings.get('UTF8_Names_Slugify') ? slug(username) : username);
export async function generateUsernameSuggestion(user: Pick<IUser, 'name' | 'emails' | 'services'>): Promise<string | undefined> {
let usernames = [];
if (user.name) {
usernames.push(name(user.name));
const nameParts = user.name.split(' ');
if (nameParts.length > 1) {
const [first] = nameParts;
const last = nameParts[nameParts.length - 1];
usernames.push(name(first[0] + last));
usernames.push(name(first + last[0]));
}
}
if (user?.name) {
usernames.push(name(user.name));
}
if (Array.isArray(user.services)) {
const services = [
...new Set(user.services.flatMap(({ name, username, firstName, lastName }) => [name, username, firstName, lastName])),
];
usernames.push(...services.map(name));
}
if (user.emails && user.emails.length > 0) {
for (const email of user.emails) {
if (email.address && email.verified === true) {
usernames.push(slug(email.address.replace(/@.+$/, '')));
usernames.push(slug(email.address.replace(/(.+)@(\w+).+/, '$1.$2')));
}
}
}
usernames = usernames.filter((e) => e);
for (const item of usernames) {
if (await usernameIsAvailable(item)) {
return item;
}
}
usernames.push(settings.get('Accounts_DefaultUsernamePrefixSuggestion'));
let index = await Users.countDocuments({ username: new RegExp(`^${usernames[0]}-[0-9]+`) });
const username = '';
while (!username) {
if (await usernameIsAvailable(`${usernames[0]}-${index}`)) {
return `${usernames[0]}-${index}`;
}
index++;
}
}

View File

@@ -0,0 +1,23 @@
// Removal checklist (next major): gravatar branch in saveNewUser.ts, `emails`
// provider in getAvatarSuggestionForUser.ts, the `service !== 'gravatar'`
// filters in setUsername.ts and auth/startup.js, the `gravatar` and
// `@types/gravatar` dependencies, and the Accounts_SetDefaultAvatar
// deprecation alert (setting registration + i18n key). Existing user documents
// keep
// `avatarOrigin: 'gravatar'` (and their stored Avatars file) — harmless, no
// migration needed; avatarOrigin is only compared against upload/url/rest.
import { SystemLogger } from '../logger/system';
let warned = false;
export function warnGravatarDeprecation(): void {
if (warned) {
return;
}
warned = true;
SystemLogger.warn(
'Gravatar integration (default avatars and avatar suggestions based on the user e-mail address) is deprecated and will be removed in a future major release.',
);
}

View File

@@ -0,0 +1,31 @@
import type { IRole, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { addUserRolesAsync } from '../roles/addUserRoles';
import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles';
const rolesToChangeTo: Map<IRole['_id'], [IRole['_id']]> = new Map([['anonymous', ['user']]]);
export const runAfterVerifyEmail = async (userId: IUser['_id']): Promise<void> => {
const user = await Users.findOneById(userId);
if (!user?.emails || !Array.isArray(user.emails)) {
return;
}
const verifiedEmail = user.emails.find((email) => email.verified);
if (!verifiedEmail) {
return;
}
const rolesThatNeedChanges = user.roles.filter((role) => rolesToChangeTo.has(role));
await Promise.all(
rolesThatNeedChanges.map(async (role) => {
const rolesToAdd = rolesToChangeTo.get(role);
if (rolesToAdd) {
await addUserRolesAsync(userId, rolesToAdd);
}
await removeUserFromRolesAsync(user._id, [role]);
}),
);
};

View File

@@ -0,0 +1,21 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';
import { saveCustomFieldsWithoutValidation } from './saveCustomFieldsWithoutValidation';
import { validateCustomFields } from './validateCustomFields';
import { trim } from '../../../lib/utils/stringUtils';
import { settings } from '../../settings';
export const saveCustomFields = async function (
userId: string,
formData: Record<string, any>,
options?: { _updater?: Updater<IUser>; session?: ClientSession },
): Promise<void> {
if (trim(settings.get('Accounts_CustomFields')).length === 0) {
return;
}
validateCustomFields(formData);
return saveCustomFieldsWithoutValidation(userId, formData, options);
};

View File

@@ -0,0 +1,72 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Subscriptions, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';
import { trim } from '../../../lib/utils/stringUtils';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
import { settings } from '../../settings';
import { notifyOnSubscriptionChangedByUserIdAndRoomType } from '../notifyListener';
const getCustomFieldsMeta = function (customFieldsMeta: string) {
try {
return JSON.parse(customFieldsMeta);
} catch (e) {
throw new Meteor.Error('error-invalid-customfield-json', 'Invalid JSON for Custom Fields');
}
};
export const saveCustomFieldsWithoutValidation = async function (
userId: string,
formData: Record<string, any>,
options?: {
_updater?: Updater<IUser>;
session?: ClientSession;
},
): Promise<void> {
const customFieldsSetting = settings.get<string>('Accounts_CustomFields');
if (!customFieldsSetting || trim(customFieldsSetting).length === 0) {
return;
}
// configured custom fields in setting
const customFieldsMeta = getCustomFieldsMeta(customFieldsSetting);
const customFields = Object.fromEntries(
Object.keys(formData)
.filter((key) => Object.hasOwn(customFieldsMeta, key))
.map((key) => [key, formData[key]]),
);
const { _updater, session } = options || {};
const updater = _updater || Users.getUpdater();
// add modified records to updater
Object.keys(customFields).forEach((fieldName) => {
// @ts-expect-error TODO `Updater.set` does not support `customFields.${fieldName}` syntax
updater.set(`customFields.${fieldName}`, customFields[fieldName]);
if (!customFieldsMeta[fieldName].modifyRecordField) {
return;
}
const { modifyRecordField } = customFieldsMeta[fieldName];
if (modifyRecordField.array) {
updater.addToSet(modifyRecordField.field, customFields[fieldName]);
} else {
updater.set(modifyRecordField.field, customFields[fieldName]);
}
});
if (!_updater) {
await Users.updateFromUpdater({ _id: userId }, updater, { session });
}
await onceTransactionCommitedSuccessfully(async () => {
const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields);
if (setCustomFieldsResponse.modifiedCount) {
void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd');
}
}, session);
};

View 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');
}
};

View 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');
}
};

View File

@@ -0,0 +1,2 @@
export { saveUser } from './saveUser';
export { validateUserEditing } from './validateUserEditing';

View 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;
};

View 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);
};
})();

View 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);
}

View 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');
}
}

View 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,
});
}
}
});

View 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',
});
}
}

View File

@@ -0,0 +1,188 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users, CallHistory } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';
import { setRealName } from './setRealName';
import { _setUsername } from './setUsername';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
import { SystemLogger } from '../logger/system';
import { FileUpload } from '../media/file-upload';
import {
notifyOnRoomChangedByUsernamesOrUids,
notifyOnSubscriptionChangedByUserId,
notifyOnSubscriptionChangedByNameAndRoomType,
} from '../notifyListener';
import { updateGroupDMsName } from '../rooms/updateGroupDMsName';
import { validateName } from '../shared/validateName';
/**
*
* @param {object} changes changes to the user
*/
export async function saveUserIdentity({
_id,
name: rawName,
username: rawUsername,
updateUsernameInBackground = false,
updater,
session,
}: {
_id: string;
name?: string;
username?: string;
updateUsernameInBackground?: boolean; // TODO: remove this
updater?: Updater<IUser>;
session?: ClientSession;
}) {
if (!_id) {
return false;
}
const name = String(rawName).trim();
const username = String(rawUsername).trim();
const user = await Users.findOneById(_id, { session });
if (!user) {
return false;
}
const previousUsername = user.username;
const previousName = user.name;
const nameChanged = previousName !== name;
const usernameChanged = previousUsername !== username;
if (typeof rawUsername !== 'undefined' && usernameChanged) {
if (!validateName(username)) {
return false;
}
if (!(await _setUsername(_id, username, user, updater, session))) {
return false;
}
user.username = username;
}
if (typeof rawName !== 'undefined' && nameChanged) {
if (!(await setRealName(_id, name, user, updater, session))) {
return false;
}
}
const updateReferences = async () => {
if (previousUsername) {
const handleUpdateParams = {
username,
previousUsername,
rawUsername,
usernameChanged,
user,
name,
previousName,
rawName,
nameChanged,
};
if (updateUsernameInBackground) {
setImmediate(async () => {
try {
await updateUsernameReferences(handleUpdateParams);
} catch (err) {
SystemLogger.error({ err });
}
});
} else {
await updateUsernameReferences(handleUpdateParams);
}
}
};
await onceTransactionCommitedSuccessfully(updateReferences, session);
return true;
}
async function updateUsernameReferences({
username,
previousUsername,
rawUsername,
usernameChanged,
user,
name,
previousName,
rawName,
nameChanged,
}: {
username: string;
previousUsername: string;
rawUsername?: string;
usernameChanged: boolean;
user: IUser;
name: string;
previousName: string | undefined;
rawName?: string;
nameChanged: boolean;
}): Promise<void> {
if (usernameChanged && typeof rawUsername !== 'undefined') {
const fileStore = FileUpload.getStore('Avatars');
const previousFile = await fileStore.model.findOneByName(previousUsername);
const file = await fileStore.model.findOneByName(username);
if (file) {
await fileStore.model.deleteFile(file._id);
}
if (previousFile) {
await fileStore.model.updateFileNameById(previousFile._id, username);
}
await Messages.updateAllUsernamesByUserId(user._id, username);
await Messages.updateUsernameOfEditByUserId(user._id, username);
const cursor = Messages.findByMention(previousUsername);
for await (const msg of cursor) {
const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`);
await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg);
}
const responses = await Promise.all([
Rooms.replaceUsername(previousUsername, username),
Rooms.replaceMutedUsername(previousUsername, username),
Rooms.replaceUsernameOfUserByUserId(user._id, username),
Subscriptions.setUserUsernameByUserId(user._id, username),
LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username),
]);
if (responses[3]?.modifiedCount) {
void notifyOnSubscriptionChangedByUserId(user._id);
}
if (responses[0]?.modifiedCount || responses[1]?.modifiedCount || responses[2]?.modifiedCount) {
void notifyOnRoomChangedByUsernamesOrUids([user._id], [previousUsername, username]);
}
}
// update other references if either the name or username has changed
if (usernameChanged || nameChanged) {
// update name and fname of 1-on-1 direct messages
const updateDirectNameResponse = await Subscriptions.updateDirectNameAndFnameByName(
previousUsername,
rawUsername && username,
rawName && name,
);
if (updateDirectNameResponse?.modifiedCount) {
void notifyOnSubscriptionChangedByNameAndRoomType({
t: 'd',
name: username,
});
}
// update name and fname of group direct messages
await updateGroupDMsName(user);
// update name and username of users on video conferences
await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName);
// update name and username of users on call history
await CallHistory.updateUserReferences(user._id, username || previousUsername, name || previousName);
}
}

View File

@@ -0,0 +1,106 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Users } from '@rocket.chat/models';
import { escapeHTML } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';
import { checkEmailAvailability } from './checkEmailAvailability';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
import { sendConfirmationEmail } from '../../meteor-methods/auth/sendConfirmationEmail';
import { settings } from '../../settings';
import * as Mailer from '../notifications/email/api';
import { validateEmailDomain } from '../validateEmailDomain';
let html = '';
Meteor.startup(() => {
Mailer.getTemplate('Email_Changed_Email', (template) => {
html = template;
});
});
const _sendEmailChangeNotification = async function (to: string, newEmail: string) {
const subject = String(settings.get('Email_Changed_Email_Subject'));
const email = {
to,
from: String(settings.get('From_Email')),
subject,
html,
data: {
email: escapeHTML(newEmail),
},
};
try {
await Mailer.send(email);
} catch (error: any) {
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, {
function: 'setEmail',
message: error.message,
});
}
};
export const setEmail = async function (
userId: string,
email: string,
shouldSendVerificationEmail = true,
verified = false,
updater?: Updater<IUser>,
session?: ClientSession,
) {
email = email.trim();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: '_setEmail' });
}
if (!email) {
throw new Meteor.Error('error-invalid-email', 'Invalid email', { function: '_setEmail' });
}
await validateEmailDomain(email);
const user = await Users.findOneById(userId, { session });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: '_setEmail' });
}
// User already has desired username, return
if (user?.emails?.[0] && user.emails[0].address === email) {
return user;
}
// Check email availability
if (!(await checkEmailAvailability(email))) {
throw new Meteor.Error('error-field-unavailable', `${email} is already in use :(`, {
function: '_setEmail',
field: email,
});
}
const oldEmail = user?.emails?.[0];
if (oldEmail) {
await onceTransactionCommitedSuccessfully(async () => {
await _sendEmailChangeNotification(oldEmail.address, email);
}, session);
}
// Set new email
if (updater) {
updater.set('emails', [{ address: email, verified }]);
} else {
await Users.setEmail(user?._id, email, verified, { session });
}
const result = {
...user,
email,
};
if (shouldSendVerificationEmail === true) {
await onceTransactionCommitedSuccessfully(async () => {
await sendConfirmationEmail(result.email);
}, session);
}
return result;
};

View File

@@ -0,0 +1,64 @@
import { api } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Users } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
import { settings } from '../../settings';
export const setRealName = async function (
userId: string,
name: string,
fullUser?: IUser,
updater?: Updater<IUser>,
session?: ClientSession,
): Promise<IUser | undefined> {
name = name.trim();
if (!userId || (settings.get('Accounts_RequireNameForSignUp') && !name)) {
return;
}
const user = fullUser || (await Users.findOneById<IUser>(userId, { session }));
if (!user) {
return;
}
// User already has desired name, return
if (user.name && user.name.trim() === name) {
return user;
}
// Set new name
if (name) {
if (updater) {
updater.set('name', name);
} else {
await Users.setName(user._id, name, { session });
}
} else if (updater) {
updater.unset('name');
} else {
await Users.unsetName(user._id, { session });
}
user.name = name;
await onceTransactionCommitedSuccessfully(() => {
if (settings.get('UI_Use_Real_Name') === true) {
void api.broadcast('user.nameChanged', {
_id: user._id,
name: user.name,
username: user.username,
});
}
void api.broadcast('user.realNameChanged', {
_id: user._id,
name,
username: user.username,
});
}, session);
return user;
};

View File

@@ -0,0 +1,45 @@
import { api } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Users } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
export async function setStatusText(
user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'statusText'>,
statusText: string,
{
updater,
session,
emit = true,
}: {
updater?: Updater<IUser>;
session?: ClientSession;
emit?: boolean;
} = {},
): Promise<boolean> {
statusText = statusText.trim().substr(0, 120);
if (user.statusText === statusText) {
return true;
}
if (updater) {
updater.set('statusText', statusText);
} else {
await Users.updateStatusText(user._id, statusText, { session });
}
if (emit) {
const { _id, username, status, name, roles } = user;
await onceTransactionCommitedSuccessfully(() => {
void api.broadcast('presence.status', {
user: { _id, username, status, statusText, name, roles },
previousStatus: status,
});
}, session);
}
return true;
}

View File

@@ -0,0 +1,169 @@
import type { IUserEmail } from '@rocket.chat/core-typings';
import { isUserFederated, isDirectMessageRoom } from '@rocket.chat/core-typings';
import { Rooms, Users, Subscriptions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import * as Mailer from '../notifications/email/api';
import {
notifyOnRoomChangedById,
notifyOnRoomChangedByUserDM,
notifyOnSubscriptionChangedByNameAndRoomType,
notifyOnUserChange,
} from '../notifyListener';
import { closeOmnichannelConversations } from '../omnichannel/closeOmnichannelConversations';
import { shouldRemoveOrChangeOwner, getSubscribedRoomsForUserWithDetails } from '../rooms/getRoomsWithSingleOwner';
import { relinquishRoomOwnerships } from '../rooms/relinquishRoomOwnerships';
async function reactivateDirectConversations(userId: string) {
// since both users can be deactivated at the same time, we should just reactivate rooms if both users are active
// for that, we need to fetch the direct messages, fetch the users involved and then the ids of rooms we can reactivate
const directConversations = await Rooms.getDirectConversationsByUserId(userId, {
projection: { _id: 1, uids: 1, t: 1 },
}).toArray();
const userIds = directConversations.reduce<string[]>((acc: string[], r) => {
if (isDirectMessageRoom(r)) {
acc.push(...r.uids);
}
return acc;
}, []);
const uniqueUserIds = [...new Set(userIds)];
const activeUsers = await Users.findActiveByUserIds(uniqueUserIds, { projection: { _id: 1 } }).toArray();
const activeUserIds = activeUsers.map((u) => u._id);
const roomsToReactivate = directConversations.reduce((acc: string[], room) => {
const otherUserId = isDirectMessageRoom(room) ? room.uids.find((u: string) => u !== userId) : undefined;
if (otherUserId && activeUserIds.includes(otherUserId)) {
acc.push(room._id);
}
return acc;
}, []);
const setDmReadOnlyResponse = await Rooms.setDmReadOnlyByUserId(userId, roomsToReactivate, false, false);
if (setDmReadOnlyResponse.modifiedCount) {
void notifyOnRoomChangedById(roomsToReactivate);
}
}
export async function setUserActiveStatus(
userId: string,
active: boolean,
confirmRelinquish = false,
executedBy?: string,
): Promise<boolean | undefined> {
check(userId, String);
check(active, Boolean);
const user = await Users.findOneById(userId);
if (!user) {
return false;
}
if (isUserFederated(user)) {
throw new Meteor.Error('error-user-is-federated', 'Cannot change federated users status', {
method: 'setUserActiveStatus',
});
}
// Users without username can't do anything, so there is no need to check for owned rooms
if (user.username != null && !active) {
const userAdmin = await Users.findOneAdmin(userId || '');
const adminsCount = await Users.countActiveUsersInRoles(['admin']);
if (userAdmin && adminsCount === 1) {
throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without an active admin is not allowed', {
method: 'removeUserFromRole',
action: 'Remove_last_admin',
});
}
const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId);
// give omnichannel rooms a special treatment :)
const chatSubscribedRooms = subscribedRooms.filter(({ t }) => t !== 'l');
const livechatSubscribedRooms = subscribedRooms.filter(({ t }) => t === 'l');
if (shouldRemoveOrChangeOwner(chatSubscribedRooms) && !confirmRelinquish) {
const rooms = await getUserSingleOwnedRooms(chatSubscribedRooms as []);
throw new Meteor.Error('user-last-owner', '', rooms);
}
// We don't want one killing the other :)
await Promise.allSettled([
closeOmnichannelConversations(user, livechatSubscribedRooms, executedBy),
relinquishRoomOwnerships(user._id, chatSubscribedRooms, false),
]);
}
if (active && !user.active) {
await callbacks.run('beforeActivateUser', user);
}
await Users.setUserActive(userId, active);
if (active && !user.active) {
await callbacks.run('afterActivateUser', user);
}
if (!active && user.active) {
await callbacks.run('afterDeactivateUser', user);
}
if (user.username) {
const { modifiedCount } = await Subscriptions.setArchivedForDMsWithUsername(user.username, !active);
if (modifiedCount) {
void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username });
}
}
if (active === false) {
await Users.unsetLoginTokens(userId);
await Promise.all([
OAuthAccessTokens.deleteByUserId(userId),
OAuthRefreshTokens.deleteByUserId(userId),
OAuthAuthCodes.deleteByUserId(userId),
]);
await Rooms.setDmReadOnlyByUserId(userId, undefined, true, false);
void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { 'services.resume.loginTokens': [], active } });
void notifyOnRoomChangedByUserDM(userId);
} else {
await Users.unsetReason(userId);
void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { active } });
await reactivateDirectConversations(userId);
}
if (active && !settings.get('Accounts_Send_Email_When_Activating')) {
return true;
}
if (!active && !settings.get('Accounts_Send_Email_When_Deactivating')) {
return true;
}
if (!user.emails || !Array.isArray(user.emails) || user.emails.length === 0) {
return true;
}
const destinations = user.emails.map((email: IUserEmail) => `${user.name || user.username}<${email.address}>`);
type UserActivated = {
subject: (params: { active: boolean }) => string;
html: (params: { active: boolean; name: string; username: string }) => string;
};
const { subject, html } = (Accounts.emailTemplates as unknown as { userActivated: UserActivated }).userActivated;
const email = {
to: String(destinations),
from: String(settings.get('From_Email')),
subject: subject({ active } as any),
html: html({
active,
name: user.name,
username: user.username,
} as any),
};
void Mailer.sendNoWrap(email);
}

View File

@@ -0,0 +1,218 @@
import { api } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Users } from '@rocket.chat/models';
import type { Response } from '@rocket.chat/server-fetch';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';
import { isRenderableImageType } from '../../../lib/renderableImageTypes';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
import { settings } from '../../settings';
import { hasPermissionAsync } from '../authorization/hasPermission';
import { SystemLogger } from '../logger/system';
import { RocketChatFile } from '../media/file';
import { FileUpload } from '../media/file-upload';
export const setAvatarFromServiceWithValidation = async (
userId: string,
dataURI: string,
contentType?: string,
service?: string,
targetUserId?: string,
): Promise<void> => {
if (!dataURI) {
throw new Meteor.Error('error-invalid-data', 'Invalid dataURI', {
method: 'setAvatarFromService',
});
}
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'setAvatarFromService',
});
}
if (!settings.get('Accounts_AllowUserAvatarChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'setAvatarFromService',
});
}
let user: Pick<IUser, '_id' | 'username'> | null;
if (targetUserId && targetUserId !== userId) {
if (!(await hasPermissionAsync(userId, 'edit-other-user-avatar'))) {
throw new Meteor.Error('error-unauthorized', 'Unauthorized', {
method: 'setAvatarFromService',
});
}
user = await Users.findOneById(targetUserId, { projection: { _id: 1, username: 1 } });
} else {
user = await Users.findOneById(userId, { projection: { _id: 1, username: 1 } });
}
if (!user) {
throw new Meteor.Error('error-invalid-desired-user', 'Invalid desired user', {
method: 'setAvatarFromService',
});
}
return setUserAvatar(user, dataURI, contentType, service);
};
export function setUserAvatar(
user: Pick<IUser, '_id' | 'username'>,
dataURI: Buffer,
contentType: string,
service: 'rest',
etag?: string,
updater?: Updater<IUser>,
session?: ClientSession,
): Promise<void>;
export function setUserAvatar(
user: Pick<IUser, '_id' | 'username'>,
dataURI: string,
contentType?: string,
service?: 'initials' | 'url' | 'rest' | string,
etag?: string,
updater?: Updater<IUser>,
session?: ClientSession,
): Promise<void>;
export async function setUserAvatar(
user: Pick<IUser, '_id' | 'username'>,
dataURI: string | Buffer,
contentType: string | undefined,
service?: 'initials' | 'url' | 'rest' | string,
etag?: string,
updater?: Updater<IUser>,
session?: ClientSession,
): Promise<void> {
if (service === 'initials') {
if (updater) {
updater.set('avatarOrigin', service);
} else {
await Users.setAvatarData(user._id, service, null, { session });
}
return;
}
const { buffer, type } = await (async (): Promise<{ buffer: Buffer; type: string }> => {
if (service === 'url' && typeof dataURI === 'string') {
let response: Response;
try {
response = await fetch(dataURI, {
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
} catch (e) {
SystemLogger.info({
msg: 'Not a valid response from the avatar url',
url: dataURI,
err: e,
});
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, {
function: 'setUserAvatar',
url: dataURI,
});
}
if (response.status !== 200) {
if (response.status !== 404) {
SystemLogger.info({
msg: 'Error while handling the setting of the avatar from a url',
url: dataURI,
username: user.username,
status: response.status,
});
throw new Meteor.Error(
'error-avatar-url-handling',
`Error while handling avatar setting from a URL (${dataURI}) for ${user.username}`,
{ function: 'RocketChat.setUserAvatar', url: dataURI, username: user.username },
);
}
SystemLogger.info({
msg: 'Not a valid response from the avatar url',
status: response.status,
url: dataURI,
});
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, {
function: 'setUserAvatar',
url: dataURI,
});
}
const contentType = response.headers.get('content-type');
if (!isRenderableImageType(contentType)) {
SystemLogger.info({
msg: 'Not a valid content-type from the provided avatar url',
contentType,
url: dataURI,
});
throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, {
function: 'setUserAvatar',
url: dataURI,
});
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type') || '',
};
}
if (service === 'rest') {
if (!contentType) {
throw new Meteor.Error('error-avatar-invalid-content-type', 'Invalid avatar content type', {
function: 'setUserAvatar',
});
}
return {
buffer: typeof dataURI === 'string' ? Buffer.from(dataURI, 'binary') : dataURI,
type: contentType,
};
}
const fileData = RocketChatFile.dataURIParse(dataURI);
return {
buffer: Buffer.from(fileData.image, 'base64'),
type: fileData.contentType,
};
})();
const fileStore = FileUpload.getStore('Avatars');
user.username && (await fileStore.deleteByName(user.username, { session }));
const file = {
userId: user._id,
type,
size: buffer.length,
};
const result = await fileStore.insert(file, buffer, { session });
const avatarETag = etag || result?.etag || '';
if (service) {
if (updater) {
updater.set('avatarOrigin', service);
updater.set('avatarETag', avatarETag);
} else {
// TODO: Why was this timeout added?
setTimeout(async () => Users.setAvatarData(user._id, service, avatarETag, { session }), 500);
}
await onceTransactionCommitedSuccessfully(async () => {
void api.broadcast('user.avatarUpdate', {
username: user.username,
avatarETag,
});
}, session);
}
}

View File

@@ -0,0 +1,171 @@
import { api } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { isUserNativeFederated } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Invites, Users, Subscriptions } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';
import _ from 'underscore';
import { checkUsernameAvailability } from './checkUsernameAvailability';
import { getAvatarSuggestionForUser } from './getAvatarSuggestionForUser';
import { saveUserIdentity } from './saveUserIdentity';
import { setUserAvatar } from './setUserAvatar';
import { validateUsername } from './validateUsername';
import { onceTransactionCommitedSuccessfully } from '../../database/utils';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { SystemLogger } from '../logger/system';
import { notifyOnUserChange } from '../notifyListener';
import { addUserToRoom } from '../rooms/addUserToRoom';
import { joinDefaultChannels } from '../rooms/joinDefaultChannels';
const isUserInFederatedRooms = async (userId: string): Promise<boolean> => {
const cursor = Subscriptions.findUserFederatedRoomIds(userId);
const hasAny = await cursor.hasNext();
await cursor.close();
return hasAny;
};
export const setUsernameWithValidation = async (userId: string, username: string): Promise<void> => {
if (!username) {
throw new Meteor.Error('error-invalid-username', 'Invalid username', { method: 'setUsername' });
}
const user = await Users.findOneById(userId);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUsername' });
}
if (isUserNativeFederated(user) || (await isUserInFederatedRooms(userId))) {
throw new Meteor.Error('error-not-allowed', 'Cannot change username for federated users or users in federated rooms', {
method: 'setUsername',
});
}
if (user.username && !settings.get('Accounts_AllowUsernameChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed');
}
if (user.username === username || (user.username && user.username.toLowerCase() === username.toLowerCase())) {
return;
}
if (!validateUsername(username)) {
throw new Meteor.Error(
'username-invalid',
`${_.escape(username)} is not a valid username, use only letters, numbers, dots, hyphens and underscores`,
);
}
if (!(await checkUsernameAvailability(username))) {
throw new Meteor.Error('error-field-unavailable', `<strong>${_.escape(username)}</strong> is already in use :(`, {
method: 'setUsername',
field: username,
});
}
if (!(await saveUserIdentity({ _id: user._id, username }))) {
throw new Meteor.Error('error-could-not-change-username', 'Could not change username', {
method: 'setUsername',
});
}
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { username } });
};
export const _setUsername = async function (
userId: string,
u: string,
fullUser: IUser,
updater?: Updater<IUser>,
session?: ClientSession,
): Promise<unknown> {
const username = u.trim();
if (!userId || !username) {
return false;
}
if (!validateUsername(username)) {
return false;
}
if (isUserNativeFederated(fullUser) || (await isUserInFederatedRooms(userId))) {
throw new Meteor.Error('error-not-allowed', 'Cannot change username for federated users or users in federated rooms', {
method: 'setUsername',
});
}
const user = fullUser || (await Users.findOneById(userId, { session }));
// User already has desired username, return
if (user.username === username) {
return user;
}
const previousUsername = user.username;
// Check username availability or if the user already owns a different casing of the name
if (!previousUsername || !(username.toLowerCase() === previousUsername.toLowerCase())) {
if (!(await checkUsernameAvailability(username))) {
return false;
}
}
// If first time setting username, send Enrollment Email
if (!previousUsername && user.emails && user.emails.length > 0 && settings.get('Accounts_Enrollment_Email')) {
await onceTransactionCommitedSuccessfully(() => {
try {
setImmediate(() => {
Accounts.sendEnrollmentEmail(user._id);
});
} catch (err: any) {
SystemLogger.error({ err });
}
}, session);
}
// Set new username*
// TODO: use updater for setting the username and handle possible side effects in addUserToRoom
await Users.setUsername(user._id, username, { session });
user.username = username;
if (!previousUsername && settings.get('Accounts_SetDefaultAvatar') === true) {
const avatarSuggestions = await getAvatarSuggestionForUser(user);
let avatarData;
let serviceName = 'gravatar';
for (const service of Object.keys(avatarSuggestions)) {
avatarData = avatarSuggestions[service];
if (service !== 'gravatar') {
serviceName = service;
break;
}
}
if (avatarData) {
await setUserAvatar(user, avatarData.blob, avatarData.contentType, serviceName, undefined, updater, session);
}
}
await onceTransactionCommitedSuccessfully(async () => {
if (!previousUsername) {
await joinDefaultChannels(user._id);
callbacks.runAsync('afterCreateUser', user);
}
// If it's the first username and the user has an invite Token, then join the invite room
if (!previousUsername && user.inviteToken) {
const inviteData = await Invites.findOneById(user.inviteToken);
if (inviteData?.rid) {
await addUserToRoom(inviteData.rid, user);
}
}
void api.broadcast('user.nameChanged', {
_id: user._id,
name: user.name,
username: user.username,
});
}, session);
return user;
};

View File

@@ -0,0 +1,40 @@
import { Rooms, Subscriptions } from '@rocket.chat/models';
const BATCH_SIZE = 100_000;
async function getArchivedRoomIds(rids: string[]): Promise<Set<string>> {
const archivedRoomIds = new Set<string>();
for (let i = 0; i < rids.length; i += BATCH_SIZE) {
const batch = await Rooms.findManyArchivedByRoomIds(rids.slice(i, i + BATCH_SIZE), { projection: { _id: 1 } }).toArray();
for (const r of batch) {
archivedRoomIds.add(r._id);
}
}
return archivedRoomIds;
}
async function unarchiveSubscriptionsByIds(ids: string[]): Promise<void> {
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
await Subscriptions.unarchiveByIds(ids.slice(i, i + BATCH_SIZE));
}
}
export const unarchiveUserSubscriptions = async (userId: string): Promise<boolean> => {
const archivedSubs = await Subscriptions.findArchivedByUserId(userId, { projection: { rid: 1 } }).toArray();
if (!archivedSubs.length) {
return false;
}
const archivedRoomIds = await getArchivedRoomIds(archivedSubs.map((s) => s.rid));
const idsToUnarchive = archivedSubs.filter((s) => !archivedRoomIds.has(s.rid)).map((s) => s._id);
if (!idsToUnarchive.length) {
return false;
}
await unarchiveSubscriptionsByIds(idsToUnarchive);
return true;
};

View File

@@ -0,0 +1,23 @@
import { Subscriptions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../notifyListener';
export const unblockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise<void> => {
const [blockedUser, blockerUser] = await Promise.all([
Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }),
Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }),
]);
if (!blockedUser || !blockerUser) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'unblockUser' });
}
const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId);
const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])];
if (listenerUsers.length) {
void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers);
}
};

View File

@@ -0,0 +1,48 @@
import { Meteor } from 'meteor/meteor';
import { trim } from '../../../lib/utils/stringUtils';
import { settings } from '../../settings';
export const validateCustomFields = function (fields) {
// Special Case:
// If an admin didn't set any custom fields there's nothing to validate against so consider any customFields valid
if (trim(settings.get('Accounts_CustomFields')) === '') {
return;
}
let customFieldsMeta;
try {
customFieldsMeta = JSON.parse(settings.get('Accounts_CustomFields'));
} catch (e) {
throw new Meteor.Error('error-invalid-customfield-json', 'Invalid JSON for Custom Fields');
}
const customFields = {};
Object.keys(customFieldsMeta).forEach((fieldName) => {
const field = customFieldsMeta[fieldName];
customFields[fieldName] = fields[fieldName];
const fieldValue = trim(fields[fieldName]);
if (field.required && fieldValue === '') {
throw new Meteor.Error('error-user-registration-custom-field', `Field ${fieldName} is required`, { method: 'registerUser' });
}
if (field.type === 'select' && field.options.indexOf(fields[fieldName]) === -1) {
throw new Meteor.Error('error-user-registration-custom-field', `Value for field ${fieldName} is invalid`, { method: 'registerUser' });
}
if (field.maxLength && fieldValue.length > field.maxLength) {
throw new Meteor.Error('error-user-registration-custom-field', `Max length of field ${fieldName} ${field.maxLength}`, {
method: 'registerUser',
});
}
if (field.minLength && fieldValue.length > 0 && fieldValue.length < field.minLength) {
throw new Meteor.Error('error-user-registration-custom-field', `Min length of field ${fieldName} ${field.minLength}`, {
method: 'registerUser',
});
}
});
};

View File

@@ -0,0 +1,15 @@
import { settings } from '../../settings';
export const validateUsername = (username: string): boolean => {
const settingsRegExp = settings.get('UTF8_User_Names_Validation');
const defaultPattern = /^[0-9a-zA-Z-_.]+$/;
let usernameRegExp: RegExp;
try {
usernameRegExp = settingsRegExp ? new RegExp(`^${settingsRegExp}$`) : defaultPattern;
} catch (e) {
usernameRegExp = defaultPattern;
}
return usernameRegExp.test(username);
};

View File

@@ -0,0 +1,26 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
export async function verifyEmail(user: Pick<IUser, '_id' | 'services' | 'emails'>, token: string): Promise<boolean> {
if (user.services?.email?.verificationTokens?.length === 0) {
throw new Error('error-invalid-token');
}
const tokenRecord = await user.services?.email?.verificationTokens?.find((t) => t.token === token);
if (!tokenRecord) {
throw new Error('error-invalid-token');
}
const emailsRecord = user.emails?.find((e) => e.address === tokenRecord.address);
if (!emailsRecord) {
throw new Error('error-invalid-token');
}
const result = await Users.verifyEmailByAddress(user._id, tokenRecord.address);
if (result.modifiedCount === 0) {
throw new Error('error-invalid-token');
}
return true;
}