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:
61
apps/meteor/server/lib/rooms/acceptRoomInvite.ts
Normal file
61
apps/meteor/server/lib/rooms/acceptRoomInvite.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IUser, IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { callbacks } from '../callbacks';
|
||||
import { notifyOnSubscriptionChangedById } from '../notifyListener';
|
||||
|
||||
/**
|
||||
* Accepts a room invite when triggered by internal events such as federation
|
||||
* or third-party callbacks. Performs the necessary database updates and triggers
|
||||
* safe callbacks, ensuring no propagation loops are created during external event
|
||||
* processing.
|
||||
*/
|
||||
|
||||
// TODO this funcion is pretty much the same as the one in addUserToRoom.ts, we should probably
|
||||
// unify them at some point
|
||||
export const performAcceptRoomInvite = async (
|
||||
room: IRoom,
|
||||
subscription: ISubscription,
|
||||
user: IUser & { username: string },
|
||||
): Promise<void> => {
|
||||
if (subscription.status !== 'INVITED' || !subscription.inviter) {
|
||||
throw new Meteor.Error('error-not-invited', `User was not invited to this room ${subscription.status}`);
|
||||
}
|
||||
const inviter = await Users.findOneById(subscription.inviter._id);
|
||||
|
||||
await callbacks.run('beforeJoinRoom', user, room);
|
||||
|
||||
await callbacks.run('beforeAddedToRoom', { user, inviter }, room);
|
||||
|
||||
try {
|
||||
await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, user, inviter);
|
||||
} catch (error: any) {
|
||||
if (error.name === AppsEngineException.name) {
|
||||
throw new Meteor.Error('error-app-prevented', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Subscriptions.acceptInvitationById(subscription._id);
|
||||
|
||||
void notifyOnSubscriptionChangedById(subscription._id, 'updated');
|
||||
|
||||
await Message.saveSystemMessage('uj', room._id, user.username, user);
|
||||
|
||||
if (room.t === 'c' || room.t === 'p') {
|
||||
process.nextTick(async () => {
|
||||
// Add a new event, with an optional inviter
|
||||
await callbacks.run('afterAddedToRoom', { user, inviter }, room);
|
||||
|
||||
// Keep the current event
|
||||
await callbacks.run('afterJoinRoom', user, room);
|
||||
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, user, inviter);
|
||||
});
|
||||
}
|
||||
};
|
||||
47
apps/meteor/server/lib/rooms/addUserToDefaultChannels.ts
Normal file
47
apps/meteor/server/lib/rooms/addUserToDefaultChannels.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions } from '@rocket.chat/models';
|
||||
|
||||
import { getDefaultChannels } from './getDefaultChannels';
|
||||
import { settings } from '../../settings';
|
||||
import { callbacks } from '../callbacks';
|
||||
import { getSubscriptionAutotranslateDefaultConfig } from '../getSubscriptionAutotranslateDefaultConfig';
|
||||
import { notifyOnSubscriptionChangedById } from '../notifyListener';
|
||||
import { getDefaultSubscriptionPref } from '../utils/lib/getDefaultSubscriptionPref';
|
||||
|
||||
export const addUserToDefaultChannels = async function (user: IUser, silenced?: boolean): Promise<void> {
|
||||
await callbacks.run('beforeJoinDefaultChannels', user);
|
||||
const defaultRooms = await getDefaultChannels();
|
||||
|
||||
for (const room of defaultRooms) {
|
||||
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) {
|
||||
const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user);
|
||||
|
||||
// Add a subscription to this user
|
||||
const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, {
|
||||
ts: new Date(),
|
||||
open: true,
|
||||
alert: true,
|
||||
unread: 1,
|
||||
userMentions: 1,
|
||||
groupMentions: 0,
|
||||
...(room.favorite && { f: true }),
|
||||
...autoTranslateConfig,
|
||||
...getDefaultSubscriptionPref(user),
|
||||
});
|
||||
|
||||
if (insertedId) {
|
||||
void notifyOnSubscriptionChangedById(insertedId, 'inserted');
|
||||
}
|
||||
|
||||
// Insert user joined message
|
||||
if (!silenced) {
|
||||
await Message.saveSystemMessage('uj', room._id, user.username || '', user);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
145
apps/meteor/server/lib/rooms/addUserToRoom.ts
Normal file
145
apps/meteor/server/lib/rooms/addUserToRoom.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
|
||||
import { Message, Team, Room } from '@rocket.chat/core-services';
|
||||
import { isBannedSubscription, isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { RoomMemberActions } from '../../../definition/IRoomTypeConfig';
|
||||
import { callbacks } from '../callbacks';
|
||||
import { roomCoordinator } from './roomCoordinator';
|
||||
import { beforeAddUserToRoom as beforeAddUserToRoomPatch } from '../../hooks/rooms/beforeAddUserToRoom';
|
||||
import { settings } from '../../settings';
|
||||
import { beforeAddUserToRoom } from '../callbacks/beforeAddUserToRoom';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener';
|
||||
|
||||
/**
|
||||
* This function adds user to the given room.
|
||||
* Caution - It does not validates if the user has permission to join room
|
||||
*/
|
||||
|
||||
export const addUserToRoom = async (
|
||||
rid: string,
|
||||
user: Pick<IUser, '_id' | 'username'>,
|
||||
inviter?: Pick<IUser, '_id' | 'username'>,
|
||||
{
|
||||
skipSystemMessage,
|
||||
skipAlertSound,
|
||||
createAsHidden = false,
|
||||
}: {
|
||||
skipSystemMessage?: boolean;
|
||||
skipAlertSound?: boolean;
|
||||
createAsHidden?: boolean;
|
||||
} = {},
|
||||
): Promise<boolean | undefined> => {
|
||||
const now = new Date();
|
||||
const room = await Rooms.findOneById(rid);
|
||||
|
||||
if (!room) {
|
||||
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
|
||||
method: 'addUserToRoom',
|
||||
});
|
||||
}
|
||||
|
||||
const userToBeAdded = await Users.findOneById(user._id);
|
||||
const roomDirectives = roomCoordinator.getRoomDirectives(room.t);
|
||||
|
||||
if (!userToBeAdded) {
|
||||
throw new Meteor.Error('user-not-found');
|
||||
}
|
||||
|
||||
if (
|
||||
!(await roomDirectives.allowMemberAction(room, RoomMemberActions.JOIN, userToBeAdded._id)) &&
|
||||
!(await roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, userToBeAdded._id))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const inviterUser = inviter && ((await Users.findOneById(inviter._id)) || undefined);
|
||||
// Not "duplicated": we're moving away from callbacks so this is a patch function. We should migrate the next one to be a patch or use this same patch, instead of calling both
|
||||
await beforeAddUserToRoomPatch([userToBeAdded.username!], room, inviterUser);
|
||||
await beforeAddUserToRoom.run({ user: userToBeAdded, inviter: inviterUser }, room);
|
||||
} catch (error) {
|
||||
throw new Meteor.Error((error as any)?.message);
|
||||
}
|
||||
|
||||
// TODO: are we calling this twice?
|
||||
|
||||
await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter });
|
||||
|
||||
try {
|
||||
await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter);
|
||||
} catch (error: any) {
|
||||
if (error.name === AppsEngineException.name) {
|
||||
throw new Meteor.Error('error-app-prevented', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// for federation rooms we stop here since everything else will be handled by the federation invite flow
|
||||
if (isRoomNativeFederated(room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: are we calling this twice?
|
||||
if (room.t === 'c' || room.t === 'p' || room.t === 'l') {
|
||||
// Add a new event, with an optional inviter
|
||||
await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }, room);
|
||||
|
||||
// Keep the current event
|
||||
await callbacks.run('beforeJoinRoom', userToBeAdded, room);
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id);
|
||||
if (subscription) {
|
||||
if (!isBannedSubscription(subscription)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const deletedSub = await Subscriptions.removeByRoomIdAndUserId(rid, userToBeAdded._id);
|
||||
if (!deletedSub) {
|
||||
return true;
|
||||
}
|
||||
void notifyOnSubscriptionChanged(deletedSub, 'removed');
|
||||
|
||||
if (!skipSystemMessage && inviter) {
|
||||
await Message.saveSystemMessage('user-unbanned', rid, userToBeAdded.username!, inviter, { ts: now });
|
||||
}
|
||||
}
|
||||
|
||||
await Room.createUserSubscription({
|
||||
room,
|
||||
ts: now,
|
||||
inviter,
|
||||
userToBeAdded,
|
||||
createAsHidden,
|
||||
skipAlertSound,
|
||||
skipSystemMessage,
|
||||
});
|
||||
|
||||
if (room.t === 'c' || room.t === 'p') {
|
||||
process.nextTick(async () => {
|
||||
// Add a new event, with an optional inviter
|
||||
await callbacks.run('afterAddedToRoom', { user: userToBeAdded, inviter }, room);
|
||||
|
||||
// Keep the current event
|
||||
await callbacks.run('afterJoinRoom', userToBeAdded, room);
|
||||
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter);
|
||||
});
|
||||
}
|
||||
|
||||
if (room.teamMain && room.teamId) {
|
||||
// if user is joining to main team channel, create a membership
|
||||
await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId);
|
||||
}
|
||||
|
||||
if (room.encrypted && settings.get('E2E_Enable') && userToBeAdded.e2e?.public_key) {
|
||||
await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id);
|
||||
}
|
||||
|
||||
void notifyOnRoomChangedById(rid);
|
||||
return true;
|
||||
};
|
||||
25
apps/meteor/server/lib/rooms/archiveRoom.ts
Normal file
25
apps/meteor/server/lib/rooms/archiveRoom.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
|
||||
import { callbacks } from '../callbacks';
|
||||
import { notifyOnRoomChanged, notifyOnSubscriptionChangedByRoomId } from '../notifyListener';
|
||||
|
||||
export const archiveRoom = async function (rid: string, user: IMessage['u']): Promise<void> {
|
||||
await Rooms.archiveById(rid);
|
||||
|
||||
const archiveResponse = await Subscriptions.archiveByRoomId(rid);
|
||||
if (archiveResponse.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
|
||||
await Message.saveSystemMessage('room-archived', rid, '', user);
|
||||
|
||||
const room = await Rooms.findOneById(rid);
|
||||
|
||||
await callbacks.run('afterRoomArchived', room, user);
|
||||
|
||||
if (room) {
|
||||
void notifyOnRoomChanged(room);
|
||||
}
|
||||
};
|
||||
76
apps/meteor/server/lib/rooms/banUserFromRoom.ts
Normal file
76
apps/meteor/server/lib/rooms/banUserFromRoom.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Message, Team } from '@rocket.chat/core-services';
|
||||
import { isBannedSubscription } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
|
||||
import { afterBanFromRoomCallback } from '../callbacks/afterBanFromRoomCallback';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener';
|
||||
import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles';
|
||||
|
||||
/**
|
||||
* Bans a user from a room when triggered by federation or other external events.
|
||||
* Executes only the necessary database operations, with no callbacks, to prevent
|
||||
* propagation loops during external event processing.
|
||||
* `byUser` must be the Rocket.Chat user who initiated the ban (local record).
|
||||
*/
|
||||
export const performUserBan = async function (room: IRoom, user: IUser, byUser: IUser): Promise<void> {
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id);
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.username) {
|
||||
throw new Error('User must have a username to be banned from the room');
|
||||
}
|
||||
|
||||
// Already banned — nothing to do
|
||||
if (isBannedSubscription(subscription)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set subscription status to BANNED (keeps the record, unlike kick which deletes it)
|
||||
await Subscriptions.banByRoomIdAndUserId(room._id, user._id);
|
||||
|
||||
// Remove the room from the user's __rooms array so they don't appear in member listings
|
||||
await Users.removeRoomByUserId(user._id, room._id);
|
||||
|
||||
// Decrement the room's user count
|
||||
await Rooms.incUsersCountById(room._id, -1);
|
||||
|
||||
// Remove room-scoped roles (moderator, owner, leader)
|
||||
if (['c', 'p'].includes(room.t)) {
|
||||
await removeUserFromRolesAsync(user._id, ['moderator', 'owner', 'leader'], room._id);
|
||||
}
|
||||
|
||||
// Remove from team when banning from main team room so roster stays in sync with subscription state
|
||||
if (room.teamId && room.teamMain) {
|
||||
await Team.removeMember(room.teamId, user._id);
|
||||
}
|
||||
|
||||
// Save system message (who banned is always recorded)
|
||||
await Message.saveSystemMessage('user-banned', room._id, user.username, user, {
|
||||
u: byUser,
|
||||
});
|
||||
|
||||
// Send 'removed' so the client drops the room stream/socket subscription.
|
||||
// The record still exists in DB with status BANNED for access-control purposes.
|
||||
void notifyOnSubscriptionChanged(subscription, 'removed');
|
||||
void notifyOnRoomChangedById(room._id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Bans a user from the given room by updating the subscription status to BANNED,
|
||||
* removing them from member listings, and triggering all standard callbacks.
|
||||
* Used for local actions (UI or API) that should propagate normally to federation
|
||||
* and other subscribers.
|
||||
*/
|
||||
export const banUserFromRoom = async function (rid: string, user: IUser, byUser: IUser): Promise<void> {
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
await performUserBan(room, user, byUser);
|
||||
|
||||
void afterBanFromRoomCallback.run({ bannedUser: user, userWhoBanned: byUser }, room);
|
||||
};
|
||||
169
apps/meteor/server/lib/rooms/cleanRoomHistory.ts
Normal file
169
apps/meteor/server/lib/rooms/cleanRoomHistory.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models';
|
||||
|
||||
import { deleteRoom } from './deleteRoom';
|
||||
import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../lib/constants';
|
||||
import { i18n } from '../i18n';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../notifyListener';
|
||||
|
||||
const FILE_CLEANUP_BATCH_SIZE = 1000;
|
||||
|
||||
export async function cleanRoomHistory({
|
||||
rid = '',
|
||||
latest = new Date(),
|
||||
oldest = new Date('0001-01-01T00:00:00Z'),
|
||||
inclusive = true,
|
||||
limit = 0,
|
||||
excludePinned = true,
|
||||
ignoreDiscussion = true,
|
||||
filesOnly = false,
|
||||
fromUsers = [],
|
||||
ignoreThreads = true,
|
||||
}: {
|
||||
rid?: IRoom['_id'];
|
||||
latest?: Date;
|
||||
oldest?: Date;
|
||||
inclusive?: boolean;
|
||||
limit?: number;
|
||||
excludePinned?: boolean;
|
||||
ignoreDiscussion?: boolean;
|
||||
filesOnly?: boolean;
|
||||
fromUsers?: string[];
|
||||
ignoreThreads?: boolean;
|
||||
}): Promise<number> {
|
||||
const gt = inclusive ? '$gte' : '$gt';
|
||||
const lt = inclusive ? '$lte' : '$lt';
|
||||
|
||||
const ts = { [gt]: oldest, [lt]: latest };
|
||||
|
||||
const text = `_${i18n.t('File_removed_by_prune')}_`;
|
||||
|
||||
let fileCount = 0;
|
||||
|
||||
const cursor = Messages.findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreDiscussion, ts, fromUsers, ignoreThreads, {
|
||||
projection: { pinned: 1, files: 1 },
|
||||
limit,
|
||||
});
|
||||
|
||||
const targetMessageIdsForAttachmentRemoval = new Set<string>();
|
||||
// Since we remove every file from the messages, we don't need to specify which fileId has been removed.
|
||||
const pruneMessageAttachment = { type: 'removed-file', color: NOTIFICATION_ATTACHMENT_COLOR, text };
|
||||
|
||||
async function performFileAttachmentCleanupBatch() {
|
||||
if (targetMessageIdsForAttachmentRemoval.size === 0) return;
|
||||
|
||||
const ids = [...targetMessageIdsForAttachmentRemoval];
|
||||
await Messages.removeFileAttachmentsByMessageIds(ids, pruneMessageAttachment);
|
||||
await Messages.clearFilesByMessageIds(ids);
|
||||
void api.broadcast('notify.deleteMessageBulk', rid, {
|
||||
rid,
|
||||
excludePinned,
|
||||
ignoreDiscussion,
|
||||
ts,
|
||||
users: fromUsers,
|
||||
ids,
|
||||
filesOnly: true,
|
||||
replaceFileAttachmentsWith: pruneMessageAttachment,
|
||||
});
|
||||
targetMessageIdsForAttachmentRemoval.clear();
|
||||
}
|
||||
|
||||
for await (const document of cursor) {
|
||||
const uploadsStore = FileUpload.getStore('Uploads');
|
||||
|
||||
document.files && (await Promise.all(document.files.map((file) => uploadsStore.deleteById(file._id))));
|
||||
|
||||
fileCount++;
|
||||
if (filesOnly) {
|
||||
targetMessageIdsForAttachmentRemoval.add(document._id);
|
||||
}
|
||||
|
||||
if (targetMessageIdsForAttachmentRemoval.size >= FILE_CLEANUP_BATCH_SIZE) {
|
||||
await performFileAttachmentCleanupBatch();
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMessageIdsForAttachmentRemoval.size > 0) {
|
||||
await performFileAttachmentCleanupBatch();
|
||||
}
|
||||
|
||||
if (filesOnly) {
|
||||
return fileCount;
|
||||
}
|
||||
|
||||
if (!ignoreDiscussion) {
|
||||
const discussionsCursor = Messages.findDiscussionByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers, {
|
||||
projection: { drid: 1 },
|
||||
...(limit && { limit }),
|
||||
});
|
||||
|
||||
for await (const { drid } of discussionsCursor) {
|
||||
if (!drid) {
|
||||
continue;
|
||||
}
|
||||
await deleteRoom(drid);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ignoreThreads) {
|
||||
const threads = new Set<string>();
|
||||
|
||||
await Messages.findThreadsByRoomIdPinnedTimestampAndUsers(
|
||||
{ rid, pinned: excludePinned, ignoreDiscussion, ts, users: fromUsers },
|
||||
{ projection: { _id: 1 } },
|
||||
).forEach(({ _id }) => {
|
||||
threads.add(_id);
|
||||
});
|
||||
|
||||
if (threads.size > 0) {
|
||||
const subscriptionIds: string[] = (
|
||||
await Subscriptions.findUnreadThreadsByRoomId(rid, [...threads], { projection: { _id: 1 } }).toArray()
|
||||
).map(({ _id }) => _id);
|
||||
|
||||
const { modifiedCount } = await Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]);
|
||||
if (modifiedCount) {
|
||||
subscriptionIds.forEach((id) => notifyOnSubscriptionChangedById(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedMessageIds = limit
|
||||
? await Messages.findByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, limit, fromUsers, ignoreThreads)
|
||||
: undefined;
|
||||
const count = await Messages.removeByIdPinnedTimestampLimitAndUsers(
|
||||
rid,
|
||||
excludePinned,
|
||||
ignoreDiscussion,
|
||||
ts,
|
||||
limit,
|
||||
fromUsers,
|
||||
ignoreThreads,
|
||||
selectedMessageIds,
|
||||
);
|
||||
|
||||
if (limit && selectedMessageIds) {
|
||||
await ReadReceipts.removeByMessageIds(selectedMessageIds);
|
||||
await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
const lastMessage = await Messages.getLastVisibleUserMessageSentByRoomId(rid);
|
||||
|
||||
await Rooms.resetLastMessageById(rid, lastMessage, -count);
|
||||
|
||||
void notifyOnRoomChangedById(rid);
|
||||
|
||||
void api.broadcast('notify.deleteMessageBulk', rid, {
|
||||
rid,
|
||||
excludePinned,
|
||||
ignoreDiscussion,
|
||||
ts,
|
||||
users: fromUsers,
|
||||
ids: selectedMessageIds,
|
||||
});
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
223
apps/meteor/server/lib/rooms/createDirectRoom.ts
Normal file
223
apps/meteor/server/lib/rooms/createDirectRoom.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { AppEvents, Apps } from '@rocket.chat/apps';
|
||||
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
|
||||
import type { ISubscriptionExtraData } from '@rocket.chat/core-services';
|
||||
import type { ICreatedRoom, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { MatchKeysAndValues } from 'mongodb';
|
||||
|
||||
import { getNameForDMs } from '../../services/room/getNameForDMs';
|
||||
import { settings } from '../../settings';
|
||||
import { callbacks } from '../callbacks';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../notifyListener';
|
||||
import { getDefaultSubscriptionPref } from '../utils/lib/getDefaultSubscriptionPref';
|
||||
|
||||
const generateSubscription = (
|
||||
fname: string,
|
||||
name: string,
|
||||
user: IUser,
|
||||
extra: MatchKeysAndValues<ISubscription>,
|
||||
): MatchKeysAndValues<ISubscription> => ({
|
||||
_id: Random.id(),
|
||||
ts: new Date(),
|
||||
alert: false,
|
||||
unread: 0,
|
||||
userMentions: 0,
|
||||
groupMentions: 0,
|
||||
...(user.customFields && { customFields: user.customFields }),
|
||||
...getDefaultSubscriptionPref(user),
|
||||
...extra,
|
||||
t: 'd',
|
||||
fname,
|
||||
name,
|
||||
u: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
},
|
||||
});
|
||||
|
||||
export async function createDirectRoom(
|
||||
members: IUser[] | string[],
|
||||
roomExtraData: Partial<IRoom> = {},
|
||||
options: {
|
||||
forceNew?: boolean;
|
||||
creator?: IUser['_id'];
|
||||
subscriptionExtra?: ISubscriptionExtraData;
|
||||
},
|
||||
): Promise<ICreatedRoom> {
|
||||
const maxUsers = settings.get<number>('DirectMesssage_maxUsers') || 1;
|
||||
if (members.length > maxUsers) {
|
||||
throw new Meteor.Error(
|
||||
'error-direct-message-max-user-exceeded',
|
||||
`You cannot add more than ${maxUsers} users, including yourself to a direct message`,
|
||||
{
|
||||
method: 'createDirectRoom',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const membersUsernames: string[] = members
|
||||
.map((member) => {
|
||||
if (typeof member === 'string') {
|
||||
return member;
|
||||
}
|
||||
return member.username;
|
||||
})
|
||||
.filter(isTruthy);
|
||||
|
||||
await callbacks.run('beforeCreateDirectRoom', membersUsernames, roomExtraData);
|
||||
|
||||
const roomMembers = await Users.findUsersByUsernames(membersUsernames).toArray();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const sortedMembers = roomMembers.sort((u1, u2) => (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!));
|
||||
|
||||
const usernames: string[] = sortedMembers.map(({ username }) => username as string).filter(Boolean);
|
||||
const uids = roomMembers.map(({ _id }) => _id).sort();
|
||||
|
||||
// Deprecated: using users' _id to compose the room _id is deprecated
|
||||
// No projection: the full room is spread into the ICreatedRoom returned below.
|
||||
const room: IRoom | null = options?.forceNew ? null : await Rooms.findOneDirectRoomContainingAllUserIDs(uids);
|
||||
|
||||
const isNewRoom = !room;
|
||||
|
||||
// Mirror setUserActiveStatus: a 1-on-1 DM containing a deactivated user is read-only
|
||||
const isReadOnlyForDeactivatedMember = uids.length === 2 && roomMembers.some((member) => member.active === false);
|
||||
|
||||
const roomInfo = {
|
||||
t: 'd',
|
||||
usernames,
|
||||
usersCount: members.length,
|
||||
msgs: 0,
|
||||
ts: new Date(),
|
||||
uids,
|
||||
...roomExtraData,
|
||||
...(isReadOnlyForDeactivatedMember && { ro: true, reactWhenReadOnly: false }),
|
||||
};
|
||||
|
||||
if (isNewRoom) {
|
||||
const tmpRoom: { _USERNAMES?: (string | undefined)[] } & typeof roomInfo = {
|
||||
...roomInfo,
|
||||
_USERNAMES: usernames,
|
||||
};
|
||||
|
||||
const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmpRoom).catch((error) => {
|
||||
if (error.name === AppsEngineException.name) {
|
||||
throw new Meteor.Error('error-app-prevented', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (prevent) {
|
||||
throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.');
|
||||
}
|
||||
|
||||
const result = await Apps.self?.triggerEvent(
|
||||
AppEvents.IPreRoomCreateModify,
|
||||
await Apps.self?.triggerEvent(AppEvents.IPreRoomCreateExtend, tmpRoom),
|
||||
);
|
||||
|
||||
if (typeof result === 'object') {
|
||||
Object.assign(roomInfo, result);
|
||||
}
|
||||
|
||||
delete tmpRoom._USERNAMES;
|
||||
}
|
||||
|
||||
// @ts-expect-error - TODO: room expects `u` to be passed, but it's not part of the original object in here
|
||||
const rid = room?._id || (await Rooms.insertOne(roomInfo)).insertedId;
|
||||
|
||||
void notifyOnRoomChangedById(rid, isNewRoom ? 'inserted' : 'updated');
|
||||
|
||||
if (roomMembers.length === 1) {
|
||||
// dm to yourself
|
||||
const { modifiedCount, upsertedCount } = await Subscriptions.updateOne(
|
||||
{ rid, 'u._id': roomMembers[0]._id },
|
||||
{
|
||||
$set: { open: true },
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
$setOnInsert: generateSubscription(roomMembers[0].name! || roomMembers[0].username!, roomMembers[0].username!, roomMembers[0], {
|
||||
...options?.subscriptionExtra,
|
||||
}),
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
if (modifiedCount || upsertedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, roomMembers[0]._id, modifiedCount ? 'updated' : 'inserted');
|
||||
}
|
||||
} else {
|
||||
const memberIds = roomMembers.map((member) => member._id);
|
||||
const membersWithPreferences: IUser[] = await Users.find(
|
||||
{ _id: { $in: memberIds } },
|
||||
{ projection: { 'username': 1, 'settings.preferences': 1 } },
|
||||
).toArray();
|
||||
|
||||
const creatorUser = roomMembers.find((member) => member._id === options?.creator);
|
||||
if (roomExtraData.federated && !creatorUser) {
|
||||
throw new Meteor.Error('error-creator-not-in-room', 'The creator user must be part of the direct room');
|
||||
}
|
||||
|
||||
const roomNames = getNameForDMs(roomMembers);
|
||||
|
||||
for (const member of membersWithPreferences) {
|
||||
const subscriptionStatus: Partial<ISubscription> =
|
||||
roomExtraData.federated && options.creator !== member._id && creatorUser
|
||||
? {
|
||||
status: 'INVITED',
|
||||
inviter: {
|
||||
_id: creatorUser._id,
|
||||
username: creatorUser.username!,
|
||||
name: creatorUser.name,
|
||||
},
|
||||
open: true,
|
||||
unread: 1,
|
||||
userMentions: 1,
|
||||
}
|
||||
: {};
|
||||
|
||||
const { fname, name } = roomNames[member._id];
|
||||
|
||||
const { modifiedCount, upsertedCount } = await Subscriptions.updateOne(
|
||||
{ rid, 'u._id': member._id },
|
||||
{
|
||||
...(options?.creator === member._id && { $set: { open: true } }),
|
||||
$setOnInsert: generateSubscription(fname, name, member, {
|
||||
...options?.subscriptionExtra,
|
||||
...(options?.creator !== member._id && { open: members.length > 2 }),
|
||||
...subscriptionStatus,
|
||||
...(roomExtraData.federated && member._id === options?.creator && { roles: ['owner'] }),
|
||||
}),
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
if (modifiedCount || upsertedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, member._id, modifiedCount ? 'updated' : 'inserted');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the room is new, run a callback
|
||||
if (isNewRoom) {
|
||||
const insertedRoom = await Rooms.findOneById(rid);
|
||||
|
||||
await callbacks.run('afterCreateDirectRoom', insertedRoom, {
|
||||
members: roomMembers,
|
||||
creatorId: options?.creator,
|
||||
});
|
||||
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom);
|
||||
}
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
...room!,
|
||||
_id: String(rid),
|
||||
usernames,
|
||||
t: 'd',
|
||||
rid,
|
||||
inserted: isNewRoom,
|
||||
};
|
||||
}
|
||||
327
apps/meteor/server/lib/rooms/createRoom.ts
Normal file
327
apps/meteor/server/lib/rooms/createRoom.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { AppEvents, Apps } from '@rocket.chat/apps';
|
||||
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
|
||||
import { FederationMatrix, Message, Room, Team } from '@rocket.chat/core-services';
|
||||
import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services';
|
||||
import { type ICreatedRoom, type IUser, type IRoom, type RoomType, isUserNativeFederated } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { createDirectRoom } from './createDirectRoom';
|
||||
import { calculateRoomRolePriorityFromRoles } from '../../../lib/roles/calculateRoomRolePriorityFromRoles';
|
||||
import { callbacks } from '../callbacks';
|
||||
import { beforeAddUserToRoom } from '../callbacks/beforeAddUserToRoom';
|
||||
import { beforeCreateRoomCallback, prepareCreateRoomCallback } from '../callbacks/beforeCreateRoomCallback';
|
||||
import { getSubscriptionAutotranslateDefaultConfig } from '../getSubscriptionAutotranslateDefaultConfig';
|
||||
import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../notifyListener';
|
||||
import { syncRoomRolePriorityForUserAndRoom } from '../roles/syncRoomRolePriority';
|
||||
import { getDefaultSubscriptionPref } from '../utils/lib/getDefaultSubscriptionPref';
|
||||
import { getValidRoomName } from '../utils/lib/getValidRoomName';
|
||||
|
||||
const isValidName = (name: unknown): name is string => {
|
||||
return typeof name === 'string' && name.trim().length > 0;
|
||||
};
|
||||
|
||||
const onlyUsernames = (members: unknown): members is string[] =>
|
||||
Array.isArray(members) && members.every((member) => typeof member === 'string');
|
||||
|
||||
async function createUsersSubscriptions({
|
||||
room,
|
||||
shouldBeHandledByFederation,
|
||||
members,
|
||||
now,
|
||||
owner,
|
||||
options,
|
||||
}: {
|
||||
room: IRoom;
|
||||
shouldBeHandledByFederation: boolean;
|
||||
members: string[];
|
||||
now: Date;
|
||||
owner: IUser;
|
||||
options?: ICreateRoomParams['options'];
|
||||
}) {
|
||||
if (shouldBeHandledByFederation) {
|
||||
const extra: Partial<ISubscriptionExtraData> = {
|
||||
...options?.subscriptionExtra,
|
||||
open: true,
|
||||
ls: now,
|
||||
roles: ['owner'],
|
||||
...(room.prid && { prid: room.prid }),
|
||||
...getDefaultSubscriptionPref(owner),
|
||||
};
|
||||
|
||||
const { insertedId } = await Subscriptions.createWithRoomAndUser(room, owner, extra);
|
||||
await syncRoomRolePriorityForUserAndRoom(owner._id, room._id, ['owner']);
|
||||
|
||||
if (insertedId) {
|
||||
await notifyOnSubscriptionChangedById(insertedId, 'inserted');
|
||||
await notifyOnRoomChanged(room, 'inserted');
|
||||
}
|
||||
|
||||
// Invite federated members to the room SYNCRONOUSLY,
|
||||
// since we do not use to invite lots of users at once, this is acceptable.
|
||||
const membersToInvite = members.filter((m) => m !== owner.username);
|
||||
|
||||
await FederationMatrix.ensureFederatedUsersExistLocally(membersToInvite);
|
||||
|
||||
for (const memberUsername of membersToInvite) {
|
||||
const member = await Users.findOneByUsername(memberUsername);
|
||||
if (!member) {
|
||||
throw new Error('Federated user not found locally');
|
||||
}
|
||||
|
||||
await Room.createUserSubscription({
|
||||
ts: new Date(),
|
||||
room,
|
||||
userToBeAdded: member,
|
||||
inviter: owner,
|
||||
status: 'INVITED',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const subs = [];
|
||||
|
||||
const memberIds = [];
|
||||
|
||||
const memberIdAndRolePriorityMap: Record<IUser['_id'], number> = {};
|
||||
|
||||
const membersCursor = Users.findUsersByUsernames(members);
|
||||
|
||||
// TODO: Check re new federation-service - should we add them here or keep on createRoom inside of homeserver?!
|
||||
for await (const member of membersCursor) {
|
||||
try {
|
||||
await beforeAddUserToRoom.run({ user: member, inviter: owner }, room);
|
||||
await callbacks.run('beforeAddedToRoom', { user: member, inviter: owner });
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
memberIds.push(member._id);
|
||||
|
||||
const extra: Partial<ISubscriptionExtraData> = { open: true, ...options?.subscriptionExtra };
|
||||
|
||||
if (room.prid) {
|
||||
extra.prid = room.prid;
|
||||
}
|
||||
|
||||
if (member.username === owner.username) {
|
||||
extra.ls = now;
|
||||
extra.roles = ['owner'];
|
||||
}
|
||||
|
||||
const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(member);
|
||||
|
||||
subs.push({
|
||||
user: member,
|
||||
extraData: {
|
||||
...extra,
|
||||
...autoTranslateConfig,
|
||||
...getDefaultSubscriptionPref(member),
|
||||
},
|
||||
});
|
||||
|
||||
if (extra.roles) {
|
||||
memberIdAndRolePriorityMap[member._id] = calculateRoomRolePriorityFromRoles(extra.roles);
|
||||
}
|
||||
}
|
||||
|
||||
if (!['d', 'l'].includes(room.t)) {
|
||||
await Users.addRoomByUserIds(memberIds, room._id);
|
||||
}
|
||||
|
||||
const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs);
|
||||
await Users.assignRoomRolePrioritiesByUserIdPriorityMap(memberIdAndRolePriorityMap, room._id);
|
||||
|
||||
Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted'));
|
||||
|
||||
await Rooms.incUsersCountById(room._id, subs.length);
|
||||
}
|
||||
|
||||
export const createRoom = async <T extends RoomType>(
|
||||
type: T,
|
||||
name: T extends 'd' ? undefined : string,
|
||||
owner: T extends 'd' ? IUser | undefined : IUser,
|
||||
members: T extends 'd' ? IUser[] : string[] = [],
|
||||
excludeSelf?: boolean,
|
||||
readOnly?: boolean,
|
||||
roomExtraData?: Partial<IRoom>,
|
||||
options?: ICreateRoomParams['options'],
|
||||
): Promise<
|
||||
ICreatedRoom & {
|
||||
rid: string;
|
||||
}
|
||||
> => {
|
||||
const { teamId, ...extraData } = roomExtraData || ({} as IRoom);
|
||||
|
||||
// TODO: use a shared helper to check whether a user is federated
|
||||
const hasFederatedMembers = members.some((member) => {
|
||||
if (typeof member === 'string') {
|
||||
return member.includes(':') && member.includes('@');
|
||||
}
|
||||
return member.username?.includes(':') && member.username?.includes('@');
|
||||
});
|
||||
|
||||
// Prevent adding federated users to rooms that are not marked as federated explicitly
|
||||
if (hasFederatedMembers && extraData.federated !== true) {
|
||||
throw new Meteor.Error('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms', {
|
||||
method: 'createRoom',
|
||||
});
|
||||
}
|
||||
|
||||
await prepareCreateRoomCallback.run({
|
||||
type,
|
||||
// name,
|
||||
// owner: ownerUsername,
|
||||
// members,
|
||||
// readOnly,
|
||||
extraData,
|
||||
// options,
|
||||
});
|
||||
|
||||
const shouldBeHandledByFederation = extraData.federated === true;
|
||||
|
||||
if (shouldBeHandledByFederation && owner && !isUserNativeFederated(owner) && !(await FederationMatrix.canUserAccessFederation(owner))) {
|
||||
throw new Meteor.Error('error-not-authorized-federation', 'Not authorized to access federation', {
|
||||
method: 'createRoom',
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'd') {
|
||||
return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id });
|
||||
}
|
||||
|
||||
const memberList = [...members];
|
||||
|
||||
if (!onlyUsernames(memberList)) {
|
||||
throw new Meteor.Error(
|
||||
'error-invalid-members',
|
||||
'members should be an array of usernames if provided for rooms other than direct messages',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidName(name)) {
|
||||
throw new Meteor.Error('error-invalid-name', 'Invalid name', {
|
||||
function: 'RocketChat.createRoom',
|
||||
});
|
||||
}
|
||||
|
||||
if (!owner) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
function: 'RocketChat.createRoom',
|
||||
});
|
||||
}
|
||||
|
||||
if (!owner?.username) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
function: 'RocketChat.createRoom',
|
||||
});
|
||||
}
|
||||
|
||||
if (!excludeSelf && owner.username && !memberList.includes(owner.username)) {
|
||||
memberList.push(owner.username);
|
||||
}
|
||||
|
||||
if (extraData.broadcast) {
|
||||
readOnly = true;
|
||||
delete extraData.reactWhenReadOnly;
|
||||
}
|
||||
|
||||
// this might not be the best way to check if the room is a discussion, we may need a specific field for that
|
||||
const isDiscussion = 'prid' in extraData && extraData.prid !== '';
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const roomProps: Omit<IRoom, '_id' | '_updatedAt'> = {
|
||||
fname: name,
|
||||
_updatedAt: now,
|
||||
...extraData,
|
||||
name: isDiscussion ? name : await getValidRoomName(name.trim(), undefined),
|
||||
t: type,
|
||||
msgs: 0,
|
||||
usersCount: 0,
|
||||
u: {
|
||||
_id: owner._id,
|
||||
username: owner.username,
|
||||
name: owner.name,
|
||||
},
|
||||
ts: now,
|
||||
ro: readOnly === true,
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
const team = await Team.getOneById(teamId, { projection: { _id: 1 } });
|
||||
if (team) {
|
||||
roomProps.teamId = team._id;
|
||||
}
|
||||
}
|
||||
|
||||
const tmp = {
|
||||
...roomProps,
|
||||
_USERNAMES: memberList,
|
||||
};
|
||||
|
||||
const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => {
|
||||
if (error.name === AppsEngineException.name) {
|
||||
throw new Meteor.Error('error-app-prevented', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (prevent) {
|
||||
throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.');
|
||||
}
|
||||
|
||||
const eventResult = await Apps.self?.triggerEvent(
|
||||
AppEvents.IPreRoomCreateModify,
|
||||
await Apps.triggerEvent(AppEvents.IPreRoomCreateExtend, tmp),
|
||||
);
|
||||
|
||||
if (eventResult && typeof eventResult === 'object' && delete eventResult._USERNAMES) {
|
||||
Object.assign(roomProps, eventResult);
|
||||
}
|
||||
|
||||
await beforeCreateRoomCallback.run({
|
||||
owner,
|
||||
room: roomProps,
|
||||
});
|
||||
|
||||
if (type === 'c') {
|
||||
await callbacks.run('beforeCreateChannel', owner, roomProps);
|
||||
}
|
||||
|
||||
const room = await Rooms.createWithFullRoomData(roomProps);
|
||||
|
||||
void notifyOnRoomChanged(room, 'inserted');
|
||||
|
||||
// If federated, we must create Matrix room BEFORE subscriptions so invites can be sent.
|
||||
if (shouldBeHandledByFederation) {
|
||||
// Reusing unused callback to create Matrix room.
|
||||
// We should discuss the opportunity to rename it to something with "before" prefix.
|
||||
await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: memberList, options });
|
||||
}
|
||||
|
||||
await createUsersSubscriptions({ room, members: memberList, now, owner, options, shouldBeHandledByFederation });
|
||||
|
||||
if (type === 'c') {
|
||||
if (room.teamId) {
|
||||
const team = await Team.getOneById(room.teamId);
|
||||
if (team) {
|
||||
await Message.saveSystemMessage('user-added-room-to-team', team.roomId, room.name || '', owner);
|
||||
}
|
||||
}
|
||||
callbacks.runAsync('afterCreateChannel', owner, room);
|
||||
} else if (type === 'p') {
|
||||
callbacks.runAsync('afterCreatePrivateGroup', owner, room);
|
||||
}
|
||||
callbacks.runAsync('afterCreateRoom', owner, room);
|
||||
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, room);
|
||||
return {
|
||||
rid: room._id, // backwards compatible
|
||||
inserted: true,
|
||||
...room,
|
||||
};
|
||||
};
|
||||
28
apps/meteor/server/lib/rooms/deleteRoom.ts
Normal file
28
apps/meteor/server/lib/rooms/deleteRoom.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Messages, Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
|
||||
import { callbacks } from '../callbacks';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener';
|
||||
|
||||
export const deleteRoom = async function (rid: string): Promise<void> {
|
||||
await FileUpload.removeFilesByRoomId(rid);
|
||||
|
||||
await Messages.removeByRoomId(rid);
|
||||
|
||||
await callbacks.run('beforeDeleteRoom', rid);
|
||||
|
||||
await Subscriptions.removeByRoomId(rid, {
|
||||
async onTrash(doc) {
|
||||
void notifyOnSubscriptionChanged(doc, 'removed');
|
||||
},
|
||||
});
|
||||
|
||||
await FileUpload.getStore('Avatars').deleteByRoomId(rid);
|
||||
|
||||
await callbacks.run('afterDeleteRoom', rid);
|
||||
|
||||
const { deletedCount } = await Rooms.removeById(rid);
|
||||
if (deletedCount) {
|
||||
void notifyOnRoomChangedById(rid, 'removed');
|
||||
}
|
||||
};
|
||||
54
apps/meteor/server/lib/rooms/executeUnbanUserFromRoom.ts
Normal file
54
apps/meteor/server/lib/rooms/executeUnbanUserFromRoom.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import { isBannedSubscription, isInviteSubscription, type IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
|
||||
import { afterUnbanFromRoomCallback } from '../callbacks/afterUnbanFromRoomCallback';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener';
|
||||
|
||||
export const executeUnbanUserFromRoom = async function (rid: string, user: IUser, byUser: IUser): Promise<void> {
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!user.username) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id);
|
||||
if (!subscription) {
|
||||
throw new Error('error-invalid-subscription');
|
||||
}
|
||||
|
||||
// if the subscription is an invite it means we were unbanned and then invited again, then
|
||||
// the invite was accepted and we receive a leave event (meaning the user was unbanned), so
|
||||
// at this point we just need send the message to say the user was unbanned.
|
||||
if (isInviteSubscription(subscription)) {
|
||||
await Message.saveSystemMessage('user-unbanned', rid, user.username, user, {
|
||||
u: { _id: byUser._id, username: byUser.username },
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// if the subscription exists and is not an invite and not banned
|
||||
if (!isBannedSubscription(subscription)) {
|
||||
throw new Error('error-user-not-banned');
|
||||
}
|
||||
|
||||
// Remove the subscription entirely — the user is no longer banned but also not a member.
|
||||
// Room count and __rooms were already adjusted during ban, so we only delete the document.
|
||||
await Subscriptions.removeById(subscription._id);
|
||||
|
||||
await Message.saveSystemMessage('user-unbanned', rid, user.username, user, {
|
||||
u: { _id: byUser._id, username: byUser.username },
|
||||
});
|
||||
|
||||
void notifyOnSubscriptionChanged(subscription, 'removed');
|
||||
void notifyOnRoomChangedById(rid);
|
||||
|
||||
const inviterUser = await Users.findOneById(byUser._id);
|
||||
if (inviterUser) {
|
||||
await afterUnbanFromRoomCallback.run({ unbannedUser: user, userWhoUnbanned: inviterUser }, room);
|
||||
}
|
||||
};
|
||||
30
apps/meteor/server/lib/rooms/findDirectRoomByIdentifier.ts
Normal file
30
apps/meteor/server/lib/rooms/findDirectRoomByIdentifier.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Users } from '@rocket.chat/models';
|
||||
|
||||
// direct rooms carry no `name`; the member set is what identifies them
|
||||
export const findDirectRoomByIdentifier = async (identifier: string, uid: IUser['_id']): Promise<IRoom | null> => {
|
||||
const targets = identifier.split(',').map((username) => username.trim());
|
||||
|
||||
// only a single target can be a room id, since ids never contain commas.
|
||||
if (targets.length === 1) {
|
||||
const byId = await Rooms.findByTypeAndNameOrId('d', targets[0]);
|
||||
if (byId) {
|
||||
return byId;
|
||||
}
|
||||
}
|
||||
|
||||
const me = await Users.findOneById<Pick<IUser, 'username'>>(uid, { projection: { username: 1 } });
|
||||
if (!me?.username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usernames = [...new Set([me.username, ...targets])];
|
||||
const members = await Users.findUsersByUsernames<Pick<IUser, '_id'>>(usernames, { projection: { _id: 1 } }).toArray();
|
||||
if (members.length !== usernames.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uids = members.map(({ _id }) => _id).sort();
|
||||
|
||||
return Rooms.findOneDirectRoomContainingAllUserIDs(uids);
|
||||
};
|
||||
25
apps/meteor/server/lib/rooms/getDefaultChannels.ts
Normal file
25
apps/meteor/server/lib/rooms/getDefaultChannels.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
|
||||
export async function getDefaultChannels(): Promise<IRoom[]> {
|
||||
const defaultRooms = await Rooms.findByDefaultAndTypes(true, ['c', 'p'], {
|
||||
projection: { usernames: 0 },
|
||||
}).toArray();
|
||||
const roomsThatAreGoingToBeJoined = new Set(defaultRooms.map((room) => room._id));
|
||||
|
||||
// If any of those are teams, we need to get all the channels that have the auto-join flag as well
|
||||
const teamRooms = defaultRooms.filter((room) => room.teamMain && room.teamId);
|
||||
if (teamRooms.length > 0) {
|
||||
for await (const teamRoom of teamRooms) {
|
||||
const defaultTeamRooms = await Rooms.findDefaultRoomsForTeam(teamRoom.teamId).toArray();
|
||||
|
||||
const defaultTeamRoomsThatWereNotAlreadyAdded = defaultTeamRooms.filter((channel) => !roomsThatAreGoingToBeJoined.has(channel._id));
|
||||
|
||||
defaultTeamRoomsThatWereNotAlreadyAdded.forEach((channel) => roomsThatAreGoingToBeJoined.add(channel._id));
|
||||
// Add the channels to the defaultRooms list
|
||||
defaultRooms.push(...defaultTeamRoomsThatWereNotAlreadyAdded);
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRooms;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Room } from '@rocket.chat/core-services';
|
||||
import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { isObject } from '../../../lib/utils/isObject';
|
||||
import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage';
|
||||
|
||||
export const getRoomByNameOrIdWithOptionToJoin = async ({
|
||||
user,
|
||||
nameOrId = '',
|
||||
type,
|
||||
tryDirectByUserIdOnly = false,
|
||||
joinChannel = true,
|
||||
errorOnEmpty = true,
|
||||
}: {
|
||||
user: Pick<IUser, '_id' | 'username' | 'federated' | 'federation'>;
|
||||
nameOrId: string;
|
||||
type?: RoomType;
|
||||
tryDirectByUserIdOnly?: boolean;
|
||||
joinChannel?: boolean;
|
||||
errorOnEmpty?: boolean;
|
||||
}): Promise<IRoom | null> => {
|
||||
let room: IRoom | null;
|
||||
|
||||
// If the nameOrId starts with #, then let's try to find a channel or group
|
||||
if (nameOrId.startsWith('#')) {
|
||||
nameOrId = nameOrId.substring(1);
|
||||
room = await Rooms.findOneByIdOrName(nameOrId);
|
||||
} else if (nameOrId.startsWith('@') || type === 'd') {
|
||||
// If the nameOrId starts with @ OR type is 'd', then let's try just a direct message
|
||||
nameOrId = nameOrId.replace('@', '');
|
||||
|
||||
let roomUser;
|
||||
if (tryDirectByUserIdOnly) {
|
||||
roomUser = await Users.findOneById(nameOrId);
|
||||
} else {
|
||||
roomUser = await Users.findOne({
|
||||
$or: [{ _id: nameOrId }, { username: nameOrId }],
|
||||
});
|
||||
}
|
||||
|
||||
room = isObject(roomUser)
|
||||
? await Rooms.findOneDirectRoomContainingAllUserIDs([...new Set([user._id, roomUser._id])])
|
||||
: await Rooms.findOneById(nameOrId);
|
||||
|
||||
// If the room hasn't been found yet, let's try some more
|
||||
if (!isObject(room)) {
|
||||
// If the roomUser wasn't found, then there's no destination to point towards
|
||||
// so return out based upon errorOnEmpty
|
||||
if (!isObject(roomUser)) {
|
||||
if (errorOnEmpty) {
|
||||
throw new Meteor.Error('invalid-channel');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const { rid } = await createDirectMessage([roomUser.username], user._id);
|
||||
|
||||
return Rooms.findOneById(rid);
|
||||
}
|
||||
} else {
|
||||
// Otherwise, we'll treat this as a channel or group.
|
||||
room = await Rooms.findOneByIdOrName(nameOrId);
|
||||
}
|
||||
|
||||
// If no room was found, handle the room return based upon errorOnEmpty
|
||||
if (!room && errorOnEmpty) {
|
||||
throw new Meteor.Error('invalid-channel');
|
||||
}
|
||||
|
||||
if (room === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If a room was found and they provided a type to search, then check
|
||||
// and if the type found isn't what we're looking for then handle
|
||||
// the return based upon errorOnEmpty
|
||||
if (type && room.t !== type) {
|
||||
if (errorOnEmpty) {
|
||||
throw new Meteor.Error('invalid-channel');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If the room type is channel and joinChannel has been passed, try to join them
|
||||
// if they can't join the room, this will error out!
|
||||
if (room.t === 'c' && joinChannel) {
|
||||
const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } });
|
||||
if (!sub) {
|
||||
await Room.join({ room, user });
|
||||
}
|
||||
}
|
||||
|
||||
return room;
|
||||
};
|
||||
81
apps/meteor/server/lib/rooms/getRoomsWithSingleOwner.ts
Normal file
81
apps/meteor/server/lib/rooms/getRoomsWithSingleOwner.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users } from '@rocket.chat/models';
|
||||
|
||||
import { subscriptionHasRole } from '../authorization';
|
||||
|
||||
export type SubscribedRoomsForUserWithDetails = {
|
||||
rid: string;
|
||||
t: string;
|
||||
shouldBeRemoved: boolean;
|
||||
shouldChangeOwner: boolean;
|
||||
userIsLastOwner: boolean;
|
||||
newOwner: IUser['_id'] | null;
|
||||
};
|
||||
|
||||
export function shouldRemoveOrChangeOwner(subscribedRooms: SubscribedRoomsForUserWithDetails[]): boolean {
|
||||
return subscribedRooms.some(({ shouldBeRemoved, shouldChangeOwner }) => shouldBeRemoved || shouldChangeOwner);
|
||||
}
|
||||
|
||||
export async function getSubscribedRoomsForUserWithDetails(
|
||||
userId: string,
|
||||
assignNewOwner = true,
|
||||
roomIds: string[] = [],
|
||||
): Promise<SubscribedRoomsForUserWithDetails[]> {
|
||||
const subscribedRooms: SubscribedRoomsForUserWithDetails[] = [];
|
||||
|
||||
const cursor =
|
||||
roomIds.length > 0 ? Subscriptions.findByUserIdAndRoomIds(userId, roomIds) : Subscriptions.findByUserIdExceptType(userId, 'd');
|
||||
|
||||
// Iterate through all the rooms the user is subscribed to, to check if he is the last owner of any of them.
|
||||
for await (const subscription of cursor) {
|
||||
const roomData: SubscribedRoomsForUserWithDetails = {
|
||||
rid: subscription.rid,
|
||||
t: subscription.t,
|
||||
shouldBeRemoved: false,
|
||||
shouldChangeOwner: false,
|
||||
userIsLastOwner: false,
|
||||
newOwner: null,
|
||||
};
|
||||
|
||||
if (subscriptionHasRole(subscription, 'owner')) {
|
||||
// Fetch the number of owners
|
||||
const numOwners = await Subscriptions.countByRoomIdAndRoles(subscription.rid, ['owner']);
|
||||
// If it's only one, then this user is the only owner.
|
||||
roomData.userIsLastOwner = numOwners === 1;
|
||||
if (numOwners === 1 && assignNewOwner) {
|
||||
// Let's check how many subscribers the room has.
|
||||
const options = { projection: { 'u._id': 1 }, sort: { ts: 1 as const } };
|
||||
const subscribersCursor = Subscriptions.findByRoomId(subscription.rid, options);
|
||||
|
||||
for await (const {
|
||||
u: { _id: uid },
|
||||
} of subscribersCursor) {
|
||||
// If we already changed the owner or this subscription is for the user we are removing, then don't try to give it ownership
|
||||
if (uid === userId) {
|
||||
continue;
|
||||
}
|
||||
const newOwner = await Users.findOneActiveById(uid, { projection: { _id: 1 } });
|
||||
if (!newOwner) {
|
||||
continue;
|
||||
}
|
||||
|
||||
roomData.newOwner = uid;
|
||||
roomData.shouldChangeOwner = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// If there's no subscriber available to be the new owner and it's not a public room, we can remove it.
|
||||
if (!roomData.shouldChangeOwner && roomData.t !== 'c') {
|
||||
roomData.shouldBeRemoved = true;
|
||||
}
|
||||
}
|
||||
} else if (roomData.t !== 'c') {
|
||||
// If the user is not an owner, remove the room if the user is the only subscriber
|
||||
roomData.shouldBeRemoved = (await Subscriptions.countByRoomId(roomData.rid)) === 1;
|
||||
}
|
||||
|
||||
subscribedRooms.push(roomData);
|
||||
}
|
||||
|
||||
return subscribedRooms;
|
||||
}
|
||||
126
apps/meteor/server/lib/rooms/invites/findOrCreateInvite.ts
Normal file
126
apps/meteor/server/lib/rooms/invites/findOrCreateInvite.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IInvite } from '@rocket.chat/core-typings';
|
||||
import { Invites, Subscriptions, Rooms } from '@rocket.chat/models';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
|
||||
import { settings } from '../../../settings';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { getURL } from '../../utils/getURL';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
function getInviteUrl(invite: Omit<IInvite, '_updatedAt'>) {
|
||||
const { _id } = invite;
|
||||
|
||||
const useDirectLink = settings.get<string>('Accounts_Registration_InviteUrlType') === 'direct';
|
||||
|
||||
return getURL(
|
||||
`invite/${_id}`,
|
||||
{
|
||||
full: useDirectLink,
|
||||
cloud: !useDirectLink,
|
||||
cloud_route: 'invite',
|
||||
},
|
||||
settings.get<string>('DeepLink_Url'),
|
||||
);
|
||||
}
|
||||
|
||||
const possibleDays = [0, 1, 7, 15, 30];
|
||||
const possibleUses = [0, 1, 5, 10, 25, 50, 100];
|
||||
|
||||
export const findOrCreateInvite = async (userId: string, invite: Pick<IInvite, 'rid' | 'days' | 'maxUses'>) => {
|
||||
if (!userId || !invite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!invite.rid) {
|
||||
throw new Meteor.Error('error-the-field-is-required', 'The field rid is required', {
|
||||
method: 'findOrCreateInvite',
|
||||
field: 'rid',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'create-invite-links', invite.rid))) {
|
||||
throw new Meteor.Error('not_authorized');
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(invite.rid, userId, {
|
||||
projection: { _id: 1 },
|
||||
});
|
||||
if (!subscription) {
|
||||
throw new Meteor.Error('error-invalid-room', 'The rid field is invalid', {
|
||||
method: 'findOrCreateInvite',
|
||||
field: 'rid',
|
||||
});
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(invite.rid);
|
||||
if (!room) {
|
||||
throw new Meteor.Error('error-invalid-room', 'The rid field is invalid', {
|
||||
method: 'findOrCreateInvite',
|
||||
field: 'rid',
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
|
||||
throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', {
|
||||
method: 'findOrCreateInvite',
|
||||
field: 'rid',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, userId))) {
|
||||
throw new Meteor.Error('error-room-type-not-allowed', 'Cannot create invite links for this room type', {
|
||||
method: 'findOrCreateInvite',
|
||||
});
|
||||
}
|
||||
|
||||
const { days = 1, maxUses = 0 } = invite;
|
||||
|
||||
if (!possibleDays.includes(days)) {
|
||||
throw new Meteor.Error('invalid-number-of-days', 'Invite should expire in 1, 7, 15 or 30 days, or send 0 to never expire.');
|
||||
}
|
||||
|
||||
if (!possibleUses.includes(maxUses)) {
|
||||
throw new Meteor.Error('invalid-number-of-uses', 'Invite should be valid for 1, 5, 10, 25, 50, 100 or infinite (0) uses.');
|
||||
}
|
||||
|
||||
// Before anything, let's check if there's an existing invite with the same settings for the same channel and user and that has not yet expired.
|
||||
const existing = await Invites.findOneByUserRoomMaxUsesAndExpiration(userId, invite.rid, maxUses, days);
|
||||
|
||||
// If an existing invite was found, return it's _id instead of creating a new one.
|
||||
if (existing) {
|
||||
existing.url = getInviteUrl(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const _id = Random.id(6);
|
||||
|
||||
// insert invite
|
||||
const createdAt = new Date();
|
||||
let expires = null;
|
||||
if (days > 0) {
|
||||
expires = new Date(createdAt);
|
||||
expires.setDate(expires.getDate() + days);
|
||||
}
|
||||
|
||||
const createInvite: Omit<IInvite, '_updatedAt'> = {
|
||||
_id,
|
||||
days,
|
||||
maxUses,
|
||||
rid: invite.rid,
|
||||
userId,
|
||||
createdAt,
|
||||
expires,
|
||||
url: '',
|
||||
uses: 0,
|
||||
};
|
||||
|
||||
await Invites.insertOne(createInvite);
|
||||
|
||||
void api.broadcast('notify.updateInvites', userId, { invite: createInvite });
|
||||
|
||||
createInvite.url = getInviteUrl(createInvite);
|
||||
return createInvite;
|
||||
};
|
||||
16
apps/meteor/server/lib/rooms/invites/listInvites.ts
Normal file
16
apps/meteor/server/lib/rooms/invites/listInvites.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Invites } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
export const listInvites = async (userId: string) => {
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'listInvites' });
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'create-invite-links'))) {
|
||||
throw new Meteor.Error('not_authorized');
|
||||
}
|
||||
|
||||
return Invites.find({}).toArray();
|
||||
};
|
||||
32
apps/meteor/server/lib/rooms/invites/removeInvite.ts
Normal file
32
apps/meteor/server/lib/rooms/invites/removeInvite.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { IInvite } from '@rocket.chat/core-typings';
|
||||
import { Invites } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
export const removeInvite = async (userId: string, invite: Pick<IInvite, '_id'>) => {
|
||||
if (!userId || !invite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'create-invite-links'))) {
|
||||
throw new Meteor.Error('not_authorized');
|
||||
}
|
||||
|
||||
if (!invite._id) {
|
||||
throw new Meteor.Error('error-the-field-is-required', 'The field _id is required', {
|
||||
method: 'removeInvite',
|
||||
field: '_id',
|
||||
});
|
||||
}
|
||||
|
||||
const { deletedCount } = await Invites.removeById(invite._id);
|
||||
|
||||
if (!deletedCount) {
|
||||
throw new Meteor.Error('invalid-invitation-id', 'Invalid Invitation _id', {
|
||||
method: 'removeInvite',
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
70
apps/meteor/server/lib/rooms/invites/sendInvitationEmail.ts
Normal file
70
apps/meteor/server/lib/rooms/invites/sendInvitationEmail.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import * as Mailer from '../../notifications/email/api';
|
||||
import { notifyOnSettingChanged } from '../../notifyListener';
|
||||
|
||||
let html = '';
|
||||
Meteor.startup(() => {
|
||||
Mailer.getTemplate('Invitation_Email', (value) => {
|
||||
html = value;
|
||||
});
|
||||
});
|
||||
|
||||
export const sendInvitationEmail = async (userId: string, emails: string[]) => {
|
||||
check(emails, [String]);
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
method: 'sendInvitationEmail',
|
||||
});
|
||||
}
|
||||
if (!(await hasPermissionAsync(userId, 'bulk-register-user'))) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
|
||||
method: 'sendInvitationEmail',
|
||||
});
|
||||
}
|
||||
const validEmails = emails.filter(Mailer.checkAddressFormat);
|
||||
|
||||
if (!validEmails || validEmails.length === 0) {
|
||||
throw new Meteor.Error('error-email-send-failed', 'No valid email addresses', {
|
||||
method: 'sendInvitationEmail',
|
||||
});
|
||||
}
|
||||
|
||||
const subject = settings.get<string>('Invitation_Subject');
|
||||
|
||||
if (!subject) {
|
||||
throw new Meteor.Error('error-email-send-failed', 'No subject', {
|
||||
method: 'sendInvitationEmail',
|
||||
});
|
||||
}
|
||||
|
||||
for (const email of validEmails) {
|
||||
try {
|
||||
await Mailer.send({
|
||||
to: email,
|
||||
from: settings.get('From_Email'),
|
||||
subject,
|
||||
html,
|
||||
data: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const value = await Settings.incrementValueById('Invitation_Email_Count', 1, { returnDocument: 'after' });
|
||||
if (value) {
|
||||
void notifyOnSettingChanged(value);
|
||||
}
|
||||
|
||||
continue;
|
||||
} catch ({ message }: any) {
|
||||
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, {
|
||||
method: 'sendInvitationEmail',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
69
apps/meteor/server/lib/rooms/invites/useInviteToken.ts
Normal file
69
apps/meteor/server/lib/rooms/invites/useInviteToken.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { isBannedSubscription } from '@rocket.chat/core-typings';
|
||||
import { Invites, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { validateInviteToken } from './validateInviteToken';
|
||||
import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
|
||||
import { addUserToRoom } from '../addUserToRoom';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
export const useInviteToken = async (userId: string, token: string) => {
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'The user is invalid', {
|
||||
method: 'useInviteToken',
|
||||
field: 'userId',
|
||||
});
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', {
|
||||
method: 'useInviteToken',
|
||||
field: 'token',
|
||||
});
|
||||
}
|
||||
|
||||
const { inviteData, room } = await validateInviteToken(token);
|
||||
|
||||
if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, userId))) {
|
||||
throw new Meteor.Error('error-room-type-not-allowed', "Can't join room of this type via invite", {
|
||||
method: 'useInviteToken',
|
||||
field: 'token',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(userId);
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'The user is invalid', {
|
||||
method: 'useInviteToken',
|
||||
field: 'userId',
|
||||
});
|
||||
}
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id);
|
||||
if (subscription && isBannedSubscription(subscription)) {
|
||||
throw new Meteor.Error('error-user-is-banned', 'User is banned from this room', {
|
||||
method: 'useInviteToken',
|
||||
});
|
||||
}
|
||||
|
||||
await Users.updateInviteToken(user._id, token);
|
||||
|
||||
if (!subscription) {
|
||||
await Invites.increaseUsageById(inviteData._id, 1);
|
||||
}
|
||||
|
||||
// If the user already has an username, then join the invite room,
|
||||
// If no username is set yet, then the the join will happen on the setUsername method
|
||||
if (user.username) {
|
||||
await addUserToRoom(room._id, user);
|
||||
}
|
||||
|
||||
return {
|
||||
room: {
|
||||
rid: inviteData.rid,
|
||||
prid: room.prid,
|
||||
fname: room.fname,
|
||||
name: room.name,
|
||||
t: room.t,
|
||||
},
|
||||
};
|
||||
};
|
||||
55
apps/meteor/server/lib/rooms/invites/validateInviteToken.ts
Normal file
55
apps/meteor/server/lib/rooms/invites/validateInviteToken.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Invites, Rooms } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export const validateInviteToken = async (token: string) => {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', {
|
||||
method: 'validateInviteToken',
|
||||
field: 'token',
|
||||
});
|
||||
}
|
||||
|
||||
const inviteData = await Invites.findOneById(token);
|
||||
if (!inviteData) {
|
||||
throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', {
|
||||
method: 'validateInviteToken',
|
||||
field: 'token',
|
||||
});
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(inviteData.rid);
|
||||
if (!room) {
|
||||
throw new Meteor.Error('error-invalid-room', 'The invite token is invalid.', {
|
||||
method: 'validateInviteToken',
|
||||
field: 'rid',
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
|
||||
throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', {
|
||||
method: 'validateInviteToken',
|
||||
field: 'rid',
|
||||
});
|
||||
}
|
||||
|
||||
if (inviteData.expires && new Date(inviteData.expires).getTime() <= Date.now()) {
|
||||
throw new Meteor.Error('error-invite-expired', 'The invite token has expired.', {
|
||||
method: 'validateInviteToken',
|
||||
field: 'expires',
|
||||
});
|
||||
}
|
||||
|
||||
if (inviteData.maxUses > 0 && inviteData.uses >= inviteData.maxUses) {
|
||||
throw new Meteor.Error('error-invite-expired', 'The invite token has expired.', {
|
||||
method: 'validateInviteToken',
|
||||
field: 'maxUses',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
inviteData,
|
||||
room,
|
||||
};
|
||||
};
|
||||
13
apps/meteor/server/lib/rooms/joinDefaultChannels.ts
Normal file
13
apps/meteor/server/lib/rooms/joinDefaultChannels.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { addUserToDefaultChannels } from './addUserToDefaultChannels';
|
||||
|
||||
export const joinDefaultChannels = async (userId: string, silenced?: boolean): Promise<void> => {
|
||||
const user = await Users.findOneById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
return addUserToDefaultChannels(user, Boolean(silenced));
|
||||
};
|
||||
85
apps/meteor/server/lib/rooms/relinquishRoomOwnerships.ts
Normal file
85
apps/meteor/server/lib/rooms/relinquishRoomOwnerships.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive, Team } from '@rocket.chat/models';
|
||||
|
||||
import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner';
|
||||
import { eraseRoomLooseValidation, eraseTeamOnRelinquishRoomOwnerships } from '../../api/lib/eraseTeam';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
import { notifyOnSubscriptionChanged } from '../notifyListener';
|
||||
import { addUserRolesAsync } from '../roles/addUserRoles';
|
||||
|
||||
const bulkTeamCleanup = async (rids: IRoom['_id'][]) => {
|
||||
const rooms = (await Rooms.findByIds(rids, { projection: { teamId: 1, teamMain: 1 } }).toArray()) as Pick<
|
||||
IRoom,
|
||||
'_id' | 'teamId' | 'teamMain'
|
||||
>[];
|
||||
|
||||
const teamsToRemove = rooms.filter((room) => room.teamMain);
|
||||
const teamIds = teamsToRemove.map((room) => room.teamId).filter((teamId) => teamId !== undefined);
|
||||
const uniqueTeamIds = [...new Set(teamIds)];
|
||||
|
||||
const deletedRoomIds: string[] = [];
|
||||
await Promise.all(
|
||||
uniqueTeamIds.map(async (teamId) => {
|
||||
const team = await Team.findOneById(teamId);
|
||||
if (!team) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = await eraseTeamOnRelinquishRoomOwnerships(team, []);
|
||||
ids.forEach((id) => deletedRoomIds.push(id));
|
||||
}),
|
||||
);
|
||||
return deletedRoomIds;
|
||||
};
|
||||
|
||||
const bulkRoomCleanUp = async (rids: string[]) => {
|
||||
// no bulk deletion for files
|
||||
await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid)));
|
||||
|
||||
const [, , , , deletedRoomIds] = await Promise.all([
|
||||
Subscriptions.removeByRoomIds(rids, {
|
||||
async onTrash(doc) {
|
||||
void notifyOnSubscriptionChanged(doc, 'removed');
|
||||
},
|
||||
}),
|
||||
Messages.removeByRoomIds(rids),
|
||||
ReadReceipts.removeByRoomIds(rids),
|
||||
ReadReceiptsArchive.removeByRoomIds(rids),
|
||||
bulkTeamCleanup(rids),
|
||||
]);
|
||||
|
||||
const restRidsToRemove = rids.filter((rid) => !deletedRoomIds.includes(rid));
|
||||
await Promise.all(
|
||||
restRidsToRemove.map(async (rid) => {
|
||||
const isDeleted = await eraseRoomLooseValidation(rid);
|
||||
if (isDeleted) {
|
||||
deletedRoomIds.push(rid);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return deletedRoomIds;
|
||||
};
|
||||
|
||||
export const relinquishRoomOwnerships = async function (
|
||||
userId: string,
|
||||
subscribedRooms: SubscribedRoomsForUserWithDetails[],
|
||||
removeDirectMessages = true,
|
||||
) {
|
||||
// change owners
|
||||
const changeOwner = subscribedRooms.filter(({ shouldChangeOwner }) => shouldChangeOwner);
|
||||
|
||||
for (const { newOwner, rid } of changeOwner) {
|
||||
newOwner && (await addUserRolesAsync(newOwner, ['owner'], rid));
|
||||
}
|
||||
|
||||
const roomIdsToRemove: string[] = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }) => rid);
|
||||
|
||||
if (removeDirectMessages) {
|
||||
(await Rooms.find1On1ByUserId(userId, { projection: { _id: 1 } }).toArray()).map(({ _id }: { _id: string }) =>
|
||||
roomIdsToRemove.push(_id),
|
||||
);
|
||||
}
|
||||
|
||||
return bulkRoomCleanUp(roomIdsToRemove);
|
||||
};
|
||||
120
apps/meteor/server/lib/rooms/removeUserFromRoom.ts
Normal file
120
apps/meteor/server/lib/rooms/removeUserFromRoom.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Apps, AppEvents } from '@rocket.chat/apps';
|
||||
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
|
||||
import { Message, Team, Room } from '@rocket.chat/core-services';
|
||||
import type { IRoom, IUser, MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Rooms } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
import { afterLeaveRoomCallback } from '../callbacks/afterLeaveRoomCallback';
|
||||
import { beforeLeaveRoomCallback } from '../callbacks/beforeLeaveRoomCallback';
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener';
|
||||
|
||||
/**
|
||||
* Removes a user from a room when triggered by federation or other external events.
|
||||
* Executes only the necessary database operations, with no callbacks, to prevent
|
||||
* propagation loops during external event processing.
|
||||
*/
|
||||
export const performUserRemoval = async function (
|
||||
room: IRoom,
|
||||
user: IUser,
|
||||
options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues },
|
||||
): Promise<void> {
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, {
|
||||
projection: { _id: 1, status: 1 },
|
||||
});
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
// make TS happy, this should never happen
|
||||
if (!user.username) {
|
||||
throw new Error('User must have a username to be removed from the room');
|
||||
}
|
||||
|
||||
// TODO: move before callbacks to service
|
||||
await beforeLeaveRoomCallback.run(user, room);
|
||||
|
||||
if (subscription) {
|
||||
if (options?.customSystemMessage) {
|
||||
await Message.saveSystemMessage(options?.customSystemMessage, room._id, user.username || '', user);
|
||||
} else if (options?.byUser) {
|
||||
const extraData = {
|
||||
u: options.byUser,
|
||||
};
|
||||
|
||||
if (room.teamMain) {
|
||||
await Message.saveSystemMessage('removed-user-from-team', room._id, user.username, user, extraData);
|
||||
} else {
|
||||
await Message.saveSystemMessage('ru', room._id, user.username, user, extraData);
|
||||
}
|
||||
} else if (subscription.status === 'INVITED') {
|
||||
await Message.saveSystemMessage('uir', room._id, user.username, user);
|
||||
} else if (room.teamMain) {
|
||||
await Message.saveSystemMessage('ult', room._id, user.username, user);
|
||||
} else {
|
||||
await Message.saveSystemMessage('ul', room._id, user.username, user);
|
||||
}
|
||||
}
|
||||
|
||||
if (room.t === 'l') {
|
||||
await Message.saveSystemMessage('command', room._id, 'survey', user);
|
||||
}
|
||||
|
||||
const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(room._id, user._id);
|
||||
if (deletedSubscription) {
|
||||
void notifyOnSubscriptionChanged(deletedSubscription, 'removed');
|
||||
}
|
||||
|
||||
if (room.teamId && room.teamMain) {
|
||||
await Team.removeMember(room.teamId, user._id);
|
||||
}
|
||||
|
||||
if (room.encrypted && settings.get('E2E_Enable')) {
|
||||
await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]);
|
||||
}
|
||||
|
||||
// remove references to the user in direct message rooms
|
||||
if (room.t === 'd') {
|
||||
await Rooms.removeUserReferenceFromDMsById(room._id, user.username, user._id);
|
||||
}
|
||||
|
||||
void notifyOnRoomChangedById(room._id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a user from the given room by performing the required database updates
|
||||
* and triggering all standard callbacks. Used for local actions (UI or API)
|
||||
* that should propagate normally to federation and other subscribers.
|
||||
*/
|
||||
export const removeUserFromRoom = async function (
|
||||
rid: string,
|
||||
user: IUser,
|
||||
options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues },
|
||||
): Promise<void> {
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rationale: for an abac room, we don't want apps to be able to prevent a user from leaving
|
||||
if (!options?.skipAppPreEvents) {
|
||||
try {
|
||||
await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser);
|
||||
} catch (error: any) {
|
||||
if (error.name === AppsEngineException.name) {
|
||||
throw new Meteor.Error('error-app-prevented', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await Room.beforeLeave(room);
|
||||
|
||||
await performUserRemoval(room, user, options);
|
||||
|
||||
await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room);
|
||||
|
||||
await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser);
|
||||
};
|
||||
133
apps/meteor/server/lib/rooms/retention/cronPruneMessages.ts
Normal file
133
apps/meteor/server/lib/rooms/retention/cronPruneMessages.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
|
||||
import { getCronAdvancedTimerFromPrecisionSetting } from '../../../../lib/getCronAdvancedTimerFromPrecisionSetting';
|
||||
import { settings } from '../../../settings';
|
||||
import { cleanRoomHistory } from '../cleanRoomHistory';
|
||||
|
||||
type RetentionRoomTypes = 'c' | 'p' | 'd';
|
||||
|
||||
const getMaxAgeSettingIdByRoomType = (type: RetentionRoomTypes) => {
|
||||
switch (type) {
|
||||
case 'c':
|
||||
return settings.get<number>('RetentionPolicy_TTL_Channels');
|
||||
case 'p':
|
||||
return settings.get<number>('RetentionPolicy_TTL_Groups');
|
||||
case 'd':
|
||||
return settings.get<number>('RetentionPolicy_TTL_DMs');
|
||||
}
|
||||
};
|
||||
|
||||
let types: RetentionRoomTypes[] = [];
|
||||
|
||||
const oldest = new Date('0001-01-01T00:00:00Z');
|
||||
|
||||
const toDays = (d: number): number => d * 1000 * 60 * 60 * 24;
|
||||
|
||||
async function job(): Promise<void> {
|
||||
const now = new Date();
|
||||
const filesOnly = settings.get<boolean>('RetentionPolicy_FilesOnly');
|
||||
const excludePinned = settings.get<boolean>('RetentionPolicy_DoNotPrunePinned');
|
||||
const ignoreDiscussion = settings.get<boolean>('RetentionPolicy_DoNotPruneDiscussion');
|
||||
const ignoreThreads = settings.get<boolean>('RetentionPolicy_DoNotPruneThreads');
|
||||
|
||||
const ignoreDiscussionQuery = ignoreDiscussion ? { prid: { $exists: false } } : {};
|
||||
|
||||
// get all rooms with default values
|
||||
for (const type of types) {
|
||||
const maxAge = getMaxAgeSettingIdByRoomType(type) || 0;
|
||||
const latest = new Date(now.getTime() - maxAge);
|
||||
|
||||
const rooms = await Rooms.find(
|
||||
{
|
||||
't': type,
|
||||
'$or': [{ 'retention.enabled': { $eq: true } }, { 'retention.enabled': { $exists: false } }],
|
||||
'retention.overrideGlobal': { $ne: true },
|
||||
...ignoreDiscussionQuery,
|
||||
},
|
||||
{ projection: { _id: 1 } },
|
||||
).toArray();
|
||||
|
||||
for await (const { _id: rid } of rooms) {
|
||||
await cleanRoomHistory({
|
||||
rid,
|
||||
latest,
|
||||
oldest,
|
||||
filesOnly,
|
||||
excludePinned,
|
||||
ignoreDiscussion,
|
||||
ignoreThreads,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rooms = await Rooms.find<IRoomWithRetentionPolicy>(
|
||||
{
|
||||
'retention.enabled': { $eq: true },
|
||||
'retention.overrideGlobal': { $eq: true },
|
||||
'retention.maxAge': { $gte: 0 },
|
||||
...ignoreDiscussionQuery,
|
||||
},
|
||||
{ projection: { _id: 1, retention: 1 } },
|
||||
).toArray();
|
||||
|
||||
for await (const { _id: rid, retention } of rooms) {
|
||||
const { maxAge = 30, filesOnly, excludePinned, ignoreThreads } = retention;
|
||||
const latest = new Date(now.getTime() - toDays(maxAge));
|
||||
await cleanRoomHistory({
|
||||
rid,
|
||||
latest,
|
||||
oldest,
|
||||
filesOnly,
|
||||
excludePinned,
|
||||
ignoreDiscussion,
|
||||
ignoreThreads,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pruneCronName = 'Prune old messages by retention policy';
|
||||
|
||||
async function deployCron(precision: string): Promise<void> {
|
||||
if (await cronJobs.has(pruneCronName)) {
|
||||
await cronJobs.remove(pruneCronName);
|
||||
}
|
||||
await cronJobs.add(pruneCronName, precision, async () => job());
|
||||
}
|
||||
|
||||
settings.watchMultiple(
|
||||
[
|
||||
'RetentionPolicy_Enabled',
|
||||
'RetentionPolicy_AppliesToChannels',
|
||||
'RetentionPolicy_AppliesToGroups',
|
||||
'RetentionPolicy_AppliesToDMs',
|
||||
'RetentionPolicy_Advanced_Precision',
|
||||
'RetentionPolicy_Advanced_Precision_Cron',
|
||||
'RetentionPolicy_Precision',
|
||||
],
|
||||
async function reloadPolicy() {
|
||||
types = [];
|
||||
|
||||
if (!settings.get('RetentionPolicy_Enabled')) {
|
||||
return cronJobs.remove(pruneCronName);
|
||||
}
|
||||
if (settings.get('RetentionPolicy_AppliesToChannels')) {
|
||||
types.push('c');
|
||||
}
|
||||
|
||||
if (settings.get('RetentionPolicy_AppliesToGroups')) {
|
||||
types.push('p');
|
||||
}
|
||||
|
||||
if (settings.get('RetentionPolicy_AppliesToDMs')) {
|
||||
types.push('d');
|
||||
}
|
||||
|
||||
const precision =
|
||||
(settings.get<boolean>('RetentionPolicy_Advanced_Precision') && settings.get<string>('RetentionPolicy_Advanced_Precision_Cron')) ||
|
||||
getCronAdvancedTimerFromPrecisionSetting(settings.get('RetentionPolicy_Precision'));
|
||||
|
||||
return deployCron(precision);
|
||||
},
|
||||
);
|
||||
1
apps/meteor/server/lib/rooms/retention/index.ts
Normal file
1
apps/meteor/server/lib/rooms/retention/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './cronPruneMessages';
|
||||
109
apps/meteor/server/lib/rooms/roomCoordinator.ts
Normal file
109
apps/meteor/server/lib/rooms/roomCoordinator.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { getUserDisplayName } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, IUpload } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { IRoomTypeConfig, IRoomTypeServerDirectives, RoomSettingsEnum, RoomMemberActions } from '../../../definition/IRoomTypeConfig';
|
||||
import { RoomCoordinator } from '../../../lib/rooms/coordinator';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
class RoomCoordinatorServer extends RoomCoordinator {
|
||||
add(roomConfig: IRoomTypeConfig, directives: Partial<IRoomTypeServerDirectives>): void {
|
||||
this.addRoomType(roomConfig, {
|
||||
allowRoomSettingChange(_room: IRoom, _setting: ValueOf<typeof RoomSettingsEnum>) {
|
||||
return true;
|
||||
},
|
||||
async allowMemberAction(_room: IRoom, _action: ValueOf<typeof RoomMemberActions>, _userId?: IUser['_id']): Promise<boolean> {
|
||||
return false;
|
||||
},
|
||||
async roomName(_room: IRoom, _userId?: string): Promise<string> {
|
||||
return '';
|
||||
},
|
||||
isGroupChat(_room: IRoom): boolean {
|
||||
return false;
|
||||
},
|
||||
async canBeDeleted(hasPermission: (permissionId: string, rid?: string) => Promise<boolean> | boolean, room: IRoom): Promise<boolean> {
|
||||
if (!hasPermission && typeof hasPermission !== 'function') {
|
||||
throw new Error('You MUST provide the "hasPermission" to canBeDeleted function');
|
||||
}
|
||||
return hasPermission(`delete-${room.t}`, room._id);
|
||||
},
|
||||
preventRenaming(): boolean {
|
||||
return false;
|
||||
},
|
||||
async getDiscussionType(): Promise<RoomType> {
|
||||
return 'p';
|
||||
},
|
||||
async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }, _file?: IUpload): Promise<boolean> {
|
||||
return false;
|
||||
},
|
||||
async getNotificationDetails(
|
||||
room: IRoom,
|
||||
sender: AtLeast<IUser, '_id' | 'name' | 'username'>,
|
||||
notificationMessage: string,
|
||||
userId: string,
|
||||
): Promise<{ title: string | undefined; text: string; name: string | undefined }> {
|
||||
const title = `#${await this.roomName(room, userId)}`;
|
||||
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
|
||||
const senderName = getUserDisplayName(sender.name, sender.username, useRealName);
|
||||
|
||||
const text = `${senderName}: ${notificationMessage}`;
|
||||
|
||||
return { title, text, name: room.name };
|
||||
},
|
||||
getMsgSender(message: IMessage): Promise<IUser | null> {
|
||||
return Users.findOneById(message.u._id);
|
||||
},
|
||||
includeInRoomSearch(): boolean {
|
||||
return false;
|
||||
},
|
||||
includeInDashboard(): boolean {
|
||||
return false;
|
||||
},
|
||||
|
||||
...directives,
|
||||
config: roomConfig,
|
||||
});
|
||||
}
|
||||
|
||||
getRoomDirectives(roomType: string): IRoomTypeServerDirectives {
|
||||
const directives = this.roomTypes[roomType]?.directives;
|
||||
|
||||
if (!directives) {
|
||||
throw new Error(`Room type ${roomType} not found`);
|
||||
}
|
||||
return directives as IRoomTypeServerDirectives;
|
||||
}
|
||||
|
||||
getTypesToShowOnDashboard(): Array<IRoomTypeConfig['identifier']> {
|
||||
return Object.keys(this.roomTypes).filter((key) => (this.roomTypes[key].directives as IRoomTypeServerDirectives).includeInDashboard());
|
||||
}
|
||||
|
||||
async getRoomName(roomType: string, roomData: IRoom, userId?: string): Promise<string> {
|
||||
return (await this.getRoomDirectives(roomType).roomName(roomData, userId)) ?? '';
|
||||
}
|
||||
|
||||
setRoomFind(roomType: string, roomFind: Required<Pick<IRoomTypeServerDirectives, 'roomFind'>>['roomFind']): void {
|
||||
const directives = this.getRoomDirectives(roomType);
|
||||
if (!directives) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (directives.roomFind) {
|
||||
throw new Error('Room find for the given type already exists');
|
||||
}
|
||||
|
||||
directives.roomFind = roomFind;
|
||||
}
|
||||
|
||||
getRoomFind(roomType: string): Required<Pick<IRoomTypeServerDirectives, 'roomFind'>>['roomFind'] | undefined {
|
||||
return this.getRoomDirectives(roomType).roomFind;
|
||||
}
|
||||
|
||||
searchableRoomTypes(): Array<string> {
|
||||
return Object.entries(this.roomTypes)
|
||||
.filter(([_identifier, { directives }]) => (directives as IRoomTypeServerDirectives).includeInRoomSearch())
|
||||
.map(([identifier]) => identifier);
|
||||
}
|
||||
}
|
||||
|
||||
export const roomCoordinator = new RoomCoordinatorServer();
|
||||
6
apps/meteor/server/lib/rooms/roomTypes/conversation.ts
Normal file
6
apps/meteor/server/lib/rooms/roomTypes/conversation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getConversationRoomType } from '../../../../lib/rooms/roomTypes/conversation';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const ConversationRoomType = getConversationRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(ConversationRoomType, {});
|
||||
118
apps/meteor/server/lib/rooms/roomTypes/direct.ts
Normal file
118
apps/meteor/server/lib/rooms/roomTypes/direct.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { AtLeast } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions } from '@rocket.chat/models';
|
||||
|
||||
import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig';
|
||||
import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
|
||||
import { getDirectMessageRoomType } from '../../../../lib/rooms/roomTypes/direct';
|
||||
import { isFederationEnabled } from '../../../services/federation/utils';
|
||||
import { settings } from '../../../settings';
|
||||
import { findDirectRoomByIdentifier } from '../findDirectRoomByIdentifier';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const DirectMessageRoomType = getDirectMessageRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(DirectMessageRoomType, {
|
||||
allowRoomSettingChange(_room, setting) {
|
||||
if (isRoomFederated(_room)) {
|
||||
if (isRoomNativeFederated(_room) && isFederationEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (setting) {
|
||||
case RoomSettingsEnum.TYPE:
|
||||
case RoomSettingsEnum.NAME:
|
||||
case RoomSettingsEnum.SYSTEM_MESSAGES:
|
||||
case RoomSettingsEnum.DESCRIPTION:
|
||||
case RoomSettingsEnum.READ_ONLY:
|
||||
case RoomSettingsEnum.REACT_WHEN_READ_ONLY:
|
||||
case RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE:
|
||||
case RoomSettingsEnum.JOIN_CODE:
|
||||
return false;
|
||||
case RoomSettingsEnum.E2E:
|
||||
return settings.get('E2E_Enable') === true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async allowMemberAction(room, action, _userId) {
|
||||
if (isRoomFederated(room)) {
|
||||
if (isRoomNativeFederated(room) && isFederationEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (action) {
|
||||
case RoomMemberActions.BLOCK:
|
||||
return !this.isGroupChat(room);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async roomName(room, uid) {
|
||||
const subscription = await (async (): Promise<{ fname?: string; name?: string } | null> => {
|
||||
if (room.fname || room.name) {
|
||||
return {
|
||||
fname: room.fname,
|
||||
name: room.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (!room._id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uid) {
|
||||
return Subscriptions.findOneByRoomIdAndUserId(room._id, uid, { projection: { name: 1, fname: 1 } });
|
||||
}
|
||||
|
||||
// If we don't know what user is requesting the roomName, then any subscription will do
|
||||
return Subscriptions.findOne({ rid: room._id }, { projection: { name: 1, fname: 1 } });
|
||||
})();
|
||||
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.get('UI_Use_Real_Name') && room.fname) {
|
||||
return subscription.fname;
|
||||
}
|
||||
|
||||
return subscription.name;
|
||||
},
|
||||
|
||||
isGroupChat(room) {
|
||||
return (room?.uids?.length || 0) > 2;
|
||||
},
|
||||
|
||||
roomFind(identifier, uid) {
|
||||
return uid ? findDirectRoomByIdentifier(identifier, uid) : undefined;
|
||||
},
|
||||
|
||||
async getNotificationDetails(room, sender, notificationMessage, userId) {
|
||||
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
|
||||
|
||||
const displayRoomName = await this.roomName(room, userId);
|
||||
|
||||
if (this.isGroupChat(room)) {
|
||||
return {
|
||||
title: displayRoomName,
|
||||
text: `${(useRealName && sender.name) || sender.username}: ${notificationMessage}`,
|
||||
name: room.name || displayRoomName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: (useRealName && sender.name) || sender.username,
|
||||
text: notificationMessage,
|
||||
name: room.name || displayRoomName,
|
||||
};
|
||||
},
|
||||
|
||||
includeInDashboard() {
|
||||
return true;
|
||||
},
|
||||
} satisfies AtLeast<IRoomTypeServerDirectives, 'isGroupChat' | 'roomName'>);
|
||||
6
apps/meteor/server/lib/rooms/roomTypes/favorite.ts
Normal file
6
apps/meteor/server/lib/rooms/roomTypes/favorite.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getFavoriteRoomType } from '../../../../lib/rooms/roomTypes/favorite';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const FavoriteRoomType = getFavoriteRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(FavoriteRoomType, {});
|
||||
7
apps/meteor/server/lib/rooms/roomTypes/index.ts
Normal file
7
apps/meteor/server/lib/rooms/roomTypes/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import './conversation';
|
||||
import './direct';
|
||||
import './favorite';
|
||||
import './livechat';
|
||||
import './private';
|
||||
import './public';
|
||||
import './unread';
|
||||
53
apps/meteor/server/lib/rooms/roomTypes/livechat.ts
Normal file
53
apps/meteor/server/lib/rooms/roomTypes/livechat.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { AtLeast, ValueOf } from '@rocket.chat/core-typings';
|
||||
import { isMessageFromVisitor } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
|
||||
import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig';
|
||||
import { getLivechatRoomType } from '../../../../lib/rooms/roomTypes/livechat';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const LivechatRoomType = getLivechatRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(LivechatRoomType, {
|
||||
allowRoomSettingChange(_room, setting) {
|
||||
switch (setting) {
|
||||
case RoomSettingsEnum.JOIN_CODE:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async allowMemberAction(_room, action) {
|
||||
return ([RoomMemberActions.INVITE, RoomMemberActions.JOIN] as Array<ValueOf<typeof RoomMemberActions>>).includes(action);
|
||||
},
|
||||
|
||||
async roomName(room, _userId?) {
|
||||
return (room.name || room.fname || (room as any).label) as string;
|
||||
},
|
||||
|
||||
async canAccessUploadedFile({ rc_token: token, rc_rid: rid }, file) {
|
||||
if (!token || !rid) {
|
||||
return false;
|
||||
}
|
||||
if (file?.rid && file.rid !== rid) {
|
||||
return false;
|
||||
}
|
||||
return !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token));
|
||||
},
|
||||
|
||||
async getNotificationDetails(room, _sender, notificationMessage, userId) {
|
||||
const roomName = await this.roomName(room, userId);
|
||||
const title = `[Omnichannel] ${roomName}`;
|
||||
const text = notificationMessage;
|
||||
|
||||
return { title, text, name: roomName };
|
||||
},
|
||||
|
||||
async getMsgSender(message) {
|
||||
if (isMessageFromVisitor(message)) {
|
||||
return LivechatVisitors.findOneEnabledById(message.u._id);
|
||||
}
|
||||
},
|
||||
} as AtLeast<IRoomTypeServerDirectives, 'roomName'>);
|
||||
70
apps/meteor/server/lib/rooms/roomTypes/private.ts
Normal file
70
apps/meteor/server/lib/rooms/roomTypes/private.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
|
||||
|
||||
import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
|
||||
import { getPrivateRoomType } from '../../../../lib/rooms/roomTypes/private';
|
||||
import { isFederationEnabled } from '../../../services/federation/utils';
|
||||
import { settings } from '../../../settings';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const PrivateRoomType = getPrivateRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(PrivateRoomType, {
|
||||
allowRoomSettingChange(room, setting) {
|
||||
if (isRoomFederated(room)) {
|
||||
if (isRoomNativeFederated(room) && isFederationEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (setting) {
|
||||
case RoomSettingsEnum.JOIN_CODE:
|
||||
return false;
|
||||
case RoomSettingsEnum.BROADCAST:
|
||||
return Boolean(room.broadcast);
|
||||
case RoomSettingsEnum.READ_ONLY:
|
||||
return !room.broadcast;
|
||||
case RoomSettingsEnum.REACT_WHEN_READ_ONLY:
|
||||
return Boolean(!room.broadcast && room.ro);
|
||||
case RoomSettingsEnum.E2E:
|
||||
return settings.get('E2E_Enable') === true;
|
||||
case RoomSettingsEnum.SYSTEM_MESSAGES:
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async allowMemberAction(_room, action, _userId) {
|
||||
if (isRoomFederated(_room as IRoom)) {
|
||||
if (isRoomNativeFederated(_room) && isFederationEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (action) {
|
||||
case RoomMemberActions.BLOCK:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async roomName(room, _userId?) {
|
||||
if (room.prid || isRoomFederated(room)) {
|
||||
return room.fname;
|
||||
}
|
||||
if (settings.get('UI_Allow_room_names_with_special_chars')) {
|
||||
return room.fname || room.name;
|
||||
}
|
||||
|
||||
return room.name;
|
||||
},
|
||||
|
||||
isGroupChat(_room) {
|
||||
return true;
|
||||
},
|
||||
|
||||
includeInDashboard() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
83
apps/meteor/server/lib/rooms/roomTypes/public.ts
Normal file
83
apps/meteor/server/lib/rooms/roomTypes/public.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Team } from '@rocket.chat/core-services';
|
||||
import type { AtLeast, IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated, isRoomNativeFederated, TeamType } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig';
|
||||
import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
|
||||
import { getPublicRoomType } from '../../../../lib/rooms/roomTypes/public';
|
||||
import { isFederationEnabled } from '../../../services/federation/utils';
|
||||
import { settings } from '../../../settings';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const PublicRoomType = getPublicRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(PublicRoomType, {
|
||||
allowRoomSettingChange(room, setting) {
|
||||
if (isRoomFederated(room)) {
|
||||
if (isRoomNativeFederated(room) && isFederationEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (setting) {
|
||||
case RoomSettingsEnum.BROADCAST:
|
||||
return Boolean(room.broadcast);
|
||||
case RoomSettingsEnum.READ_ONLY:
|
||||
return Boolean(!room.broadcast);
|
||||
case RoomSettingsEnum.REACT_WHEN_READ_ONLY:
|
||||
return Boolean(!room.broadcast && room.ro);
|
||||
case RoomSettingsEnum.E2E:
|
||||
return false;
|
||||
case RoomSettingsEnum.SYSTEM_MESSAGES:
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async allowMemberAction(_room, action, _userId) {
|
||||
if (isRoomFederated(_room as IRoom)) {
|
||||
if (isRoomNativeFederated(_room) && isFederationEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (action) {
|
||||
case RoomMemberActions.BLOCK:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
async roomName(room, _userId?) {
|
||||
if (room.prid || isRoomFederated(room)) {
|
||||
return room.fname;
|
||||
}
|
||||
if (settings.get('UI_Allow_room_names_with_special_chars')) {
|
||||
return room.fname || room.name;
|
||||
}
|
||||
return room.name;
|
||||
},
|
||||
|
||||
isGroupChat(_room) {
|
||||
return true;
|
||||
},
|
||||
|
||||
includeInDashboard() {
|
||||
return true;
|
||||
},
|
||||
|
||||
async getDiscussionType(room) {
|
||||
if (room?.teamId) {
|
||||
const team = await Team.getOneById(room.teamId, { projection: { type: 1 } });
|
||||
if (team?.type === TeamType.PRIVATE) {
|
||||
return 'p';
|
||||
}
|
||||
}
|
||||
return 'c';
|
||||
},
|
||||
|
||||
includeInRoomSearch() {
|
||||
return true;
|
||||
},
|
||||
} as AtLeast<IRoomTypeServerDirectives, 'isGroupChat' | 'roomName'>);
|
||||
6
apps/meteor/server/lib/rooms/roomTypes/unread.ts
Normal file
6
apps/meteor/server/lib/rooms/roomTypes/unread.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getUnreadRoomType } from '../../../../lib/rooms/roomTypes/unread';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const UnreadRoomType = getUnreadRoomType(roomCoordinator);
|
||||
|
||||
roomCoordinator.add(UnreadRoomType, {});
|
||||
51
apps/meteor/server/lib/rooms/setRoomAvatar.ts
Normal file
51
apps/meteor/server/lib/rooms/setRoomAvatar.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { api, Message } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { isRegisterUser } from '@rocket.chat/core-typings';
|
||||
import { Avatars, Rooms } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { RocketChatFile } from '../media/file';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
|
||||
export const setRoomAvatar = async function (rid: string, dataURI: string, user: IUser): Promise<void> {
|
||||
if (!isRegisterUser(user)) {
|
||||
throw new Meteor.Error('invalid-user', 'Invalid user', {
|
||||
function: 'RocketChat.setRoomAvatar',
|
||||
});
|
||||
}
|
||||
|
||||
const fileStore = FileUpload.getStore('Avatars');
|
||||
|
||||
const current = await Avatars.findOneByRoomId(rid);
|
||||
|
||||
if (!dataURI) {
|
||||
await fileStore.deleteByRoomId(rid);
|
||||
await Message.saveSystemMessage('room_changed_avatar', rid, '', user);
|
||||
void api.broadcast('room.avatarUpdate', { _id: rid });
|
||||
await Rooms.unsetAvatarData(rid);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = RocketChatFile.dataURIParse(dataURI);
|
||||
|
||||
const buffer = Buffer.from(fileData.image, 'base64');
|
||||
|
||||
const file = {
|
||||
rid,
|
||||
type: fileData.contentType,
|
||||
size: buffer.length,
|
||||
uid: user._id,
|
||||
};
|
||||
|
||||
if (current) {
|
||||
await fileStore.deleteById(current._id);
|
||||
}
|
||||
|
||||
const result = await fileStore.insert(file, buffer);
|
||||
|
||||
setTimeout(async () => {
|
||||
result.etag && (await Rooms.setAvatarData(rid, 'upload', result.etag));
|
||||
await Message.saveSystemMessage('room_changed_avatar', rid, '', user);
|
||||
void api.broadcast('room.avatarUpdate', { _id: rid, avatarETag: result.etag });
|
||||
}, 500);
|
||||
};
|
||||
2
apps/meteor/server/lib/rooms/settings/index.ts
Normal file
2
apps/meteor/server/lib/rooms/settings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { saveRoomTopic } from './saveRoomTopic';
|
||||
export { saveRoomName } from './saveRoomName';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
export const saveReactWhenReadOnly = async function (
|
||||
rid: string,
|
||||
allowReact: boolean,
|
||||
user: {
|
||||
_id: string;
|
||||
username: string;
|
||||
},
|
||||
sendMessage = true,
|
||||
) {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveReactWhenReadOnly',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Rooms.setAllowReactingWhenReadOnlyById(rid, allowReact);
|
||||
|
||||
if (result && sendMessage) {
|
||||
const type = allowReact ? 'room-allowed-reacting' : 'room-disallowed-reacting';
|
||||
|
||||
await Message.saveSystemMessage(type, rid, '', user);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { UpdateResult } from 'mongodb';
|
||||
|
||||
export const saveRoomAnnouncement = async function (
|
||||
rid: string,
|
||||
roomAnnouncement: string,
|
||||
user: IUser,
|
||||
sendMessage = true,
|
||||
): Promise<UpdateResult> {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomAnnouncement',
|
||||
});
|
||||
}
|
||||
|
||||
let message;
|
||||
let announcementDetails;
|
||||
if (typeof roomAnnouncement === 'string') {
|
||||
message = roomAnnouncement;
|
||||
} else {
|
||||
({ message, ...announcementDetails } = roomAnnouncement);
|
||||
}
|
||||
|
||||
const updated = await Rooms.setAnnouncementById(rid, message, announcementDetails);
|
||||
if (updated && sendMessage) {
|
||||
await Message.saveSystemMessage('room_changed_announcement', rid, message, user);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { UpdateResult } from 'mongodb';
|
||||
|
||||
import { notifyOnSubscriptionChangedByRoomId } from '../../notifyListener';
|
||||
|
||||
export const saveRoomCustomFields = async function (rid: string, roomCustomFields: Record<string, any>): Promise<UpdateResult> {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomCustomFields',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Match.test(roomCustomFields, Object)) {
|
||||
throw new Meteor.Error('invalid-roomCustomFields-type', 'Invalid roomCustomFields type', {
|
||||
function: 'RocketChat.saveRoomCustomFields',
|
||||
});
|
||||
}
|
||||
|
||||
const ret = await Rooms.setCustomFieldsById(rid, roomCustomFields);
|
||||
|
||||
// Update customFields of any user's Subscription related with this rid
|
||||
const { modifiedCount } = await Subscriptions.updateCustomFieldsByRoomId(rid, roomCustomFields);
|
||||
if (modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
18
apps/meteor/server/lib/rooms/settings/saveRoomDescription.ts
Normal file
18
apps/meteor/server/lib/rooms/settings/saveRoomDescription.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { UpdateResult } from 'mongodb';
|
||||
|
||||
export const saveRoomDescription = async function (rid: string, roomDescription: string, user: IUser): Promise<UpdateResult> {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomDescription',
|
||||
});
|
||||
}
|
||||
|
||||
const update = await Rooms.setDescriptionById(rid, roomDescription);
|
||||
await Message.saveSystemMessage('room_changed_description', rid, roomDescription, user);
|
||||
return update;
|
||||
};
|
||||
38
apps/meteor/server/lib/rooms/settings/saveRoomEncrypted.ts
Normal file
38
apps/meteor/server/lib/rooms/settings/saveRoomEncrypted.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { isRegisterUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { UpdateResult } from 'mongodb';
|
||||
|
||||
import { notifyOnSubscriptionChangedByRoomId } from '../../notifyListener';
|
||||
|
||||
export const saveRoomEncrypted = async function (rid: string, encrypted: boolean, user: IUser, sendMessage = true): Promise<UpdateResult> {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomEncrypted',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isRegisterUser(user)) {
|
||||
throw new Meteor.Error('invalid-user', 'Invalid user', {
|
||||
function: 'RocketChat.saveRoomEncrypted',
|
||||
});
|
||||
}
|
||||
|
||||
const update = await Rooms.saveEncryptedById(rid, encrypted);
|
||||
if (update && sendMessage) {
|
||||
const type = encrypted ? 'room_e2e_enabled' : 'room_e2e_disabled';
|
||||
|
||||
await Message.saveSystemMessage(type, rid, user.username, user);
|
||||
}
|
||||
|
||||
if (encrypted) {
|
||||
const { modifiedCount } = await Subscriptions.disableAutoTranslateByRoomId(rid);
|
||||
if (modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
}
|
||||
return update;
|
||||
};
|
||||
105
apps/meteor/server/lib/rooms/settings/saveRoomName.ts
Normal file
105
apps/meteor/server/lib/rooms/settings/saveRoomName.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Message, Room } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { isRoomNativeFederated } from '@rocket.chat/core-typings';
|
||||
import { Integrations, Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { Document, UpdateResult } from 'mongodb';
|
||||
|
||||
import { callbacks } from '../../callbacks';
|
||||
import { notifyOnIntegrationChangedByChannels, notifyOnSubscriptionChangedByRoomId } from '../../notifyListener';
|
||||
import { checkUsernameAvailability } from '../../users/checkUsernameAvailability';
|
||||
import { getValidRoomName } from '../../utils/lib/getValidRoomName';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
const updateFName = async (rid: string, displayName: string): Promise<(UpdateResult | Document)[]> => {
|
||||
const responses = await Promise.all([Rooms.setFnameById(rid, displayName), Subscriptions.updateFnameByRoomId(rid, displayName)]);
|
||||
|
||||
if (responses[1]?.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
|
||||
return responses;
|
||||
};
|
||||
|
||||
const updateRoomName = async (rid: string, displayName: string, slugifiedRoomName: string) => {
|
||||
// Check if the name is available
|
||||
if (!(await checkUsernameAvailability(slugifiedRoomName, 'room'))) {
|
||||
throw new Meteor.Error('error-duplicate-handle', `A room, team or user with name '${slugifiedRoomName}' already exists`, {
|
||||
function: 'RocketChat.updateRoomName',
|
||||
handle: slugifiedRoomName,
|
||||
});
|
||||
}
|
||||
|
||||
const responses = await Promise.all([
|
||||
Rooms.setNameById(rid, slugifiedRoomName, displayName),
|
||||
Subscriptions.updateNameAndAlertByRoomId(rid, slugifiedRoomName, displayName),
|
||||
]);
|
||||
|
||||
if (responses[1]?.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
|
||||
return responses;
|
||||
};
|
||||
|
||||
export async function saveRoomName(
|
||||
rid: string,
|
||||
displayName: string | undefined,
|
||||
user: IUser,
|
||||
sendMessage = true,
|
||||
): Promise<string | undefined> {
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (!room) {
|
||||
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomdisplayName',
|
||||
});
|
||||
}
|
||||
|
||||
if (roomCoordinator.getRoomDirectives(room.t).preventRenaming()) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
|
||||
function: 'RocketChat.saveRoomdisplayName',
|
||||
});
|
||||
}
|
||||
|
||||
await Room.beforeNameChange(room);
|
||||
|
||||
if (isRoomNativeFederated(room)) {
|
||||
displayName = `${displayName}:${room.federation.mrid.split(':').pop()}`;
|
||||
}
|
||||
|
||||
if (displayName === room.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayName?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDiscussion = Boolean(room?.prid);
|
||||
|
||||
const slugifiedRoomName = isDiscussion || isRoomNativeFederated(room) ? displayName : await getValidRoomName(displayName, rid);
|
||||
|
||||
let update;
|
||||
|
||||
if (isDiscussion || isRoomNativeFederated(room)) {
|
||||
update = await updateFName(rid, displayName);
|
||||
} else {
|
||||
update = await updateRoomName(rid, displayName, slugifiedRoomName);
|
||||
}
|
||||
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.name && !isDiscussion) {
|
||||
await Integrations.updateRoomName(room.name, slugifiedRoomName);
|
||||
void notifyOnIntegrationChangedByChannels([slugifiedRoomName]);
|
||||
}
|
||||
|
||||
if (sendMessage) {
|
||||
await Message.saveSystemMessage('r', rid, displayName, user);
|
||||
}
|
||||
|
||||
await callbacks.run('afterRoomNameChange', { room, name: displayName, oldName: room.name, user });
|
||||
return displayName;
|
||||
}
|
||||
29
apps/meteor/server/lib/rooms/settings/saveRoomReadOnly.ts
Normal file
29
apps/meteor/server/lib/rooms/settings/saveRoomReadOnly.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
export async function saveRoomReadOnly(
|
||||
rid: string,
|
||||
readOnly: boolean,
|
||||
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
|
||||
sendMessage = true,
|
||||
) {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomReadOnly',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Rooms.setReadOnlyById(rid, readOnly);
|
||||
|
||||
if (result && sendMessage) {
|
||||
if (readOnly) {
|
||||
await Message.saveSystemMessage('room-set-read-only', rid, '', user);
|
||||
} else {
|
||||
await Message.saveSystemMessage('room-removed-read-only', rid, '', user);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { MessageTypesValues as messageTypesValues } from '../../../../app/lib/lib/MessageTypes';
|
||||
|
||||
export const saveRoomSystemMessages = async function (rid: string, systemMessages: MessageTypesValues[]) {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomSystemMessages',
|
||||
});
|
||||
}
|
||||
if (
|
||||
systemMessages &&
|
||||
(!Match.test(systemMessages, [String]) || systemMessages.some((value) => !messageTypesValues.map(({ key }) => key).includes(value)))
|
||||
) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid option', {
|
||||
function: 'RocketChat.saveRoomSystemMessages',
|
||||
});
|
||||
}
|
||||
return Rooms.setSystemMessagesById(rid, systemMessages);
|
||||
};
|
||||
31
apps/meteor/server/lib/rooms/settings/saveRoomTopic.ts
Normal file
31
apps/meteor/server/lib/rooms/settings/saveRoomTopic.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Message, Room } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { callbacks } from '../../callbacks';
|
||||
|
||||
export const saveRoomTopic = async (
|
||||
rid: string,
|
||||
roomTopic: string | undefined,
|
||||
user: Pick<IUser, 'username' | '_id' | 'federation' | 'federated'>,
|
||||
sendMessage = true,
|
||||
) => {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomTopic',
|
||||
});
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(rid);
|
||||
|
||||
await Room.beforeTopicChange(room!);
|
||||
|
||||
const update = await Rooms.setTopicById(rid, roomTopic);
|
||||
if (update && sendMessage) {
|
||||
await Message.saveSystemMessage('room_changed_topic', rid, roomTopic || '', user);
|
||||
}
|
||||
await callbacks.run('afterRoomTopicChange', undefined, { room, topic: roomTopic, user });
|
||||
return update;
|
||||
};
|
||||
74
apps/meteor/server/lib/rooms/settings/saveRoomType.ts
Normal file
74
apps/meteor/server/lib/rooms/settings/saveRoomType.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
import { Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { UpdateResult, Document } from 'mongodb';
|
||||
|
||||
import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig';
|
||||
import { settings } from '../../../settings';
|
||||
import { i18n } from '../../i18n';
|
||||
import { notifyOnSubscriptionChangedByRoomId } from '../../notifyListener';
|
||||
import { roomCoordinator } from '../roomCoordinator';
|
||||
|
||||
export const saveRoomType = async function (
|
||||
rid: string,
|
||||
roomType: IRoom['t'],
|
||||
user: IUser,
|
||||
sendMessage = true,
|
||||
): Promise<UpdateResult | Document> {
|
||||
if (!Match.test(rid, String)) {
|
||||
throw new Meteor.Error('invalid-room', 'Invalid room', {
|
||||
function: 'RocketChat.saveRoomType',
|
||||
});
|
||||
}
|
||||
if (roomType !== 'c' && roomType !== 'p') {
|
||||
throw new Meteor.Error('error-invalid-room-type', 'error-invalid-room-type', {
|
||||
function: 'RocketChat.saveRoomType',
|
||||
type: roomType,
|
||||
});
|
||||
}
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (room == null) {
|
||||
throw new Meteor.Error('error-invalid-room', 'error-invalid-room', {
|
||||
function: 'RocketChat.saveRoomType',
|
||||
_id: rid,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await roomCoordinator.getRoomDirectives(room.t)?.allowRoomSettingChange(room, RoomSettingsEnum.TYPE))) {
|
||||
throw new Meteor.Error('error-direct-room', "Can't change type of direct rooms", {
|
||||
function: 'RocketChat.saveRoomType',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Promise.all([Rooms.setTypeById(rid, roomType), Subscriptions.updateTypeByRoomId(rid, roomType)]);
|
||||
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (roomType === 'c' && room.abacAttributes?.length) {
|
||||
await Rooms.unsetAbacAttributesById(rid);
|
||||
}
|
||||
|
||||
if (result[1]?.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
|
||||
if (sendMessage) {
|
||||
let message;
|
||||
if (roomType === 'c') {
|
||||
message = i18n.t('public', {
|
||||
lng: user?.language || settings.get('Language') || 'en',
|
||||
});
|
||||
} else {
|
||||
message = i18n.t('private', {
|
||||
lng: user?.language || settings.get('Language') || 'en',
|
||||
});
|
||||
}
|
||||
await Message.saveSystemMessage('room_changed_privacy', rid, message, user);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
|
||||
|
||||
import { calculateRoomRolePriorityFromRoles } from '../../../lib/roles/calculateRoomRolePriorityFromRoles';
|
||||
|
||||
const READ_BATCH_SIZE = 1000;
|
||||
|
||||
const SYNC_VERSION = 2;
|
||||
|
||||
async function assignRoomRolePrioritiesFromMap(userIdAndRoomRolePrioritiesMap: Map<IUser['_id'], IUser['roomRolePriorities']>) {
|
||||
const bulk = Users.col.initializeUnorderedBulkOp();
|
||||
|
||||
userIdAndRoomRolePrioritiesMap.forEach((roomRolePriorities, userId) => {
|
||||
userIdAndRoomRolePrioritiesMap.delete(userId);
|
||||
|
||||
if (roomRolePriorities) {
|
||||
const updateFields = Object.entries(roomRolePriorities).reduce(
|
||||
(operations, rolePriorityData) => {
|
||||
const [rid, rolePriority] = rolePriorityData;
|
||||
operations[`roomRolePriorities.${rid}`] = rolePriority;
|
||||
return operations;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
bulk.find({ _id: userId }).updateOne({
|
||||
$set: updateFields,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (bulk.length > 0) {
|
||||
await bulk.execute();
|
||||
}
|
||||
}
|
||||
|
||||
export const syncRolePrioritiesForRoomIfRequired = async (rid: IRoom['_id']) => {
|
||||
const userIdAndRoomRolePrioritiesMap = new Map<IUser['_id'], IUser['roomRolePriorities']>();
|
||||
|
||||
if (await Rooms.hasCreatedRolePrioritiesForRoom(rid, SYNC_VERSION)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = Subscriptions.find(
|
||||
{ rid, roles: { $exists: true } },
|
||||
{
|
||||
projection: { 'rid': 1, 'roles': 1, 'u._id': 1 },
|
||||
sort: { _id: 1 },
|
||||
},
|
||||
).batchSize(READ_BATCH_SIZE);
|
||||
|
||||
for await (const sub of cursor) {
|
||||
if (!sub.roles?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userId = sub.u._id;
|
||||
const roomId = sub.rid;
|
||||
const priority = calculateRoomRolePriorityFromRoles(sub.roles);
|
||||
|
||||
const existingPriorities = userIdAndRoomRolePrioritiesMap.get(userId) || {};
|
||||
existingPriorities[roomId] = priority;
|
||||
userIdAndRoomRolePrioritiesMap.set(userId, existingPriorities);
|
||||
}
|
||||
|
||||
// Flush any remaining priorities in the map
|
||||
await assignRoomRolePrioritiesFromMap(userIdAndRoomRolePrioritiesMap);
|
||||
|
||||
await Rooms.markRolePrioritesCreatedForRoom(rid, SYNC_VERSION);
|
||||
};
|
||||
44
apps/meteor/server/lib/rooms/unarchiveRoom.ts
Normal file
44
apps/meteor/server/lib/rooms/unarchiveRoom.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
|
||||
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '../notifyListener';
|
||||
|
||||
const BATCH_SIZE = 100_000;
|
||||
|
||||
async function getActiveUserIds(userIds: string[]): Promise<Set<string>> {
|
||||
const activeUserIds = new Set<string>();
|
||||
|
||||
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
|
||||
const batch = await Users.findActiveByIds(userIds.slice(i, i + BATCH_SIZE), { projection: { _id: 1 } }).toArray();
|
||||
for (const u of batch) {
|
||||
activeUserIds.add(u._id);
|
||||
}
|
||||
}
|
||||
|
||||
return activeUserIds;
|
||||
}
|
||||
|
||||
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 unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise<void> {
|
||||
await Rooms.unarchiveById(rid);
|
||||
|
||||
const archivedSubs = await Subscriptions.findArchivedByRoomId(rid, { projection: { 'u._id': 1 } }).toArray();
|
||||
|
||||
if (archivedSubs.length > 0) {
|
||||
const activeUserIds = await getActiveUserIds(archivedSubs.map((s) => s.u._id));
|
||||
const idsToUnarchive = archivedSubs.filter((s) => activeUserIds.has(s.u._id)).map((s) => s._id);
|
||||
|
||||
await unarchiveSubscriptionsByIds(idsToUnarchive);
|
||||
void notifyOnSubscriptionChangedByRoomId(rid);
|
||||
}
|
||||
|
||||
await Message.saveSystemMessage('room-unarchived', rid, '', user);
|
||||
|
||||
void notifyOnRoomChangedById(rid);
|
||||
};
|
||||
84
apps/meteor/server/lib/rooms/updateGroupDMsName.ts
Normal file
84
apps/meteor/server/lib/rooms/updateGroupDMsName.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { isNotUndefined } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
import type { ClientSession } from 'mongodb';
|
||||
|
||||
import { notifyOnSubscriptionChangedByRoomId } from '../notifyListener';
|
||||
|
||||
type GroupDMMember = Pick<IUser, '_id' | 'username' | 'name'>;
|
||||
|
||||
const getFname = (members: GroupDMMember[]): string => members.map(({ name, username }) => name || username).join(', ');
|
||||
const getName = (members: GroupDMMember[]): string => members.map(({ username }) => username).join(',');
|
||||
|
||||
async function getUsersWhoAreInTheSameGroupDMsAs(user: GroupDMMember) {
|
||||
// add all users to single array so we can fetch details from them all at once
|
||||
if ((await Rooms.countGroupDMsByUids([user._id])) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userIds = new Set<string>();
|
||||
const users = new Map<string, GroupDMMember>();
|
||||
|
||||
const rooms = Rooms.findGroupDMsByUids([user._id], { projection: { uids: 1 } });
|
||||
await rooms.forEach((room) => {
|
||||
if (!room.uids) {
|
||||
return;
|
||||
}
|
||||
|
||||
room.uids.forEach((uid) => uid !== user._id && userIds.add(uid));
|
||||
});
|
||||
|
||||
(await Users.findByIds([...userIds], { projection: { username: 1, name: 1 } }).toArray()).forEach((user) => users.set(user._id, user));
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
function sortUsersAlphabetically(u1: GroupDMMember, u2: GroupDMMember): number {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!);
|
||||
}
|
||||
|
||||
export const updateGroupDMsName = async (
|
||||
userThatChangedName: GroupDMMember,
|
||||
options?: {
|
||||
session?: ClientSession;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (!userThatChangedName.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await getUsersWhoAreInTheSameGroupDMsAs(userThatChangedName);
|
||||
if (!users) {
|
||||
return;
|
||||
}
|
||||
|
||||
users.set(userThatChangedName._id, userThatChangedName);
|
||||
|
||||
const { session } = options || {};
|
||||
|
||||
const rooms = Rooms.findGroupDMsByUids([userThatChangedName._id], { projection: { uids: 1 }, session });
|
||||
|
||||
const getMembers = (uids: string[]) => uids.map((uid) => users.get(uid)).filter(isNotUndefined);
|
||||
|
||||
// loop rooms to update the subscriptions from them all
|
||||
for await (const room of rooms) {
|
||||
if (!room.uids) {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = getMembers(room.uids);
|
||||
const sortedMembers = members.sort(sortUsersAlphabetically);
|
||||
|
||||
const subs = Subscriptions.findByRoomId(room._id, { projection: { '_id': 1, 'u._id': 1 }, session });
|
||||
for await (const sub of subs) {
|
||||
const otherMembers = sortedMembers.filter(({ _id }) => _id !== sub.u._id);
|
||||
const updateNameRespose = await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers), {
|
||||
session,
|
||||
});
|
||||
if (updateNameRespose.modifiedCount) {
|
||||
void notifyOnSubscriptionChangedByRoomId(room._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user