import { Team } from '@rocket.chat/core-services'; import type { IRoom, IRoomWithRetentionPolicy, IUser, MessageTypesValues, ITeam, RequiredField } from '@rocket.chat/core-typings'; import { TeamType } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms, Users } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { isABACManagedRoom } from '../../lib/authorization/isABACManagedRoom'; import { SystemLogger } from '../../lib/logger/system'; import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage'; import { notifyOnRoomChangedById } from '../../lib/notifyListener'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { setRoomAvatar } from '../../lib/rooms/setRoomAvatar'; import { saveReactWhenReadOnly } from '../../lib/rooms/settings/saveReactWhenReadOnly'; import { saveRoomAnnouncement } from '../../lib/rooms/settings/saveRoomAnnouncement'; import { saveRoomCustomFields } from '../../lib/rooms/settings/saveRoomCustomFields'; import { saveRoomDescription } from '../../lib/rooms/settings/saveRoomDescription'; import { saveRoomEncrypted } from '../../lib/rooms/settings/saveRoomEncrypted'; import { saveRoomName } from '../../lib/rooms/settings/saveRoomName'; import { saveRoomReadOnly } from '../../lib/rooms/settings/saveRoomReadOnly'; import { saveRoomSystemMessages } from '../../lib/rooms/settings/saveRoomSystemMessages'; import { saveRoomTopic } from '../../lib/rooms/settings/saveRoomTopic'; import { saveRoomType } from '../../lib/rooms/settings/saveRoomType'; import { settings } from '../../settings'; type RoomSettings = { roomAvatar: string; featured: boolean; roomName: string | undefined; roomTopic: string; roomAnnouncement: string; roomCustomFields: Record; roomDescription: string; roomType: IRoom['t']; readOnly: boolean; reactWhenReadOnly: boolean; systemMessages: MessageTypesValues[]; default: boolean; joinCode: string; retentionEnabled: boolean; retentionMaxAge: number; retentionExcludePinned: boolean; retentionFilesOnly: boolean; retentionIgnoreThreads: boolean; retentionOverrideGlobal: boolean; encrypted: boolean; favorite: { favorite: boolean; defaultValue: boolean; }; }; type RoomSettingsValidators = { [TRoomSetting in keyof RoomSettings]?: (params: { userId: IUser['_id']; value: RoomSettings[TRoomSetting]; room: IRoom; rid: IRoom['_id']; }) => Promise | void; }; const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWithRetentionPolicy => 'retention' in room && room.retention !== undefined; const isAbacManagedTeam = (team: Partial | null, teamRoom: IRoom): boolean => { return ( team?.type === TeamType.PRIVATE && settings.get('ABAC_Enabled') && Array.isArray(teamRoom?.abacAttributes) && teamRoom.abacAttributes.length > 0 ); }; const guardABACManagedField = (room: IRoom, value: string | undefined, current: string | undefined, fieldName: string): void => { if (!value && !current) { return; } if (value === current) { return; } if (isABACManagedRoom(room)) { throw new Meteor.Error('error-action-not-allowed', `Editing an ABAC managed room's ${fieldName} is not allowed`, { method: 'saveRoomSettings', action: 'Editing_room', }); } }; const validators: RoomSettingsValidators = { async default({ userId, room, value }) { if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { method: 'saveRoomSettings', action: 'Viewing_room_administration', }); } if (isABACManagedRoom(room) && value) { throw new Meteor.Error('error-action-not-allowed', 'Setting an ABAC managed room as default is not allowed', { method: 'saveRoomSettings', action: 'Viewing_room_administration', }); } }, async featured({ userId }) { if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { method: 'saveRoomSettings', action: 'Viewing_room_administration', }); } }, async roomType({ userId, room, value }) { if (value === room.t) { return; } if (value === 'c' && (!room.teamId || room.teamMain) && !(await hasPermissionAsync(userId, 'create-c'))) { throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } if (value === 'p' && (!room.teamId || room.teamMain) && !(await hasPermissionAsync(userId, 'create-p'))) { throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } if (isABACManagedRoom(room) && value !== 'p') { throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private room to public is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } if (!room.teamId || room.teamMain) { return; } const team = await Team.getInfoById(room.teamId); if (value === 'c' && !(await hasPermissionAsync(userId, 'create-team-channel', team?.roomId))) { throw new Meteor.Error('error-action-not-allowed', `Changing a team's private group to a public channel is not allowed`, { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } if (value === 'p' && !(await hasPermissionAsync(userId, 'create-team-group', team?.roomId))) { throw new Meteor.Error('error-action-not-allowed', `Changing a team's public channel to a private room is not allowed`, { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } if (isAbacManagedTeam(team, room) && value !== 'p') { throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private team room to public is not allowed', { method: 'saveRoomSettings', action: 'Change_Room_Type', }); } }, async roomAnnouncement({ room, value }) { guardABACManagedField(room, value, room.announcement, 'announcement'); }, async roomTopic({ room, value }) { guardABACManagedField(room, value, room.topic, 'topic'); }, async roomDescription({ room, value }) { guardABACManagedField(room, value, room.description, 'description'); }, async encrypted({ userId, value, room, rid }) { if (value !== room.encrypted) { if (!(await roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E))) { throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', { method: 'saveRoomSettings', action: 'Change_Room_Encrypted', }); } if (room.t !== 'd' && !(await hasPermissionAsync(userId, 'toggle-room-e2e-encryption', rid))) { throw new Meteor.Error('error-action-not-allowed', 'You do not have permission to toggle E2E encryption', { method: 'saveRoomSettings', action: 'Change_Room_Encrypted', }); } } }, async retentionEnabled({ userId, value, room, rid }) { if ( !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && (!hasRetentionPolicy(room) || value !== room.retention.enabled) ) { throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } }, async retentionMaxAge({ userId, value, room, rid }) { if ( !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && (!hasRetentionPolicy(room) || value !== room.retention.maxAge) ) { throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } }, async retentionExcludePinned({ userId, value, room, rid }) { if ( !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && (!hasRetentionPolicy(room) || value !== room.retention.excludePinned) ) { throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } }, async retentionFilesOnly({ userId, value, room, rid }) { if ( !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && (!hasRetentionPolicy(room) || value !== room.retention.filesOnly) ) { throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } }, async retentionIgnoreThreads({ userId, value, room, rid }) { if ( !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && (!hasRetentionPolicy(room) || value !== room.retention.ignoreThreads) ) { throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } }, async roomAvatar({ userId, rid }) { if (!(await hasPermissionAsync(userId, 'edit-room-avatar', rid))) { throw new Meteor.Error('error-action-not-allowed', 'Editing a room avatar is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } }, }; type RoomSettingsSavers = { [TRoomSetting in keyof RoomSettings]?: (params: { userId: IUser['_id']; user: RequiredField; value: RoomSettings[TRoomSetting]; room: IRoom; rid: IRoom['_id']; }) => void | Promise; }; const settingSavers: RoomSettingsSavers = { async roomName({ value, rid, user, room }) { if (!(await saveRoomName(rid, value, user))) { return; } if (room.teamId && room.teamMain) { void Team.update(user._id, room.teamId, { type: room.t === 'c' ? TeamType.PUBLIC : TeamType.PRIVATE, name: value, updateRoom: false, }); } }, async roomTopic({ value, room, rid, user }) { if (!value && !room.topic) { return; } if (value !== room.topic) { await saveRoomTopic(rid, value, user); } }, async roomAnnouncement({ value, room, rid, user }) { if (!value && !room.announcement) { return; } if (value !== room.announcement) { await saveRoomAnnouncement(rid, value, user); } }, async roomCustomFields({ value, room, rid }) { if (value !== room.customFields) { await saveRoomCustomFields(rid, value); } }, async roomDescription({ value, room, rid, user }) { if (!value && !room.description) { return; } if (value !== room.description) { await saveRoomDescription(rid, value, user); } }, async roomType({ value, room, rid, user }) { if (value === room.t) { return; } if (!(await saveRoomType(rid, value, user))) { return; } if (room.teamId && room.teamMain) { const type = value === 'c' ? TeamType.PUBLIC : TeamType.PRIVATE; void Team.update(user._id, room.teamId, { type, updateRoom: false }); } }, async readOnly({ value, room, rid, user }) { if (value !== room.ro) { await saveRoomReadOnly(rid, value, user); } }, async reactWhenReadOnly({ value, room, rid, user }) { if (value !== room.reactWhenReadOnly) { await saveReactWhenReadOnly(rid, value, user); } }, async systemMessages({ value, room, rid }) { if (JSON.stringify(value) !== JSON.stringify(room.sysMes)) { await saveRoomSystemMessages(rid, value); if (room?.prid) { try { await updateAndNotifyParentRoomWithParentMessage({ ...room, sysMes: value }); } catch (err) { SystemLogger.error({ msg: 'Failed to propagate discussion metadata', err, rid }); } } } }, async joinCode({ value, rid }) { await Rooms.setJoinCodeById(rid, String(value)); }, async default({ value, rid }) { await Rooms.saveDefaultById(rid, value); }, async featured({ value, rid }) { await Rooms.saveFeaturedById(rid, value); }, async retentionEnabled({ value, rid }) { await Rooms.saveRetentionEnabledById(rid, value); }, async retentionMaxAge({ value, rid }) { await Rooms.saveRetentionMaxAgeById(rid, value); }, async retentionExcludePinned({ value, rid }) { await Rooms.saveRetentionExcludePinnedById(rid, value); }, async retentionFilesOnly({ value, rid }) { await Rooms.saveRetentionFilesOnlyById(rid, value); }, async retentionIgnoreThreads({ value, rid }) { await Rooms.saveRetentionIgnoreThreadsById(rid, value); }, async retentionOverrideGlobal({ value, rid }) { await Rooms.saveRetentionOverrideGlobalById(rid, value); }, async encrypted({ value, room, rid, user }) { await saveRoomEncrypted(rid, value, user, Boolean(room.encrypted) !== Boolean(value)); }, async favorite({ value, rid }) { await Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue); }, async roomAvatar({ value, rid, user }) { await setRoomAvatar(rid, value, user); }, }; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { saveRoomSettings(rid: IRoom['_id'], settings: Partial): Promise<{ result: true; rid: IRoom['_id'] }>; saveRoomSettings( rid: IRoom['_id'], setting: RoomSettingName, value: RoomSettings[RoomSettingName], ): Promise<{ result: true; rid: IRoom['_id'] }>; } } const fields: (keyof RoomSettings)[] = [ 'roomAvatar', 'featured', 'roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionIgnoreThreads', 'retentionOverrideGlobal', 'encrypted', 'favorite', ]; const validate = ( setting: TRoomSetting, params: { userId: IUser['_id']; value: RoomSettings[TRoomSetting]; room: IRoom; rid: IRoom['_id']; }, ) => { const validator = validators[setting]; return validator?.(params); }; async function save( setting: TRoomSetting, params: { userId: IUser['_id']; user: RequiredField; value: RoomSettings[TRoomSetting]; room: IRoom; rid: IRoom['_id']; }, ) { const saver = settingSavers[setting]; await saver?.(params); } export async function saveRoomSettings( userId: IUser['_id'], rid: IRoom['_id'], settings: Partial, ): Promise<{ result: true; rid: IRoom['_id'] }>; export async function saveRoomSettings( userId: IUser['_id'], rid: IRoom['_id'], setting: RoomSettingName, value: RoomSettings[RoomSettingName], ): Promise<{ result: true; rid: IRoom['_id'] }>; export async function saveRoomSettings( userId: IUser['_id'], rid: IRoom['_id'], settings: Partial | keyof RoomSettings, value?: RoomSettings[keyof RoomSettings], ): Promise<{ result: true; rid: IRoom['_id'] }> { if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'RocketChat.saveRoomName', }); } if (!Match.test(rid, String)) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'saveRoomSettings', }); } if (typeof settings !== 'object') { settings = { [settings]: value, }; } if (!Object.keys(settings).every((key) => fields.includes(key as keyof typeof settings))) { throw new Meteor.Error('error-invalid-settings', 'Invalid settings provided', { method: 'saveRoomSettings', }); } const room = await Rooms.findOneById(rid); if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'saveRoomSettings', }); } if (!(await hasPermissionAsync(userId, 'edit-room', rid))) { if (!(Object.keys(settings).includes('encrypted') && room.t === 'd')) { throw new Meteor.Error('error-action-not-allowed', 'Editing room is not allowed', { method: 'saveRoomSettings', action: 'Editing_room', }); } settings = { encrypted: settings.encrypted }; } if (room.broadcast && (settings.readOnly || settings.reactWhenReadOnly)) { throw new Meteor.Error('error-action-not-allowed', 'Editing readOnly/reactWhenReadOnly are not allowed for broadcast rooms', { method: 'saveRoomSettings', action: 'Editing_room', }); } const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveRoomSettings', }); } // validations for (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { await validate(setting, { userId, value: settings[setting], room, rid, }); if (setting === 'retentionOverrideGlobal' && settings.retentionOverrideGlobal === false) { delete settings.retentionMaxAge; delete settings.retentionExcludePinned; delete settings.retentionFilesOnly; delete settings.retentionIgnoreThreads; } } // saving data for (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { await save(setting, { userId, user: user as RequiredField, value: settings[setting], room, rid, }); } void notifyOnRoomChangedById(rid); return { result: true, rid: room._id, }; } Meteor.methods({ saveRoomSettings: (...args) => { const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'RocketChat.saveRoomName', }); } return saveRoomSettings(userId, ...args); }, });