snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
35
apps/meteor/server/lib/users/blockUser.ts
Normal file
35
apps/meteor/server/lib/users/blockUser.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
5
apps/meteor/server/lib/users/checkEmailAvailability.ts
Normal file
5
apps/meteor/server/lib/users/checkEmailAvailability.ts
Normal 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));
|
||||
};
|
||||
73
apps/meteor/server/lib/users/checkUsernameAvailability.ts
Normal file
73
apps/meteor/server/lib/users/checkUsernameAvailability.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
186
apps/meteor/server/lib/users/deleteUser.ts
Normal file
186
apps/meteor/server/lib/users/deleteUser.ts
Normal 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 };
|
||||
}
|
||||
193
apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts
Normal file
193
apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts
Normal 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;
|
||||
}
|
||||
158
apps/meteor/server/lib/users/getFullUserData.ts
Normal file
158
apps/meteor/server/lib/users/getFullUserData.ts
Normal 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;
|
||||
}
|
||||
19
apps/meteor/server/lib/users/getStatusText.ts
Normal file
19
apps/meteor/server/lib/users/getStatusText.ts
Normal 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;
|
||||
};
|
||||
13
apps/meteor/server/lib/users/getUserCreatedByApp.ts
Normal file
13
apps/meteor/server/lib/users/getUserCreatedByApp.ts
Normal 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 ?? [];
|
||||
}
|
||||
31
apps/meteor/server/lib/users/getUserSingleOwnedRooms.ts
Normal file
31
apps/meteor/server/lib/users/getUserSingleOwnedRooms.ts
Normal 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;
|
||||
};
|
||||
79
apps/meteor/server/lib/users/getUsernameSuggestion.ts
Normal file
79
apps/meteor/server/lib/users/getUsernameSuggestion.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
23
apps/meteor/server/lib/users/gravatarDeprecation.ts
Normal file
23
apps/meteor/server/lib/users/gravatarDeprecation.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
31
apps/meteor/server/lib/users/runAfterVerifyEmail.ts
Normal file
31
apps/meteor/server/lib/users/runAfterVerifyEmail.ts
Normal 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]);
|
||||
}),
|
||||
);
|
||||
};
|
||||
21
apps/meteor/server/lib/users/saveCustomFields.ts
Normal file
21
apps/meteor/server/lib/users/saveCustomFields.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
20
apps/meteor/server/lib/users/saveUser/handleBio.ts
Normal file
20
apps/meteor/server/lib/users/saveUser/handleBio.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/model-typings';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
|
||||
const MAX_BIO_LENGTH = 260;
|
||||
|
||||
export const handleBio = (userUpdater: Updater<IUser>, bio: SaveUserData['bio']) => {
|
||||
if (bio?.trim()) {
|
||||
if (bio.length > MAX_BIO_LENGTH) {
|
||||
throw new MeteorError('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, {
|
||||
method: 'saveUserProfile',
|
||||
});
|
||||
}
|
||||
userUpdater.set('bio', bio);
|
||||
} else {
|
||||
userUpdater.unset('bio');
|
||||
}
|
||||
};
|
||||
20
apps/meteor/server/lib/users/saveUser/handleNickname.ts
Normal file
20
apps/meteor/server/lib/users/saveUser/handleNickname.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/model-typings';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
|
||||
const MAX_NICKNAME_LENGTH = 120;
|
||||
|
||||
export const handleNickname = (userUpdater: Updater<IUser>, nickname: SaveUserData['nickname']) => {
|
||||
if (nickname?.trim()) {
|
||||
if (nickname.length > MAX_NICKNAME_LENGTH) {
|
||||
throw new MeteorError('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, {
|
||||
method: 'saveUserProfile',
|
||||
});
|
||||
}
|
||||
userUpdater.set('nickname', nickname);
|
||||
} else {
|
||||
userUpdater.unset('nickname');
|
||||
}
|
||||
};
|
||||
2
apps/meteor/server/lib/users/saveUser/index.ts
Normal file
2
apps/meteor/server/lib/users/saveUser/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { saveUser } from './saveUser';
|
||||
export { validateUserEditing } from './validateUserEditing';
|
||||
96
apps/meteor/server/lib/users/saveUser/saveNewUser.ts
Normal file
96
apps/meteor/server/lib/users/saveUser/saveNewUser.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import Gravatar from 'gravatar';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { notifyOnUserChangeById } from '../../notifyListener';
|
||||
import { validateEmailDomain } from '../../validateEmailDomain';
|
||||
import { warnGravatarDeprecation } from '../gravatarDeprecation';
|
||||
import { setUserAvatar } from '../setUserAvatar';
|
||||
import { handleBio } from './handleBio';
|
||||
import { handleNickname } from './handleNickname';
|
||||
import type { SaveUserData } from './saveUser';
|
||||
import { sendPasswordEmail, sendWelcomeEmail } from './sendUserEmail';
|
||||
import { getNewUserRoles } from '../../../services/user/lib/getNewUserRoles';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean, performedBy: IUser) {
|
||||
await validateEmailDomain(userData.email);
|
||||
|
||||
const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles();
|
||||
const isGuest = roles?.length === 1 && roles.includes('guest');
|
||||
|
||||
// insert user
|
||||
const createUser: Record<string, any> = {
|
||||
username: userData.username,
|
||||
password: userData.password,
|
||||
...(userData.name && { name: userData.name }),
|
||||
joinDefaultChannels: userData.joinDefaultChannels,
|
||||
isGuest,
|
||||
globalRoles: roles,
|
||||
skipNewUserRolesSetting: true,
|
||||
performedBy,
|
||||
};
|
||||
if (userData.email) {
|
||||
createUser.email = userData.email;
|
||||
}
|
||||
|
||||
const _id = await Accounts.createUserAsync(createUser);
|
||||
|
||||
const updater = Users.getUpdater();
|
||||
|
||||
updater.set('settings', userData.settings || {});
|
||||
if (typeof userData.name !== 'undefined') {
|
||||
updater.set('name', userData.name);
|
||||
}
|
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') {
|
||||
updater.set('requirePasswordChange', userData.requirePasswordChange);
|
||||
}
|
||||
|
||||
if (typeof userData.verified === 'boolean') {
|
||||
updater.set('emails.0.verified', userData.verified);
|
||||
}
|
||||
|
||||
if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== '') {
|
||||
updater.set('freeSwitchExtension', userData.freeSwitchExtension);
|
||||
}
|
||||
|
||||
handleBio(updater, userData.bio);
|
||||
handleNickname(updater, userData.nickname);
|
||||
|
||||
await Users.updateFromUpdater({ _id }, updater);
|
||||
|
||||
if (userData.sendWelcomeEmail) {
|
||||
await sendWelcomeEmail(userData);
|
||||
}
|
||||
|
||||
if (sendPassword) {
|
||||
await sendPasswordEmail(userData);
|
||||
}
|
||||
|
||||
userData._id = _id;
|
||||
|
||||
// Offline (air-gapped) licenses suppress the default Gravatar fetch — a
|
||||
// default-on outbound call the workspace must never initiate on its own.
|
||||
if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email && !License.hasOfflineLicense()) {
|
||||
warnGravatarDeprecation();
|
||||
|
||||
const gravatarUrl = Gravatar.url(userData.email, {
|
||||
default: '404',
|
||||
size: '200',
|
||||
protocol: 'https',
|
||||
});
|
||||
|
||||
try {
|
||||
await setUserAvatar({ ...userData, _id }, gravatarUrl, '', 'url');
|
||||
} catch (e) {
|
||||
// Ignore this error for now, as it not being successful isn't bad
|
||||
}
|
||||
}
|
||||
|
||||
void notifyOnUserChangeById({ clientAction: 'inserted', id: _id });
|
||||
|
||||
return _id;
|
||||
};
|
||||
261
apps/meteor/server/lib/users/saveUser/saveUser.ts
Normal file
261
apps/meteor/server/lib/users/saveUser/saveUser.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { isUserFederated } from '@rocket.chat/core-typings';
|
||||
import type { IUser, IRole, IUserSettings, RequiredField } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { ClientSession } from 'mongodb';
|
||||
|
||||
import { handleBio } from './handleBio';
|
||||
import { handleNickname } from './handleNickname';
|
||||
import { saveNewUser } from './saveNewUser';
|
||||
import { sendPasswordEmail } from './sendUserEmail';
|
||||
import { setPasswordUpdater } from './setPasswordUpdater';
|
||||
import { validateUserData } from './validateUserData';
|
||||
import { validateUserEditing } from './validateUserEditing';
|
||||
import { wrapInSessionTransaction, onceTransactionCommitedSuccessfully } from '../../../database/utils';
|
||||
import type { UserChangedAuditStore } from '../../auditServerEvents/userChanged';
|
||||
import { generatePassword } from '../../auth/generatePassword';
|
||||
import { passwordPolicy } from '../../auth/passwordPolicy';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { notifyOnUserChange } from '../../notifyListener';
|
||||
import { shouldBreakInVersion } from '../../shouldBreakInVersion';
|
||||
import { saveCustomFields } from '../saveCustomFields';
|
||||
import { saveUserIdentity } from '../saveUserIdentity';
|
||||
import { setEmail } from '../setEmail';
|
||||
import { setStatusText } from '../setStatusText';
|
||||
|
||||
export type SaveUserData = {
|
||||
_id?: IUser['_id'];
|
||||
setRandomPassword?: boolean;
|
||||
|
||||
password?: string;
|
||||
requirePasswordChange?: boolean;
|
||||
|
||||
username?: string;
|
||||
name?: string;
|
||||
|
||||
statusText?: string;
|
||||
email?: string;
|
||||
verified?: boolean;
|
||||
|
||||
bio?: string;
|
||||
nickname?: string;
|
||||
|
||||
roles?: IRole['_id'][];
|
||||
settings?: Partial<IUserSettings>;
|
||||
language?: string;
|
||||
|
||||
joinDefaultChannels?: boolean;
|
||||
sendWelcomeEmail?: boolean;
|
||||
|
||||
customFields?: Record<string, any>;
|
||||
active?: boolean;
|
||||
|
||||
freeSwitchExtension?: string;
|
||||
};
|
||||
export type UpdateUserData = RequiredField<SaveUserData, '_id'>;
|
||||
export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id;
|
||||
|
||||
type SaveUserOptions = {
|
||||
auditStore?: UserChangedAuditStore;
|
||||
};
|
||||
|
||||
const findUserById = async (uid: IUser['_id']): Promise<IUser> => {
|
||||
const user = await Users.findOneById(uid);
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const _saveUser = (session?: ClientSession) =>
|
||||
async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) {
|
||||
const performedBy = await findUserById(userId);
|
||||
|
||||
const oldUserData = userData._id && (await Users.findOneById(userData._id));
|
||||
if (oldUserData && isUserFederated(oldUserData)) {
|
||||
throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user');
|
||||
}
|
||||
|
||||
await validateUserData(userId, userData);
|
||||
|
||||
await callbacks.run('beforeSaveUser', {
|
||||
user: userData,
|
||||
oldUser: oldUserData,
|
||||
});
|
||||
|
||||
let sendPassword = false;
|
||||
|
||||
if (userData.hasOwnProperty('setRandomPassword')) {
|
||||
if (userData.setRandomPassword) {
|
||||
userData.password = generatePassword();
|
||||
userData.requirePasswordChange = true;
|
||||
sendPassword = true;
|
||||
}
|
||||
|
||||
delete userData.setRandomPassword;
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData)) {
|
||||
// TODO audit new users
|
||||
return saveNewUser(userData, sendPassword, performedBy);
|
||||
}
|
||||
|
||||
if (!oldUserData) {
|
||||
throw new MeteorError('error-user-not-found', 'User not found', {
|
||||
method: 'saveUser',
|
||||
});
|
||||
}
|
||||
|
||||
options?.auditStore?.setOriginalUser(oldUserData);
|
||||
|
||||
await validateUserEditing(userId, userData);
|
||||
|
||||
// update user
|
||||
const updater = Users.getUpdater();
|
||||
|
||||
if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) {
|
||||
if (
|
||||
!(await saveUserIdentity({
|
||||
_id: userData._id,
|
||||
username: userData.username,
|
||||
name: userData.name,
|
||||
updateUsernameInBackground: true,
|
||||
updater,
|
||||
session,
|
||||
}))
|
||||
) {
|
||||
throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', {
|
||||
method: 'saveUser',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof userData.statusText === 'string') {
|
||||
await setStatusText(oldUserData, userData.statusText, { updater, session });
|
||||
}
|
||||
|
||||
if (userData.email) {
|
||||
const shouldSendVerificationEmailToUser = userData.verified !== true;
|
||||
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater);
|
||||
}
|
||||
|
||||
if (
|
||||
userData.password?.trim() &&
|
||||
(await hasPermissionAsync(userId, 'edit-other-user-password')) &&
|
||||
passwordPolicy.validate(userData.password)
|
||||
) {
|
||||
await setPasswordUpdater(updater, userData.password.trim());
|
||||
} else {
|
||||
sendPassword = false;
|
||||
}
|
||||
|
||||
handleBio(updater, userData.bio);
|
||||
handleNickname(updater, userData.nickname);
|
||||
|
||||
if (userData.roles) {
|
||||
updater.set('roles', userData.roles);
|
||||
}
|
||||
if (userData.settings) {
|
||||
updater.set('settings', { preferences: userData.settings.preferences });
|
||||
}
|
||||
|
||||
if (userData.language) {
|
||||
updater.set('language', userData.language);
|
||||
}
|
||||
|
||||
if (typeof userData.requirePasswordChange !== 'undefined') {
|
||||
updater.set('requirePasswordChange', userData.requirePasswordChange);
|
||||
if (!userData.requirePasswordChange) {
|
||||
updater.unset('requirePasswordChangeReason');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== (oldUserData?.freeSwitchExtension ?? '')) {
|
||||
if (userData.freeSwitchExtension.trim() === '') {
|
||||
updater.unset('freeSwitchExtension');
|
||||
} else {
|
||||
updater.set('freeSwitchExtension', userData.freeSwitchExtension);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof userData.verified === 'boolean') {
|
||||
if (oldUserData && 'emails' in oldUserData && oldUserData.emails?.some(({ address }) => address === userData.email)) {
|
||||
const index = oldUserData.emails.findIndex(({ address }) => address === userData.email);
|
||||
updater.set(`emails.${index}.verified`, userData.verified);
|
||||
}
|
||||
if (!userData.email) {
|
||||
updater.set(`emails.0.verified`, userData.verified);
|
||||
}
|
||||
}
|
||||
|
||||
if (userData.customFields) {
|
||||
await saveCustomFields(userData._id, userData.customFields, { _updater: updater, session });
|
||||
}
|
||||
|
||||
await Users.updateFromUpdater({ _id: userData._id }, updater, { session });
|
||||
|
||||
await onceTransactionCommitedSuccessfully(async () => {
|
||||
if (session && options?.auditStore) {
|
||||
// setting this inside here to avoid moving `executeSetUserActiveStatus` from the endpoint fn
|
||||
// updater will be commited by this point, so it won't affect the external user activation/deactivation
|
||||
if (userData.active !== undefined) {
|
||||
updater.set('active', userData.active);
|
||||
}
|
||||
options.auditStore.setUpdateFilter(updater.getRawUpdateFilter());
|
||||
void options.auditStore.commitAuditEvent();
|
||||
}
|
||||
|
||||
// App IPostUserUpdated event hook
|
||||
// We need to pass the session here to ensure this record is fetched
|
||||
// with the uncommited transaction data.
|
||||
const userUpdated = await Users.findOneById(userData._id);
|
||||
|
||||
await callbacks.run('afterSaveUser', {
|
||||
user: userUpdated,
|
||||
oldUser: oldUserData,
|
||||
});
|
||||
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, {
|
||||
user: userUpdated,
|
||||
previousUser: oldUserData,
|
||||
performedBy,
|
||||
});
|
||||
|
||||
if (sendPassword) {
|
||||
await sendPasswordEmail(userData);
|
||||
}
|
||||
|
||||
if (typeof userData.verified === 'boolean') {
|
||||
delete userData.verified;
|
||||
}
|
||||
void notifyOnUserChange({
|
||||
clientAction: 'updated',
|
||||
id: userData._id,
|
||||
diff: {
|
||||
...userData,
|
||||
emails: userUpdated?.emails,
|
||||
},
|
||||
});
|
||||
}, session);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isBroken = shouldBreakInVersion('9.0.0');
|
||||
export const saveUser = (() => {
|
||||
if (!process.env.DEBUG_DISABLE_USER_AUDIT) {
|
||||
return wrapInSessionTransaction(_saveUser);
|
||||
}
|
||||
|
||||
if (isBroken) {
|
||||
throw new Error('DEBUG_DISABLE_USER_AUDIT flag is deprecated and should be removed');
|
||||
}
|
||||
const saveUserNoSession = _saveUser();
|
||||
return function saveUser(userId: IUser['_id'], userData: SaveUserData, _options?: any) {
|
||||
return saveUserNoSession(userId, userData);
|
||||
};
|
||||
})();
|
||||
55
apps/meteor/server/lib/users/saveUser/sendUserEmail.ts
Normal file
55
apps/meteor/server/lib/users/saveUser/sendUserEmail.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
import { settings } from '../../../settings';
|
||||
import * as Mailer from '../../notifications/email/api';
|
||||
|
||||
let html = '';
|
||||
let passwordChangedHtml = '';
|
||||
Meteor.startup(() => {
|
||||
Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => {
|
||||
html = template;
|
||||
});
|
||||
|
||||
Mailer.getTemplate('Password_Changed_Email', (template) => {
|
||||
passwordChangedHtml = template;
|
||||
});
|
||||
});
|
||||
|
||||
export async function sendUserEmail(subject: string, html: string, userData: SaveUserData): Promise<void> {
|
||||
if (!userData.email) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = {
|
||||
to: userData.email,
|
||||
from: settings.get<string>('From_Email'),
|
||||
subject,
|
||||
html,
|
||||
data: {
|
||||
email: userData.email,
|
||||
password: userData.password ?? '******',
|
||||
...(typeof userData.name !== 'undefined' ? { name: userData.name } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await Mailer.send(email);
|
||||
} catch (error) {
|
||||
const errorMessage = typeof error === 'object' && error && 'message' in error ? error.message : '';
|
||||
|
||||
throw new MeteorError('error-email-send-failed', `Error trying to send email: ${errorMessage}`, {
|
||||
function: 'RocketChat.saveUser',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWelcomeEmail(userData: Pick<SaveUserData, 'email' | 'name' | 'password'>) {
|
||||
return sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData);
|
||||
}
|
||||
|
||||
export async function sendPasswordEmail(userData: Pick<SaveUserData, 'email' | 'name' | 'password'>) {
|
||||
return sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData);
|
||||
}
|
||||
25
apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts
Normal file
25
apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/model-typings';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
const hashPassword = async (password: string) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(password);
|
||||
const hashedPassword = hash.digest('hex');
|
||||
return bcrypt.hash(hashedPassword, Accounts._bcryptRounds());
|
||||
};
|
||||
|
||||
export async function setPasswordUpdater(
|
||||
updater: Updater<IUser>,
|
||||
newPlaintextPassword: string,
|
||||
options: { logout?: boolean } = { logout: true },
|
||||
) {
|
||||
updater.set('services.password.bcrypt', await hashPassword(newPlaintextPassword));
|
||||
|
||||
if (options.logout) {
|
||||
updater.unset('services.resume.loginTokens');
|
||||
}
|
||||
}
|
||||
108
apps/meteor/server/lib/users/saveUser/validateUserData.ts
Normal file
108
apps/meteor/server/lib/users/saveUser/validateUserData.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { makeFunction } from '@rocket.chat/patch-injection';
|
||||
import escape from 'lodash.escape';
|
||||
|
||||
import type { SaveUserData } from './saveUser';
|
||||
import { isUpdateUserData } from './saveUser';
|
||||
import { trim } from '../../../../lib/utils/stringUtils';
|
||||
import { settings } from '../../../settings';
|
||||
import { getRoleIds } from '../../authorization/getRoles';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { checkEmailAvailability } from '../checkEmailAvailability';
|
||||
import { checkUsernameAvailability } from '../checkUsernameAvailability';
|
||||
|
||||
export const validateUserData = makeFunction(async (userId: IUser['_id'], userData: SaveUserData): Promise<void> => {
|
||||
const existingRoles = await getRoleIds();
|
||||
|
||||
if (userData.verified && userData._id && userId === userData._id) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Editing email verification is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Editing_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (isUpdateUserData(userData) && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Editing user is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Editing_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData) && !(await hasPermissionAsync(userId, 'create-user'))) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Adding user is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Adding_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (userData.roles) {
|
||||
const newRoles = userData.roles.filter((roleId) => !existingRoles.includes(roleId));
|
||||
if (newRoles.length > 0) {
|
||||
throw new MeteorError('error-action-not-allowed', 'The field Roles consist invalid role id', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Assign_role',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (userData.roles?.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Assigning admin is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Assign_admin',
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.get('Accounts_RequireNameForSignUp') && !isUpdateUserData(userData) && !trim(userData.name)) {
|
||||
throw new MeteorError('error-the-field-is-required', 'The field Name is required', {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: 'Name',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData) && !trim(userData.username)) {
|
||||
throw new MeteorError('error-the-field-is-required', 'The field Username is required', {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: 'Username',
|
||||
});
|
||||
}
|
||||
|
||||
let nameValidation;
|
||||
|
||||
try {
|
||||
nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`);
|
||||
} catch (e) {
|
||||
nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$');
|
||||
}
|
||||
|
||||
if (userData.username && !nameValidation.test(userData.username)) {
|
||||
throw new MeteorError('error-input-is-not-a-valid-field', `${escape(userData.username)} is not a valid username`, {
|
||||
method: 'insertOrUpdateUser',
|
||||
input: userData.username,
|
||||
field: 'Username',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData) && !userData.password && !userData.setRandomPassword) {
|
||||
throw new MeteorError('error-the-field-is-required', 'The field Password is required', {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: 'Password',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUpdateUserData(userData)) {
|
||||
if (userData.username && !(await checkUsernameAvailability(userData.username))) {
|
||||
throw new MeteorError('error-field-unavailable', `${escape(userData.username)} is already in use :(`, {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: userData.username,
|
||||
});
|
||||
}
|
||||
|
||||
if (userData.email && !(await checkEmailAvailability(userData.email))) {
|
||||
throw new MeteorError('error-field-unavailable', `${escape(userData.email)} is already in use :(`, {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: userData.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
123
apps/meteor/server/lib/users/saveUser/validateUserEditing.ts
Normal file
123
apps/meteor/server/lib/users/saveUser/validateUserEditing.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable complexity */
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { UpdateUserData } from './saveUser';
|
||||
import { settings } from '../../../settings';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
const isEditingUserRoles = (previousRoles: IUser['roles'], newRoles?: IUser['roles']) =>
|
||||
newRoles !== undefined &&
|
||||
(newRoles.some((item) => !previousRoles.includes(item)) || previousRoles.some((item) => !newRoles.includes(item)));
|
||||
const isEditingField = (previousValue?: string, newValue?: string) => typeof newValue !== 'undefined' && newValue !== previousValue;
|
||||
|
||||
export const canEditExtension = async (newExtension?: string) => {
|
||||
if (!settings.get('VoIP_TeamCollab_SIP_Integration_Enabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newExtension && (await Users.findOneByFreeSwitchExtension(newExtension, { projection: { _id: 1 } }))) {
|
||||
throw new MeteorError('error-extension-not-available', 'Extension is already assigned to another user');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate permissions to edit user fields
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {{ _id: string, roles?: string[], username?: string, name?: string, statusText?: string, email?: string, password?: string}} userData
|
||||
*/
|
||||
export async function validateUserEditing(userId: IUser['_id'], userData: UpdateUserData): Promise<void> {
|
||||
const editingMyself = userData._id && userId === userData._id;
|
||||
|
||||
const canEditOtherUserInfo = await hasPermissionAsync(userId, 'edit-other-user-info');
|
||||
const canEditOtherUserPassword = await hasPermissionAsync(userId, 'edit-other-user-password');
|
||||
const user = await Users.findOneById(userData._id);
|
||||
|
||||
if (!user) {
|
||||
throw new MeteorError('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
if (isEditingUserRoles(user.roles, userData.roles) && !(await hasPermissionAsync(userId, 'assign-roles'))) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Assign roles is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Assign_role',
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.get('Accounts_AllowUserProfileChange') && !canEditOtherUserInfo && !canEditOtherUserPassword) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user profile is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEditingField(user.username, userData.username) &&
|
||||
!settings.get('Accounts_AllowUsernameChange') &&
|
||||
(editingMyself ? user.username : !canEditOtherUserInfo)
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit username is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEditingField(user.statusText, userData.statusText) &&
|
||||
!settings.get('Accounts_AllowUserStatusMessageChange') &&
|
||||
(!canEditOtherUserInfo || editingMyself)
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user status is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEditingField(user.name, userData.name) &&
|
||||
!settings.get('Accounts_AllowRealNameChange') &&
|
||||
(!canEditOtherUserInfo || editingMyself)
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user real name is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
user.emails?.[0] &&
|
||||
isEditingField(user.emails[0].address, userData.email) &&
|
||||
!settings.get('Accounts_AllowEmailChange') &&
|
||||
(!canEditOtherUserInfo || editingMyself)
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user email is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
userData.password &&
|
||||
!settings.get('Accounts_AllowPasswordChange') &&
|
||||
(editingMyself ? user.services?.password || !user.requirePasswordChange : !canEditOtherUserPassword)
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user password is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEditingField(user.freeSwitchExtension ?? '', userData.freeSwitchExtension) &&
|
||||
!(await canEditExtension(userData.freeSwitchExtension))
|
||||
) {
|
||||
throw new MeteorError('error-action-not-allowed', 'Edit user voice call extension is not allowed', {
|
||||
method: 'insertOrUpdateUser',
|
||||
action: 'Update_user',
|
||||
});
|
||||
}
|
||||
}
|
||||
188
apps/meteor/server/lib/users/saveUserIdentity.ts
Normal file
188
apps/meteor/server/lib/users/saveUserIdentity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
106
apps/meteor/server/lib/users/setEmail.ts
Normal file
106
apps/meteor/server/lib/users/setEmail.ts
Normal 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;
|
||||
};
|
||||
64
apps/meteor/server/lib/users/setRealName.ts
Normal file
64
apps/meteor/server/lib/users/setRealName.ts
Normal 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;
|
||||
};
|
||||
45
apps/meteor/server/lib/users/setStatusText.ts
Normal file
45
apps/meteor/server/lib/users/setStatusText.ts
Normal 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;
|
||||
}
|
||||
169
apps/meteor/server/lib/users/setUserActiveStatus.ts
Normal file
169
apps/meteor/server/lib/users/setUserActiveStatus.ts
Normal 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);
|
||||
}
|
||||
218
apps/meteor/server/lib/users/setUserAvatar.ts
Normal file
218
apps/meteor/server/lib/users/setUserAvatar.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
171
apps/meteor/server/lib/users/setUsername.ts
Normal file
171
apps/meteor/server/lib/users/setUsername.ts
Normal 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;
|
||||
};
|
||||
40
apps/meteor/server/lib/users/unarchiveUserSubscriptions.ts
Normal file
40
apps/meteor/server/lib/users/unarchiveUserSubscriptions.ts
Normal 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;
|
||||
};
|
||||
23
apps/meteor/server/lib/users/unblockUser.ts
Normal file
23
apps/meteor/server/lib/users/unblockUser.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
48
apps/meteor/server/lib/users/validateCustomFields.js
Normal file
48
apps/meteor/server/lib/users/validateCustomFields.js
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
15
apps/meteor/server/lib/users/validateUsername.ts
Normal file
15
apps/meteor/server/lib/users/validateUsername.ts
Normal 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);
|
||||
};
|
||||
26
apps/meteor/server/lib/users/verifyEmail.ts
Normal file
26
apps/meteor/server/lib/users/verifyEmail.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user