Files
upstream-rocketchat/apps/meteor/server/meteor-methods/rooms/saveRoomSettings.ts
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

569 lines
18 KiB
TypeScript

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<string, any>;
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> | void;
};
const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWithRetentionPolicy =>
'retention' in room && room.retention !== undefined;
const isAbacManagedTeam = (team: Partial<ITeam> | null, teamRoom: IRoom): boolean => {
return (
team?.type === TeamType.PRIVATE &&
settings.get<boolean>('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<IUser, 'username' | 'name'>;
value: RoomSettings[TRoomSetting];
room: IRoom;
rid: IRoom['_id'];
}) => void | Promise<void>;
};
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<RoomSettings>): Promise<{ result: true; rid: IRoom['_id'] }>;
saveRoomSettings<RoomSettingName extends keyof RoomSettings>(
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 = <TRoomSetting extends keyof RoomSettings>(
setting: TRoomSetting,
params: {
userId: IUser['_id'];
value: RoomSettings[TRoomSetting];
room: IRoom;
rid: IRoom['_id'];
},
) => {
const validator = validators[setting];
return validator?.(params);
};
async function save<TRoomSetting extends keyof RoomSettings>(
setting: TRoomSetting,
params: {
userId: IUser['_id'];
user: RequiredField<IUser, 'username' | 'name'>;
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<RoomSettings>,
): Promise<{ result: true; rid: IRoom['_id'] }>;
export async function saveRoomSettings<RoomSettingName extends keyof RoomSettings>(
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<RoomSettings> | 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<IUser, 'username' | 'name'>,
value: settings[setting],
room,
rid,
});
}
void notifyOnRoomChangedById(rid);
return {
result: true,
rid: room._id,
};
}
Meteor.methods<ServerMethods>({
saveRoomSettings: (...args) => {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
function: 'RocketChat.saveRoomName',
});
}
return saveRoomSettings(userId, ...args);
},
});