apps/meteor/app/api/server/v1/rooms.ts
import { Media, Team } from '@rocket.chat/core-services';
import type { IRoom, IUpload } from '@rocket.chat/core-typings';
import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models';
import type { Notifications } from '@rocket.chat/rest-typings';
import {
isGETRoomsNameExists,
isRoomsImagesProps,
isRoomsMuteUnmuteUserProps,
isRoomsExportProps,
isRoomsIsMemberProps,
isRoomsCleanHistoryProps,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { isTruthy } from '../../../../lib/isTruthy';
import { omit } from '../../../../lib/utils/omit';
import * as dataExport from '../../../../server/lib/dataExport';
import { eraseRoom } from '../../../../server/methods/eraseRoom';
import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom';
import { unmuteUserInRoom } from '../../../../server/methods/unmuteUserInRoom';
import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings';
import { createDiscussion } from '../../../discussion/server/methods/createDiscussion';
import { FileUpload } from '../../../file-upload/server';
import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMessage';
import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom';
import { settings } from '../../../settings/server';
import { API } from '../api';
import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage';
import { getPaginationItems } from '../helpers/getPaginationItems';
import { getUserFromParams } from '../helpers/getUserFromParams';
import { getUploadFormData } from '../lib/getUploadFormData';
import {
findAdminRoom,
findAdminRooms,
findAdminRoomsAutocomplete,
findChannelAndPrivateAutocomplete,
findChannelAndPrivateAutocompleteWithPagination,
findRoomsAvailableForTeams,
} from '../lib/rooms';
async function findRoomByIdOrName({
params,
checkedArchived = true,
}: {
params:
| {
roomId?: string;
}
| {
roomName?: string;
};
checkedArchived?: boolean;
}): Promise<IRoom> {
if (
(!('roomId' in params) && !('roomName' in params)) ||
('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName)
) {
throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" or "roomName" is required');
}
const projection = { ...API.v1.defaultFieldsToExclude };
let room;
if ('roomId' in params) {
room = await Rooms.findOneById(params.roomId || '', { projection });
} else if ('roomName' in params) {
room = await Rooms.findOneByName(params.roomName || '', { projection });
}
if (!room) {
throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel');
}
if (checkedArchived && room.archived) {
throw new Meteor.Error('error-room-archived', `The channel, ${room.name}, is archived`);
}
return room;
}
API.v1.addRoute(
'rooms.nameExists',
{
authRequired: true,
validateParams: isGETRoomsNameExists,
},
{
async get() {
const { roomName } = this.queryParams;
return API.v1.success({ exists: await Meteor.callAsync('roomNameExists', roomName) });
},
},
);
API.v1.addRoute(
'rooms.delete',
{
authRequired: true,
},
{
async post() {
const { roomId } = this.bodyParams;
if (!roomId) {
return API.v1.failure("The 'roomId' param is required");
}
await eraseRoom(roomId, this.userId);
return API.v1.success();
},
},
);
API.v1.addRoute(
'rooms.get',
{ authRequired: true },
{
async get() {
const { updatedSince } = this.queryParams;
let updatedSinceDate;
if (updatedSince) {
if (isNaN(Date.parse(updatedSince))) {
throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.');
} else {
updatedSinceDate = new Date(updatedSince);
}
}
let result: { update: IRoom[]; remove: IRoom[] } = await Meteor.callAsync('rooms/get', updatedSinceDate);
if (Array.isArray(result)) {
result = {
update: result,
remove: [],
};
}
return API.v1.success({
update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))),
remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))),
});
},
},
);
API.v1.addRoute(
'rooms.upload/:rid',
{
authRequired: true,
deprecation: {
version: '8.0.0',
alternatives: ['rooms.media'],
},
},
{
async post() {
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
return API.v1.unauthorized();
}
const file = await getUploadFormData(
{
request: this.request,
},
{ field: 'file', sizeLimit: settings.get<number>('FileUpload_MaxFileSize') },
);
if (!file) {
throw new Meteor.Error('invalid-field');
}
const { fields } = file;
let { fileBuffer } = file;
const details = {
name: file.filename,
size: fileBuffer.length,
type: file.mimetype,
rid: this.urlParams.rid,
userId: this.userId,
};
const stripExif = settings.get('Message_Attachments_Strip_Exif');
if (stripExif) {
// No need to check mime. Library will ignore any files without exif/xmp tags (like BMP, ico, PDF, etc)
fileBuffer = await Media.stripExifFromBuffer(fileBuffer);
}
const fileStore = FileUpload.getStore('Uploads');
const uploadedFile = await fileStore.insert(details, fileBuffer);
uploadedFile.description = fields.description;
delete fields.description;
await sendFileMessage(this.userId, { roomId: this.urlParams.rid, file: uploadedFile, msgData: fields });
const message = await Messages.getMessageByFileIdAndUsername(uploadedFile._id, this.userId);
return API.v1.success({
message,
});
},
},
);
API.v1.addRoute(
'rooms.media/:rid',
{ authRequired: true },
{
async post() {
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
return API.v1.unauthorized();
}
const file = await getUploadFormData(
{
request: this.request,
},
{ field: 'file', sizeLimit: settings.get<number>('FileUpload_MaxFileSize') },
);
if (!file) {
throw new Meteor.Error('invalid-field');
}
let { fileBuffer } = file;
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
const { fields } = file;
let content;
if (fields.content) {
try {
content = JSON.parse(fields.content);
} catch (e) {
console.error(e);
throw new Meteor.Error('invalid-field-content');
}
}
const details = {
name: file.filename,
size: fileBuffer.length,
type: file.mimetype,
rid: this.urlParams.rid,
userId: this.userId,
content,
expiresAt,
};
const stripExif = settings.get('Message_Attachments_Strip_Exif');
if (stripExif) {
// No need to check mime. Library will ignore any files without exif/xmp tags (like BMP, ico, PDF, etc)
fileBuffer = await Media.stripExifFromBuffer(fileBuffer);
}
const fileStore = FileUpload.getStore('Uploads');
const uploadedFile = await fileStore.insert(details, fileBuffer);
uploadedFile.path = FileUpload.getPath(`${uploadedFile._id}/${encodeURI(uploadedFile.name || '')}`);
await Uploads.updateFileComplete(uploadedFile._id, this.userId, omit(uploadedFile, '_id'));
return API.v1.success({
file: {
_id: uploadedFile._id,
url: uploadedFile.path,
},
});
},
},
);
API.v1.addRoute(
'rooms.mediaConfirm/:rid/:fileId',
{ authRequired: true },
{
async post() {
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
return API.v1.unauthorized();
}
const file = await Uploads.findOneById(this.urlParams.fileId);
if (!file) {
throw new Meteor.Error('invalid-file');
}
file.description = this.bodyParams.description;
delete this.bodyParams.description;
await sendFileMessage(
this.userId,
{ roomId: this.urlParams.rid, file, msgData: this.bodyParams },
{ parseAttachmentsForE2EE: false },
);
await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId);
const message = await Messages.getMessageByFileIdAndUsername(file._id, this.userId);
return API.v1.success({
message,
});
},
},
);
API.v1.addRoute(
'rooms.saveNotification',
{ authRequired: true },
{
async post() {
const { roomId, notifications } = this.bodyParams;
if (!roomId) {
return API.v1.failure("The 'roomId' param is required");
}
if (!notifications || Object.keys(notifications).length === 0) {
return API.v1.failure("The 'notifications' param is required");
}
await Promise.all(
Object.keys(notifications as Notifications).map(async (notificationKey) =>
Meteor.callAsync('saveNotificationSettings', roomId, notificationKey, notifications[notificationKey as keyof Notifications]),
),
);
return API.v1.success();
},
},
);
API.v1.addRoute(
'rooms.favorite',
{ authRequired: true },
{
async post() {
const { favorite } = this.bodyParams;
if (!this.bodyParams.hasOwnProperty('favorite')) {
return API.v1.failure("The 'favorite' param is required");
}
const room = await findRoomByIdOrName({ params: this.bodyParams });
await Meteor.callAsync('toggleFavorite', room._id, favorite);
return API.v1.success();
},
},
);
API.v1.addRoute(
'rooms.cleanHistory',
{ authRequired: true, validateParams: isRoomsCleanHistoryProps },
{
async post() {
const { _id } = await findRoomByIdOrName({ params: this.bodyParams });
const {
latest,
oldest,
inclusive = false,
limit,
excludePinned,
filesOnly,
ignoreThreads,
ignoreDiscussion,
users,
} = this.bodyParams;
if (!latest) {
return API.v1.failure('Body parameter "latest" is required.');
}
if (!oldest) {
return API.v1.failure('Body parameter "oldest" is required.');
}
const count = await Meteor.callAsync('cleanRoomHistory', {
roomId: _id,
latest: new Date(latest),
oldest: new Date(oldest),
inclusive,
limit,
excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false),
filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false),
ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false),
ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false),
fromUsers: users,
});
return API.v1.success({ _id, count });
},
},
);
API.v1.addRoute(
'rooms.info',
{ authRequired: true },
{
async get() {
const room = await findRoomByIdOrName({ params: this.queryParams });
const { fields } = await this.parseJsonQuery();
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
return API.v1.failure('not-allowed', 'Not Allowed');
}
const discussionParent =
room.prid &&
(await Rooms.findOneById<Pick<IRoom, 'name' | 'fname' | 't' | 'prid' | 'u'>>(room.prid, {
projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1, sidepanel: 1 },
}));
const { team, parentRoom } = await Team.getRoomInfo(room);
const parent = discussionParent || parentRoom;
return API.v1.success({
room: (await Rooms.findOneByIdOrName(room._id, { projection: fields })) ?? undefined,
...(team && { team }),
...(parent && { parent }),
});
},
},
);
API.v1.addRoute(
'rooms.leave',
{ authRequired: true },
{
async post() {
const room = await findRoomByIdOrName({ params: this.bodyParams });
const user = await Users.findOneById(this.userId);
if (!user) {
return API.v1.failure('Invalid user');
}
await leaveRoomMethod(user, room._id);
return API.v1.success();
},
},
);
API.v1.addRoute(
'rooms.createDiscussion',
{ authRequired: true },
{
async post() {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams;
if (!prid) {
return API.v1.failure('Body parameter "prid" is required.');
}
if (!t_name) {
return API.v1.failure('Body parameter "t_name" is required.');
}
if (users && !Array.isArray(users)) {
return API.v1.failure('Body parameter "users" must be an array.');
}
if (encrypted !== undefined && typeof encrypted !== 'boolean') {
return API.v1.failure('Body parameter "encrypted" must be a boolean when included.');
}
const discussion = await createDiscussion(this.userId, {
prid,
pmid,
t_name,
reply,
users: users?.filter(isTruthy) || [],
encrypted,
topic,
});
return API.v1.success({ discussion });
},
},
);
API.v1.addRoute(
'rooms.getDiscussions',
{ authRequired: true },
{
async get() {
const room = await findRoomByIdOrName({ params: this.queryParams });
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
return API.v1.failure('not-allowed', 'Not Allowed');
}
const ourQuery = Object.assign(query, { prid: room._id });
const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, {
sort: sort || { fname: 1 },
skip: offset,
limit: count,
projection: fields,
});
const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
discussions,
count: discussions.length,
offset,
total,
});
},
},
);
API.v1.addRoute(
'rooms.images',
{ authRequired: true, validateParams: isRoomsImagesProps },
{
async get() {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>>(this.queryParams.roomId, {
projection: { t: 1, teamId: 1, prid: 1 },
});
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
return API.v1.unauthorized();
}
let initialImage: IUpload | null = null;
if (this.queryParams.startingFromId) {
initialImage = await Uploads.findOneById(this.queryParams.startingFromId);
}
const { offset, count } = await getPaginationItems(this.queryParams);
const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, {
skip: offset,
limit: count,
});
const [files, total] = await Promise.all([cursor.toArray(), totalCount]);
// If the initial image was not returned in the query, insert it as the first element of the list
if (initialImage && !files.find(({ _id }) => _id === (initialImage as IUpload)._id)) {
files.splice(0, 0, initialImage);
}
return API.v1.success({
files,
count,
offset,
total,
});
},
},
);
API.v1.addRoute(
'rooms.adminRooms',
{ authRequired: true },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { types, filter } = this.queryParams;
return API.v1.success(
await findAdminRooms({
uid: this.userId,
filter: filter || '',
types: (types && !Array.isArray(types) ? [types] : types) ?? [],
pagination: {
offset,
count,
sort,
},
}),
);
},
},
);
API.v1.addRoute(
'rooms.autocomplete.adminRooms',
{ authRequired: true },
{
async get() {
const { selector } = this.queryParams;
if (!selector) {
return API.v1.failure("The 'selector' param is required");
}
return API.v1.success(
await findAdminRoomsAutocomplete({
uid: this.userId,
selector: JSON.parse(selector),
}),
);
},
},
);
API.v1.addRoute(
'rooms.adminRooms.getRoom',
{ authRequired: true },
{
async get() {
const { rid } = this.queryParams;
const room = await findAdminRoom({
uid: this.userId,
rid: rid || '',
});
if (!room) {
return API.v1.failure('not-allowed', 'Not Allowed');
}
return API.v1.success(room);
},
},
);
API.v1.addRoute(
'rooms.autocomplete.channelAndPrivate',
{ authRequired: true },
{
async get() {
const { selector } = this.queryParams;
if (!selector) {
return API.v1.failure("The 'selector' param is required");
}
return API.v1.success(
await findChannelAndPrivateAutocomplete({
uid: this.userId,
selector: JSON.parse(selector),
}),
);
},
},
);
API.v1.addRoute(
'rooms.autocomplete.channelAndPrivate.withPagination',
{ authRequired: true },
{
async get() {
const { selector } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
if (!selector) {
return API.v1.failure("The 'selector' param is required");
}
return API.v1.success(
await findChannelAndPrivateAutocompleteWithPagination({
uid: this.userId,
selector: JSON.parse(selector),
pagination: {
offset,
count,
sort,
},
}),
);
},
},
);
API.v1.addRoute(
'rooms.autocomplete.availableForTeams',
{ authRequired: true },
{
async get() {
const { name } = this.queryParams;
if (name && typeof name !== 'string') {
return API.v1.failure("The 'name' param is invalid");
}
return API.v1.success(
await findRoomsAvailableForTeams({
uid: this.userId,
name: name || '',
}),
);
},
},
);
API.v1.addRoute(
'rooms.saveRoomSettings',
{ authRequired: true },
{
async post() {
const { rid, ...params } = this.bodyParams;
const result = await saveRoomSettings(this.userId, rid, params);
return API.v1.success({ rid: result.rid });
},
},
);
API.v1.addRoute(
'rooms.changeArchivationState',
{ authRequired: true },
{
async post() {
const { rid, action } = this.bodyParams;
let result;
if (action === 'archive') {
result = await Meteor.callAsync('archiveRoom', rid);
} else {
result = await Meteor.callAsync('unarchiveRoom', rid);
}
return API.v1.success({ result });
},
},
);
API.v1.addRoute(
'rooms.export',
{ authRequired: true, validateParams: isRoomsExportProps },
{
async post() {
const { rid, type } = this.bodyParams;
if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) {
throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed');
}
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-invalid-room');
}
const user = await Users.findOneById(this.userId);
if (!user || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not Allowed');
}
if (type === 'file') {
const { dateFrom, dateTo } = this.bodyParams;
const { format } = this.bodyParams;
const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0);
const convertedDateTo = dateTo ? new Date(dateTo) : new Date();
convertedDateTo.setDate(convertedDateTo.getDate() + 1);
if (convertedDateFrom > convertedDateTo) {
throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date');
}
void dataExport.sendFile(
{
rid,
format: format as 'html' | 'json',
dateFrom: convertedDateFrom,
dateTo: convertedDateTo,
},
user,
);
return API.v1.success();
}
if (type === 'email') {
const { toUsers, toEmails, subject, messages } = this.bodyParams;
if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) {
throw new Meteor.Error('error-invalid-recipient');
}
const result = await dataExport.sendViaEmail(
{
rid,
toUsers: (toUsers as string[]) || [],
toEmails: toEmails || [],
subject: subject || '',
messages: messages || [],
language: user.language || 'en',
},
user,
);
return API.v1.success(result);
}
return API.v1.failure();
},
},
);
API.v1.addRoute(
'rooms.isMember',
{
authRequired: true,
validateParams: isRoomsIsMemberProps,
},
{
async get() {
const { roomId, userId, username } = this.queryParams;
const [room, user] = await Promise.all([
findRoomByIdOrName({
params: { roomId },
}) as Promise<IRoom>,
Users.findOneByIdOrUsername(userId || username),
]);
if (!user?._id) {
return API.v1.failure('error-user-not-found');
}
if (await canAccessRoomAsync(room, { _id: this.user._id })) {
return API.v1.success({
isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0,
});
}
return API.v1.unauthorized();
},
},
);
API.v1.addRoute(
'rooms.muteUser',
{ authRequired: true, validateParams: isRoomsMuteUnmuteUserProps },
{
async post() {
const user = await getUserFromParams(this.bodyParams);
if (!user.username) {
return API.v1.failure('Invalid user');
}
await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
return API.v1.success();
},
},
);
API.v1.addRoute(
'rooms.unmuteUser',
{ authRequired: true, validateParams: isRoomsMuteUnmuteUserProps },
{
async post() {
const user = await getUserFromParams(this.bodyParams);
if (!user.username) {
return API.v1.failure('Invalid user');
}
await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
return API.v1.success();
},
},
);