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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import type { ICreateRoomParams } from '@rocket.chat/core-services';
import type { ICreatedRoom, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Users } from '@rocket.chat/models';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { callbacks } from '../../lib/callbacks';
import { createRoom } from '../../lib/rooms/createRoom';
import { settings } from '../../settings';
export async function createDirectMessage(
usernames: IUser['username'][],
userId: IUser['_id'] | null,
excludeSelf = false,
): Promise<Omit<ICreatedRoom, '_id' | 'inserted'>> {
check(usernames, [String]);
check(userId, String);
check(excludeSelf, Match.Optional(Boolean));
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
const me = await Users.findOneById(userId, { projection: { username: 1, name: 1 } });
if (!me?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
if (settings.get('Message_AllowDirectMessagesToYourself') === false && usernames.length === 1 && me.username === usernames[0]) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
const users = await Promise.all(usernames.filter((username) => username !== me.username));
const options: Exclude<ICreateRoomParams['options'], undefined> = { creator: me._id };
const roomUsers = excludeSelf ? users : [me, ...users];
// allow self-DMs
if (roomUsers.length === 1 && roomUsers[0] !== undefined && typeof roomUsers[0] !== 'string' && roomUsers[0]._id !== me._id) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
if (!(await hasPermissionAsync(userId, 'create-d'))) {
// If the user can't create DMs but can access already existing ones
if ((await hasPermissionAsync(userId, 'view-d-room')) && !Object.keys(roomUsers).some((user) => typeof user === 'string')) {
// Check if the direct room already exists, then return it
const uids = (roomUsers as IUser[]).map(({ _id }) => _id).sort();
// No projection: the full room is spread into the ICreatedRoom-shaped return below.
const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uids);
if (room) {
return {
...room,
t: 'd',
rid: room._id,
};
}
}
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'createDirectMessage',
});
}
if (excludeSelf && (await hasPermissionAsync(userId, 'view-room-administration'))) {
options.subscriptionExtra = { open: true };
}
const extraData = {};
try {
await callbacks.run('federation.beforeCreateDirectMessage', roomUsers, extraData);
} catch (error) {
throw new Meteor.Error((error as any)?.message);
}
const {
_id: rid,
inserted,
...room
} = await createRoom<'d'>('d', undefined, undefined, roomUsers as IUser[], false, undefined, extraData, options);
return {
// @ts-expect-error - room type is already defined in the `createRoom` return type
t: 'd',
// @ts-expect-error - room id is not defined in the `createRoom` return type
rid,
...room,
};
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
createDirectMessage(...usernames: Exclude<IUser['username'], undefined>[]): Omit<ICreatedRoom, '_id' | 'inserted'>;
}
}
Meteor.methods<ServerMethods>({
async createDirectMessage(...usernames) {
return createDirectMessage(usernames, Meteor.userId());
},
});
RateLimiter.limitMethod('createDirectMessage', 10, 60000, {
async userId(userId: IUser['_id']) {
return !(await hasPermissionAsync(userId, 'send-many-messages'));
},
});

View File

@@ -0,0 +1,277 @@
import { Message } from '@rocket.chat/core-services';
import type { IMessage, IRoom, IUser, MessageAttachmentDefault } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { afterSaveMessageAsync } from '../../hooks/messages/afterSaveMessage';
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
import { hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { i18n } from '../../lib/i18n';
import { attachMessage } from '../../lib/messages/attachMessage';
import { sendMessage } from '../../lib/messages/sendMessage';
import { addUserToRoom } from '../../lib/rooms/addUserToRoom';
import { createRoom } from '../../lib/rooms/createRoom';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { settings } from '../../settings';
const getParentRoom = async (rid: IRoom['_id']) => {
const room = await Rooms.findOne(rid);
return room && (room.prid ? Rooms.findOne(room.prid, { projection: { _id: 1 } }) : room);
};
async function createDiscussionMessage(
rid: IRoom['_id'],
user: IUser,
drid: IRoom['_id'],
msg: IMessage['msg'],
messageEmbedded?: MessageAttachmentDefault,
): Promise<IMessage> {
return Message.saveSystemMessage('discussion-created', rid, msg, user, {
drid,
...(messageEmbedded && { attachments: [messageEmbedded] }),
});
}
async function mentionMessage(
rid: IRoom['_id'],
{ _id, username, name }: Pick<IUser, '_id' | 'username' | 'name'>,
messageEmbedded?: MessageAttachmentDefault,
) {
if (!username) {
return null;
}
await Messages.insertOne({
rid,
msg: '',
u: { _id, username, name },
ts: new Date(),
_updatedAt: new Date(),
...(messageEmbedded && { attachments: [messageEmbedded] }),
});
}
type CreateDiscussionProperties = {
prid: IRoom['_id'];
pmid?: IMessage['_id'];
t_name: string;
reply?: string;
users: Array<Exclude<IUser['username'], undefined>>;
user: IUser;
encrypted?: boolean;
topic?: string;
};
const create = async ({
prid,
pmid,
t_name: discussionName,
reply,
users,
user,
encrypted,
topic,
}: CreateDiscussionProperties): Promise<IRoom & { rid: string }> => {
// if you set both, prid and pmid, and the rooms dont match... should throw an error)
let message: null | IMessage = null;
if (pmid) {
message = await Messages.findOneById(pmid);
if (!message) {
throw new Meteor.Error('error-invalid-message', 'Invalid message', {
method: 'DiscussionCreation',
});
}
if (prid) {
const parentRoom = await getParentRoom(message.rid);
if (prid !== parentRoom?._id) {
throw new Meteor.Error('error-invalid-arguments', 'Root message room ID does not match parent room ID ', {
method: 'DiscussionCreation',
});
}
} else {
prid = message.rid;
}
}
if (!prid) {
throw new Meteor.Error('error-invalid-arguments', 'Missing parent room ID', { method: 'DiscussionCreation' });
}
let parentRoom;
try {
parentRoom = await canSendMessageAsync(prid, { uid: user._id, username: user.username, type: user.type });
} catch (error) {
throw new Meteor.Error((error as Error).message);
}
if (parentRoom.prid) {
throw new Meteor.Error('error-nested-discussion', 'Cannot create nested discussions', {
method: 'DiscussionCreation',
});
}
if (typeof encrypted !== 'boolean') {
encrypted = Boolean(parentRoom.encrypted);
}
if (encrypted && reply) {
throw new Meteor.Error('error-invalid-arguments', 'Encrypted discussions must not receive an initial reply.', {
method: 'DiscussionCreation',
});
}
if (pmid) {
// No projection: the full room is spread into the returned discussion below.
const discussionAlreadyExists = await Rooms.findOne({
prid,
pmid,
});
if (discussionAlreadyExists) {
// do not allow multiple discussions to the same message'\
await addUserToRoom(discussionAlreadyExists._id, user);
return { ...discussionAlreadyExists, rid: discussionAlreadyExists._id };
}
}
const name = Random.id();
// auto invite the replied message owner
const invitedUsers = message ? [message.u.username, ...users] : users;
const type = await roomCoordinator.getRoomDirectives(parentRoom.t).getDiscussionType(parentRoom);
const description = parentRoom.encrypted ? '' : message?.msg;
const discussionTopic = topic || parentRoom.name;
if (!type) {
throw new Meteor.Error('error-invalid-type', 'Cannot define discussion room type', {
method: 'DiscussionCreation',
});
}
if (
type === 'p' &&
!encrypted &&
settings.get<boolean>('E2E_Enable') &&
settings.get<boolean>('E2E_Force_Encryption_For_Private_Rooms')
) {
throw new Meteor.Error(
'error-encrypted-private-rooms-enforced-discussion',
'Workspace policy requires all private rooms to be encrypted. To create this discussion, make the parent channel public or enable encryption on it.',
{ method: 'DiscussionCreation' },
);
}
const discussion = await createRoom(
type,
name,
user,
[...new Set(invitedUsers)].filter(Boolean),
false,
false,
{
fname: discussionName,
description, // TODO discussions remove
topic: discussionTopic,
prid,
encrypted,
},
{
creator: user._id,
},
);
let discussionMsg;
if (message) {
if (parentRoom.encrypted) {
message.msg = i18n.t('Encrypted_message');
}
await mentionMessage(discussion._id, user, attachMessage(message, parentRoom));
discussionMsg = await createDiscussionMessage(message.rid, user, discussion._id, discussionName, attachMessage(message, parentRoom));
} else {
discussionMsg = await createDiscussionMessage(prid, user, discussion._id, discussionName);
}
if (reply) {
await sendMessage(user, { msg: reply }, discussion);
}
if (discussionMsg) {
afterSaveMessageAsync(discussionMsg, parentRoom, user);
}
return discussion;
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
createDiscussion: typeof create;
}
}
export const createDiscussion = async (
userId: string,
{ prid, pmid, t_name: discussionName, reply, users, encrypted, topic }: Omit<CreateDiscussionProperties, 'user'>,
): Promise<
IRoom & {
rid: string;
}
> => {
if (!settings.get('Discussion_enabled')) {
throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' });
}
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'DiscussionCreation',
});
}
if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user'], prid))) {
throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' });
}
const user = await Users.findOneById(userId, { projection: { services: 0 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDiscussion',
});
}
return create({ prid, pmid, t_name: discussionName, reply, users, user, encrypted, topic });
};
Meteor.methods<ServerMethods>({
/**
* Create discussion by room or message
* @constructor
* @param {string} prid - Parent Room Id - The room id, optional if you send pmid.
* @param {string} pmid - Parent Message Id - Create the discussion by a message, optional.
* @param {string} reply - The reply, optional
* @param {string} t_name - discussion name
* @param {string[]} users - users to be added
* @param {boolean} encrypted - if the discussion's e2e encryption should be enabled.
*/
async createDiscussion({ prid, pmid, t_name: discussionName, reply, users, encrypted }: CreateDiscussionProperties) {
methodDeprecationLogger.method('createDiscussion', '9.0.0', '/v1/rooms.createDiscussion');
check(prid, Match.Maybe(String));
check(pmid, Match.Maybe(String));
check(reply, Match.Maybe(String));
check(discussionName, String);
check(users, [String]);
check(encrypted, Match.Maybe(Boolean));
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'DiscussionCreation',
});
}
return createDiscussion(uid, { prid, pmid, t_name: discussionName, reply, users, encrypted });
},
});

View File

@@ -0,0 +1,58 @@
import { Upload } from '@rocket.chat/core-services';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Users, Uploads } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { DeleteResult } from 'mongodb';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { FileUpload } from '../../lib/media/file-upload';
import { deleteMessageValidatingPermission } from '../../lib/messages/deleteMessage';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteFileMessage(fileID: string): Promise<void | DeleteResult>;
}
}
Meteor.methods<ServerMethods>({
async deleteFileMessage(fileID) {
methodDeprecationLogger.method('deleteFileMessage', '9.0.0', '/v1/chat.delete');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'deleteFileMessage',
});
}
check(fileID, String);
const msg = await Messages.getMessageByFileId(fileID);
if (msg) {
return deleteMessageValidatingPermission(msg, userId);
}
const user = await Users.findOneById(userId, { projection: { username: 1 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'deleteFileMessage',
});
}
const file = await Uploads.findOneById(fileID, { projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 } });
if (!file) {
throw new Meteor.Error('error-invalid-file', 'Invalid file', {
method: 'deleteFileMessage',
});
}
if (!(await Upload.canDeleteFile(user, file, null))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'deleteFileMessage',
});
}
return FileUpload.getStore('Uploads').deleteById(fileID);
},
});

View File

@@ -0,0 +1,65 @@
import type { IMessage, RequiredField, SlashCommandPreviewItem } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { slashCommands } from '../../lib/utils/slashCommand';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
executeSlashCommandPreview(
command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
triggerId?: string;
},
preview: SlashCommandPreviewItem,
): void;
}
}
export const executeSlashCommandPreview = async (
command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
triggerId?: string;
},
preview: SlashCommandPreviewItem,
userId: string,
): Promise<void> => {
if (!command?.cmd || !slashCommands.commands[command.cmd]) {
throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', {
method: 'executeSlashCommandPreview',
});
}
const theCmd = slashCommands.commands[command.cmd];
if (!theCmd.providesPreview) {
throw new Meteor.Error('error-invalid-command', 'Command Does Not Provide Previews', {
method: 'executeSlashCommandPreview',
});
}
if (!preview) {
throw new Meteor.Error('error-invalid-command-preview', 'Invalid Preview Provided', {
method: 'executeSlashCommandPreview',
});
}
return slashCommands.executePreview(command.cmd, command.params, command.msg, preview, userId, command.triggerId);
};
Meteor.methods<ServerMethods>({
executeSlashCommandPreview(command, preview) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSlashCommandPreview',
});
}
return executeSlashCommandPreview(command, preview, userId);
},
});

View File

@@ -0,0 +1,70 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { follow } from '../../lib/messaging/threads/functions';
import { notifyOnMessageChange } from '../../lib/notifyListener';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
followMessage(message: { mid: IMessage['_id'] }): false | undefined;
}
}
export const followMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise<false | undefined> => {
if (mid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' });
}
const message = await Messages.findOneById(mid);
if (!message) {
throw new Meteor.Error('error-invalid-message', 'Invalid message', {
method: 'followMessage',
});
}
if (!(await canAccessRoomIdAsync(message.rid, user._id))) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' });
}
const id = message.tmid || message._id;
const followResult = await follow({ tmid: id, uid: user._id });
void notifyOnMessageChange({
id,
});
const isFollowed = true;
await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed);
return followResult;
};
Meteor.methods<ServerMethods>({
async followMessage({ mid }) {
methodDeprecationLogger.method('followMessage', '9.0.0', '/v1/chat.followMessage');
check(mid, String);
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'followMessage' });
}
return followMessage(user, { mid });
},
});
RateLimiter.limitMethod('followMessage', 5, 5000, {
userId() {
return true;
},
});

View File

@@ -0,0 +1,173 @@
import { Authorization } from '@rocket.chat/core-services';
import type { IMessage, MessageTypesValues } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { getHiddenSystemMessages } from '../../lib/messaging/getHiddenSystemMessages';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
import { settings } from '../../settings/cached';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getChannelHistory(params: {
rid: string;
latest?: Date;
oldest?: Date;
inclusive?: boolean;
offset?: number;
count?: number;
unreads?: boolean;
showThreadMessages?: boolean;
}): boolean | IMessage[] | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number };
}
}
export const getChannelHistory = async ({
rid,
fromUserId,
latest,
oldest,
inclusive,
offset = 0,
count = 20,
unreads,
showThreadMessages = true,
}: {
rid: string;
fromUserId: string;
latest?: Date;
oldest?: Date;
inclusive?: boolean;
offset?: number;
count?: number;
unreads?: boolean;
showThreadMessages?: boolean;
}): Promise<false | IMessage[] | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number }> => {
check(rid, String);
if (!fromUserId) {
return false;
}
const room = await Rooms.findOneById(rid);
if (!room) {
return false;
}
// Make sure they can access the room
if (!(await Authorization.canReadRoom(room, { _id: fromUserId }))) {
return false;
}
// Ensure latest is always defined.
if (latest === undefined) {
latest = new Date();
}
// Verify oldest is a date if it exists
if (oldest !== undefined && {}.toString.call(oldest) !== '[object Date]') {
throw new Meteor.Error('error-invalid-date', 'Invalid date', { method: 'getChannelHistory' });
}
const hiddenSystemMessages = settings.get<MessageTypesValues[]>('Hide_System_Messages');
const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages);
const options: Record<string, unknown> = {
sort: {
ts: -1,
},
skip: offset,
limit: count,
};
const records =
oldest === undefined
? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes(
rid,
latest,
hiddenMessageTypes,
options,
showThreadMessages,
inclusive,
).toArray()
: await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
rid,
oldest,
latest,
hiddenMessageTypes,
options,
showThreadMessages,
inclusive,
).toArray();
const messages = await normalizeMessagesForUser(records, fromUserId);
if (unreads) {
let unreadNotLoaded = 0;
let firstUnread = undefined;
if (oldest !== undefined) {
const firstMsg = messages[messages.length - 1];
if (firstMsg !== undefined && firstMsg.ts > oldest) {
const unreadMessages = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
rid,
oldest,
firstMsg.ts,
hiddenMessageTypes,
{
limit: 1,
sort: {
ts: 1,
},
},
showThreadMessages,
);
const totalCursor = await Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(
rid,
oldest,
firstMsg.ts,
hiddenMessageTypes,
showThreadMessages,
);
firstUnread = (await unreadMessages.toArray())[0];
unreadNotLoaded = totalCursor;
}
}
return {
messages: messages || [],
firstUnread,
unreadNotLoaded,
};
}
return {
messages: messages || [],
};
};
Meteor.methods<ServerMethods>({
async getChannelHistory({ rid, latest, oldest, inclusive, offset = 0, count = 20, unreads, showThreadMessages = true }) {
methodDeprecationLogger.method('getChannelHistory', '9.0.0', '/v1/channels.history');
check(rid, String);
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getChannelHistory' });
}
const fromUserId = Meteor.userId();
if (!fromUserId) {
return false;
}
return getChannelHistory({ rid, fromUserId, latest, oldest, inclusive, offset, count, unreads, showThreadMessages });
},
});

View File

@@ -0,0 +1,45 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdsAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getMessages(messages: IMessage['_id'][]): Promise<IMessage[]>;
}
}
Meteor.methods<ServerMethods>({
async getMessages(messages) {
methodDeprecationLogger.method('getMessages', '9.0.0', '/v1/chat.getMessages');
check(messages, [String]);
const user = await Meteor.userAsync();
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getMessages' });
}
const msgs = await Messages.findVisibleByIds(messages).toArray();
if (!msgs.length) {
return msgs;
}
if (
!(await canAccessRoomIdsAsync(
msgs.map((m) => m.rid),
user,
))
) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getMessages' });
}
return msgs;
},
});

View File

@@ -0,0 +1,42 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getSingleMessage(mid: IMessage['_id']): Promise<IMessage | null>;
}
}
export const getSingleMessage = async (userId: string, mid: IMessage['_id']): Promise<IMessage | null> => {
const msg = await Messages.findOneById(mid);
if (!msg?.rid) {
return null;
}
if (!(await canAccessRoomIdAsync(msg.rid, userId))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' });
}
return msg;
};
Meteor.methods<ServerMethods>({
async getSingleMessage(mid) {
check(mid, String);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getSingleMessage' });
}
return getSingleMessage(uid, mid);
},
});

View File

@@ -0,0 +1,51 @@
import type { IMessage, RequiredField, SlashCommandPreviews } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { slashCommands } from '../../lib/utils/slashCommand';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getSlashCommandPreviews(command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
}): SlashCommandPreviews | undefined;
}
}
export const getSlashCommandPreviews = async (command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
userId: string;
}): Promise<SlashCommandPreviews | undefined> => {
if (!command?.cmd || !slashCommands.commands[command.cmd]) {
throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', {
method: 'executeSlashCommandPreview',
});
}
const theCmd = slashCommands.commands[command.cmd];
if (!theCmd.providesPreview) {
throw new Meteor.Error('error-invalid-command', 'Command Does Not Provide Previews', {
method: 'executeSlashCommandPreview',
});
}
return slashCommands.getPreviews(command.cmd, command.params, command.msg, command.userId);
};
Meteor.methods<ServerMethods>({
async getSlashCommandPreviews(command) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSlashCommandPreview',
});
}
return getSlashCommandPreviews({ ...command, userId });
},
});

View File

@@ -0,0 +1,68 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { readThread } from '../../lib/messaging/threads/functions';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getThreadMessages(params: { tmid: IMessage['_id']; limit?: number; skip?: number }): Promise<IMessage[]>;
}
}
const MAX_LIMIT = 100;
Meteor.methods<ServerMethods>({
async getThreadMessages({ tmid, limit, skip }) {
methodDeprecationLogger.method('getThreadMessages', '9.0.0', '/v1/chat.getThreadMessages');
if ((limit ?? 0) > MAX_LIMIT) {
throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, {
method: 'getThreadMessages',
});
}
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', {
method: 'getThreadMessages',
});
}
if (typeof tmid !== 'string') {
throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'getThreadMessages' });
}
const thread = await Messages.findOneById(tmid);
if (!thread) {
return [];
}
const user = await Meteor.userAsync();
const room = await Rooms.findOneById(thread.rid);
if (!user || !room || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' });
}
if (!thread.tcount) {
return [];
}
await callbacks.run('beforeReadMessages', thread.rid, user._id);
await readThread({ user: user as IUser, room, tmid: thread._id });
const result = await Messages.findVisibleThreadByThreadId(thread._id, {
...(skip && { skip }),
...(limit && { limit }),
sort: { ts: -1 },
}).toArray();
return [thread, ...result];
},
});

View File

@@ -0,0 +1,45 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { settings } from '../../settings';
const MAX_LIMIT = 100;
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getThreadsList(params: { rid: IRoom['_id']; limit?: number; skip?: number }): IMessage[];
}
}
Meteor.methods<ServerMethods>({
async getThreadsList({ rid, limit = 50, skip = 0 }) {
methodDeprecationLogger.method('getThreadsList', '9.0.0', '/v1/chat.getThreadsList');
if (limit > MAX_LIMIT) {
throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, {
method: 'getThreadsList',
});
}
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'getThreadsList' });
}
if (typeof rid !== 'string') {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getThreadsList' });
}
const user = await Meteor.userAsync();
const room = await Rooms.findOneById(rid);
if (!user || !room || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not Allowed', { method: 'getThreadsList' });
}
return Messages.findThreadsByRoomId(room._id, skip, limit).toArray();
},
});

View File

@@ -0,0 +1,53 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Users, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getUserMentionsByChannel(params: { roomId: string; options: { limit: number; skip: number; sort: { ts: -1 | 1 } } }): IMessage[];
}
}
export const getUserMentionsByChannel = async (
userId: string,
roomId: string,
options: { limit?: number; skip?: number; sort?: { ts?: -1 | 1 } },
) => {
check(roomId, String);
const user = await Users.findOneById(userId);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
const room = await Rooms.findOneById(roomId);
if (!room || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'getUserMentionsByChannel',
});
}
return Messages.findVisibleByMentionAndRoomId(user.username, roomId, options).toArray();
};
Meteor.methods<ServerMethods>({
async getUserMentionsByChannel({ roomId, options }) {
methodDeprecationLogger.method('getUserMentionsByChannel', '9.0.0', '/v1/channels.getAllUserMentionsByChannel');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getUserMentionsByChannel',
});
}
return getUserMentionsByChannel(uid, roomId, options);
},
});

View File

@@ -0,0 +1,67 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync, roomAccessAttributes } from '../../lib/authorization';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { loadMessageHistory } from '../../lib/messages/loadMessageHistory';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadHistory(
rid: IRoom['_id'],
ts?: Date,
limit?: number,
ls?: string | Date,
showThreadMessages?: boolean,
):
| {
messages: IMessage[];
firstUnread: IMessage | undefined;
unreadNotLoaded: number;
}
| false;
}
}
Meteor.methods<ServerMethods>({
async loadHistory(rid, end, limit = 20, ls, showThreadMessages = true) {
methodDeprecationLogger.method('loadHistory', '9.0.0', '/v1/rooms.history');
check(rid, String);
const fromUser = await Meteor.userAsync();
if (!fromUser && settings.get('Accounts_AllowAnonymousRead') === false) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'loadHistory',
});
}
const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, sysMes: 1 } });
if (!room) {
return false;
}
// this checks the Allow Anonymous Read setting, so no need to check again
if (!(await canAccessRoomAsync(room, fromUser || undefined))) {
return false;
}
// if fromId is undefined and it passed the previous check, the user is reading anonymously
if (!fromUser) {
return loadMessageHistory({ rid, end, limit, ls, showThreadMessages, room });
}
const canPreview = await hasPermissionAsync(fromUser, 'preview-c-room');
if (room.t === 'c' && !canPreview && !(await Subscriptions.findOneByRoomIdAndUserId(rid, fromUser._id, { projection: { _id: 1 } }))) {
return false;
}
return loadMessageHistory({ userId: fromUser._id, rid, end, limit, ls, showThreadMessages, room });
},
});

View File

@@ -0,0 +1,39 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadMissedMessages(rid: IRoom['_id'], ts: Date): Promise<false | IMessage[]>;
}
}
Meteor.methods<ServerMethods>({
async loadMissedMessages(rid, start) {
methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages');
check(rid, String);
check(start, Date);
const fromId = Meteor.userId() ?? undefined;
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getUsersOfRoom' });
}
if (!(await canAccessRoomIdAsync(rid, fromId))) {
return false;
}
return Messages.findVisibleByRoomIdAfterTimestamp(rid, start, true, {
sort: {
ts: -1,
},
}).toArray();
},
});

View File

@@ -0,0 +1,61 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadNextMessages(rid: IRoom['_id'], end?: Date, limit?: number): Promise<{ messages: IMessage[] }>;
}
}
Meteor.methods<ServerMethods>({
async loadNextMessages(rid, end, limit = 20) {
methodDeprecationLogger.method('loadNextMessages', '9.0.0', '/v1/rooms.history');
check(rid, String);
check(limit, Number);
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'loadNextMessages',
});
}
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'loadNextMessages' });
}
const fromId = Meteor.userId();
if (!fromId || !(await canAccessRoomIdAsync(rid, fromId))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'loadNextMessages' });
}
let records;
if (end) {
records = await Messages.findVisibleByRoomIdAfterTimestamp(rid, end, true, {
sort: {
ts: 1,
},
limit,
}).toArray();
} else {
records = await Messages.findVisibleByRoomId(rid, {
sort: {
ts: 1,
},
limit,
}).toArray();
}
return {
messages: await normalizeMessagesForUser(records, fromId),
};
},
});

View File

@@ -0,0 +1,102 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { FindOptions } from 'mongodb';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadSurroundingMessages(
message: Pick<IMessage, '_id' | 'rid'> & { ts?: Date },
limit?: number,
showThreadMessages?: boolean,
):
| {
messages: IMessage[];
moreBefore: boolean;
moreAfter: boolean;
}
| false;
}
}
Meteor.methods<ServerMethods>({
async loadSurroundingMessages(message, limit = 50, showThreadMessages = true) {
methodDeprecationLogger.method('loadSurroundingMessages', '9.0.0', '/v1/rooms.history');
check(message, Object);
check(limit, Number);
check(showThreadMessages, Boolean);
const fromId = Meteor.userId() ?? undefined;
if (!fromId && settings.get('Accounts_AllowAnonymousRead') === false) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'loadSurroundingMessages',
});
}
if (!message._id) {
return false;
}
const mainMessage = await Messages.findOneById(message._id);
if (!mainMessage?.rid) {
return false;
}
if (!(await canAccessRoomIdAsync(mainMessage.rid, fromId))) {
return false;
}
limit -= 1;
const options: FindOptions<IMessage> = {
sort: {
ts: -1,
},
limit: Math.ceil(limit / 2),
};
const messages = await Messages.findVisibleByRoomIdBeforeTimestamp(
mainMessage.rid,
mainMessage.ts,
showThreadMessages,
options,
).toArray();
const moreBefore = messages.length === options.limit;
messages.push(mainMessage);
options.sort = {
ts: 1,
};
options.limit = Math.floor(limit / 2);
const afterMessages = await Messages.findVisibleByRoomIdAfterTimestamp(
mainMessage.rid,
mainMessage.ts,
showThreadMessages,
options,
).toArray();
const moreAfter = afterMessages.length === options.limit;
messages.push(...afterMessages);
return {
messages: fromId ? await normalizeMessagesForUser(messages, fromId) : messages,
moreBefore,
moreAfter,
};
},
});

View File

@@ -0,0 +1,121 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Logger } from '@rocket.chat/logger';
import { Messages, Subscriptions, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { parseMessageSearchQuery } from '../../lib/parseMessageSearchQuery';
import type { IRawSearchResult } from '../../lib/search/model/ISearchResult';
import { settings } from '../../settings';
const logger = new Logger('MessageSearch');
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
messageSearch(text: string, rid?: string, limit?: number, offset?: number): IRawSearchResult | false;
}
}
export const messageSearch = async function (
userId: string,
text: string,
rid?: string,
limit?: number,
offset?: number,
): Promise<IRawSearchResult | false> {
check(text, String);
check(rid, Match.Maybe(String));
check(limit, Match.Optional(Number));
check(offset, Match.Optional(Number));
// Don't process anything else if the user can't access the room
if (rid) {
if (!(await canAccessRoomIdAsync(rid, userId))) {
return false;
}
} else if (settings.get('Search.defaultProvider.GlobalSearchEnabled') !== true) {
return {
message: {
docs: [],
},
};
}
const user = (await Users.findOneById(userId)) || undefined;
let parsedQuery: ReturnType<typeof parseMessageSearchQuery>;
try {
parsedQuery = parseMessageSearchQuery(text, {
user,
offset,
limit,
forceRegex: settings.get('Message_AlwaysSearchRegExp'),
});
} catch (error: unknown) {
logger.error({ msg: 'Error while parsing message search query', error });
if (error instanceof SyntaxError) {
return { message: { docs: [] } };
}
throw error;
}
const { query, options } = parsedQuery;
if (Object.keys(query).length === 0) {
return {
message: {
docs: [],
},
};
}
query.t = {
$ne: 'rm', // hide removed messages (useful when searching for user messages)
};
query._hidden = {
$ne: true, // don't return _hidden messages
};
if (rid) {
query.rid = rid;
} else {
query.rid = {
$in: user?._id ? (await Subscriptions.findByUserId(user._id).toArray()).map((subscription: ISubscription) => subscription.rid) : [],
};
}
try {
return {
message: {
docs: await Messages.find(query, {
// @ts-expect-error col.s.db is not typed
readPreference: readSecondaryPreferred(Messages.col.s.db),
...options,
}).toArray(),
},
};
} catch (error) {
logger.error({ msg: 'Error while finding messages', error });
throw new Error('error-while-finding-messages', { cause: error });
}
};
Meteor.methods<ServerMethods>({
async messageSearch(text, rid, limit, offset) {
methodDeprecationLogger.method('messageSearch', '9.0.0', '/v1/chat.search');
const currentUserId = Meteor.userId();
if (!currentUserId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'messageSearch',
});
}
return messageSearch(currentUserId, text, rid, limit, offset);
},
});

View File

@@ -0,0 +1,39 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { readMessages } from '../../lib/readMessages';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
readMessages(rid: string, readThreads?: boolean): Promise<void>;
}
}
Meteor.methods<ServerMethods>({
async readMessages(rid, readThreads = false) {
check(rid, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'readMessages',
});
}
const user = ((await Meteor.userAsync()) as IUser | null) ?? undefined;
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readMessages' });
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readMessages' });
}
await readMessages(room, userId, readThreads);
},
});

View File

@@ -0,0 +1,53 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { readThread } from '../../lib/messaging/threads/functions';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
readThreads(tmid: IMessage['_id']): void;
}
}
Meteor.methods<ServerMethods>({
async readThreads(tmid) {
methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/chat.readThread');
check(tmid, String);
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', {
method: 'getThreadMessages',
});
}
const thread = await Messages.findOneById(tmid);
if (!thread) {
return;
}
const user = (await Meteor.userAsync()) ?? undefined;
const room = await Rooms.findOneById(thread.rid);
if (!room) {
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' });
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' });
}
await callbacks.run('beforeReadMessages', thread.rid, user?._id);
if (user?._id) {
await readThread({ user: user as IUser, room, tmid });
}
},
});

View File

@@ -0,0 +1,111 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const findOneByIdAndUserIdAndRoomId = sinon.stub();
const updateFileMetadata = sinon.stub().resolves();
const getPath = sinon.stub().returns('/path/to/file.txt');
const isImagePreviewSupported = sinon.stub().returns(false);
const getFileExtension = sinon.stub().returns('txt');
const { parseFileIntoMessageAttachments } = proxyquire.noCallThru().load('./sendFileMessage', {
'@rocket.chat/models': {
Uploads: { findOneByIdAndUserIdAndRoomId, updateFileMetadata },
Rooms: { findOneById: sinon.stub() },
Users: { findOneById: sinon.stub() },
},
'meteor/check': {
check: sinon.stub(),
Match: {
Maybe: sinon.stub(),
Optional: sinon.stub(),
ObjectIncluding: sinon.stub(),
},
},
'meteor/meteor': {
Meteor: {
Error: class Error extends global.Error {},
methods: sinon.stub(),
},
},
'../../lib/media/file-upload/lib/FileUpload': {
FileUpload: { getPath },
},
'../../lib/media/file-upload/isImagePreviewSupported': { isImagePreviewSupported },
'../../../lib/utils/getFileExtension': { getFileExtension },
'../../lib/callbacks': { callbacks: { runAsync: sinon.stub() } },
'../../lib/logger/system': { SystemLogger: { error: sinon.stub() } },
'../../lib/authorization/canAccessRoom': { canAccessRoomAsync: sinon.stub().resolves(true) },
'./sendMessage': { executeSendMessage: sinon.stub().resolves({}) },
});
describe('sendFileMessage - Mass Assignment & Type Pollution Prevention', () => {
const mockUser = { _id: 'user123' };
const roomId = 'room123';
beforeEach(() => {
findOneByIdAndUserIdAndRoomId.reset();
updateFileMetadata.reset();
findOneByIdAndUserIdAndRoomId.resolves({ _id: 'file123' });
});
it('should filter out invalid types, nulls, and malicious fields before updating the database', async () => {
const maliciousFilePayload = {
_id: 'file123',
name: null, // invalid type, must be ignored
type: 'text/plain',
size: 1024,
description: 12345, // invalid type, must be ignored
typeGroup: 'image', // only valid field
content: null, // invalid type, must be ignored
maliciousRoleAssignment: 'admin', // mass assignment, must be ignored
$set: { bypassSecurity: true }, // mongo injection, must be ignored
};
await parseFileIntoMessageAttachments(maliciousFilePayload as any, roomId, mockUser as any);
expect(updateFileMetadata.calledOnce).to.equal(true);
const [fileId, userId, safeMetadata] = updateFileMetadata.getCall(0).args;
expect(fileId).to.equal('file123');
expect(userId).to.equal('user123');
expect(safeMetadata).to.deep.equal({
typeGroup: 'image',
});
});
it('should pass valid fields correctly to the database', async () => {
const validFilePayload = {
_id: 'file123',
name: 'picture.jpg',
type: 'image/jpeg',
size: 2048,
description: 'Description',
typeGroup: 'image',
content: {
algorithm: 'rc.v1.aes-sha2',
ciphertext: 'test',
},
};
await parseFileIntoMessageAttachments(validFilePayload as any, roomId, mockUser as any);
expect(updateFileMetadata.calledOnce).to.equal(true);
const [, , safeMetadata] = updateFileMetadata.getCall(0).args;
expect(safeMetadata).to.deep.equal({
name: 'picture.jpg',
description: 'Description',
typeGroup: 'image',
content: {
algorithm: 'rc.v1.aes-sha2',
ciphertext: 'test',
},
});
});
});

View File

@@ -0,0 +1,255 @@
import type {
MessageAttachment,
FileAttachmentProps,
IUser,
IUpload,
AtLeast,
FilesAndAttachments,
IMessage,
FileProp,
} from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Uploads, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { executeSendMessage } from './sendMessage';
import { getFileExtension } from '../../../lib/utils/getFileExtension';
import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { SystemLogger } from '../../lib/logger/system';
import { isImagePreviewSupported } from '../../lib/media/file-upload/isImagePreviewSupported';
import { FileUpload } from '../../lib/media/file-upload/lib/FileUpload';
function validateFileRequiredFields(file: Partial<IUpload>): asserts file is AtLeast<IUpload, '_id' | 'name' | 'type' | 'size'> {
const requiredFields = ['_id', 'name', 'type', 'size'];
requiredFields.forEach((field) => {
if (!Object.keys(file).includes(field)) {
throw new Meteor.Error('error-invalid-file', 'Invalid file');
}
});
}
export const parseFileIntoMessageAttachments = async (
file: Partial<IUpload>,
roomId: string,
user: IUser,
): Promise<FilesAndAttachments> => {
validateFileRequiredFields(file);
const upload = await Uploads.findOneByIdAndUserIdAndRoomId(file._id, user._id, roomId, { projection: { _id: 1 } });
if (!upload) {
throw new Meteor.Error('error-invalid-file', 'Invalid file', {
method: 'sendFileMessage',
});
}
const safeMetadata = {
...(typeof file.name === 'string' && { name: file.name }),
...(typeof file.description === 'string' && { description: file.description }),
...(typeof file.typeGroup === 'string' && { typeGroup: file.typeGroup }),
...(file.content && typeof file.content === 'object' && { content: file.content }),
};
await Uploads.updateFileMetadata(file._id, user._id, safeMetadata);
const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`);
const attachments: MessageAttachment[] = [];
const files: FileProp[] = [
{
_id: file._id,
name: file.name || '',
type: file.type || 'file',
size: file.size || 0,
format: file.identify?.format || '',
typeGroup: file.typeGroup,
},
];
if (isImagePreviewSupported(file.type as string)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
image_alt: file?.description,
title_link: fileUrl,
title_link_download: true,
image_url: fileUrl,
image_type: file.type as string,
image_size: file.size,
fileId: file._id,
};
if (file.identify?.size) {
attachment.image_dimensions = file.identify.size;
}
try {
attachment.image_preview = await FileUpload.resizeImagePreview(file);
const thumbResult = await FileUpload.createImageThumbnail(file);
if (thumbResult) {
const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult;
const thumbnail = await FileUpload.uploadImageThumbnail(
{
thumbFileName,
thumbFileType,
originalFileId,
},
thumbBuffer,
roomId,
user._id,
);
const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`);
attachment.image_url = thumbUrl;
attachment.image_type = thumbnail.type;
attachment.image_dimensions = {
width,
height,
};
files.push({
_id: thumbnail._id,
name: thumbnail.name || '',
type: thumbnail.type || 'file',
size: thumbnail.size || 0,
format: thumbnail.identify?.format || '',
typeGroup: thumbnail.typeGroup || '',
});
}
} catch (err) {
SystemLogger.error({ err });
}
attachments.push(attachment);
} else if (/^audio\/.+/.test(file.type as string)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
audio_url: fileUrl,
audio_type: file.type as string,
audio_size: file.size,
fileId: file._id,
};
attachments.push(attachment);
} else if (/^video\/.+/.test(file.type as string)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
video_url: fileUrl,
video_type: file.type as string,
video_size: file.size as number,
fileId: file._id,
};
attachments.push(attachment);
} else {
const attachment = {
title: file.name,
type: 'file',
format: getFileExtension(file.name),
description: file.description,
title_link: fileUrl,
title_link_download: true,
size: file.size as number,
fileId: file._id,
};
attachments.push(attachment);
}
return { files, attachments };
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendFileMessage: (roomId: string, _store: string, file: Partial<IUpload>, msgData?: Record<string, any>) => boolean;
}
}
export const sendFileMessage = async (
userId: string,
{
roomId,
file,
msgData,
}: {
roomId: string;
file: Partial<IUpload>;
msgData?: Record<string, any>;
},
): Promise<boolean> => {
const user = await Users.findOneById(userId, { projection: { services: 0 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'sendFileMessage',
} as any);
}
const room = await Rooms.findOneById(roomId);
if (!room) {
return false;
}
if (user?.type !== 'app' && !(await canAccessRoomAsync(room, user))) {
return false;
}
check(
msgData,
Match.Maybe({
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
tmid: Match.Optional(String),
customFields: Match.Optional(String),
t: Match.Optional(String),
content: Match.Optional(
Match.ObjectIncluding({
algorithm: String,
ciphertext: String,
}),
),
}),
);
const data = {
rid: roomId,
ts: new Date(),
...(msgData as Partial<IMessage>),
...(msgData?.customFields && { customFields: JSON.parse(msgData.customFields) }),
msg: msgData?.msg ?? '',
groupable: msgData?.groupable ?? false,
};
const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user);
data.file = files[0];
data.files = files;
data.attachments = attachments;
const msg = await executeSendMessage(userId, data);
callbacks.runAsync('afterFileUpload', { user, room, message: msg });
return msg;
};
Meteor.methods<ServerMethods>({
async sendFileMessage(roomId, _store, file, msgData = {}) {
methodDeprecationLogger.method('sendFileMessage', '9.0.0', '/v1/rooms.mediaConfirm/:rid/:fileId');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'sendFileMessage',
} as any);
}
return sendFileMessage(userId, { roomId, file, msgData });
},
});

View File

@@ -0,0 +1,187 @@
import { api } from '@rocket.chat/core-services';
import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import type { RocketchatI18nKeys } from '@rocket.chat/i18n';
import { MessageTypes } from '@rocket.chat/message-types';
import { Messages, Users } from '@rocket.chat/models';
import type { TOptions } from 'i18next';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { i18n } from '../../lib/i18n';
import { SystemLogger } from '../../lib/logger/system';
import { sendMessage } from '../../lib/messages/sendMessage';
import { metrics } from '../../lib/metrics';
import { settings } from '../../settings';
/**
*
* @param uid
* @param message
* @param extraInfo
* - ts: The timestamp of the message. the message object already has a ts, but this value is validated and only a window of 10 seconds is allowed to be used. this value overrides the message.ts value without validation.
*
*
* @returns
*/
export async function executeSendMessage(
uid: IUser['_id'] | IUser,
message: AtLeast<IMessage, 'rid'>,
extraInfo?: { ts?: Date; previewUrls?: string[] },
) {
if (message.tshow && !message.tmid) {
throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', {
method: 'sendMessage',
});
}
if (message.tmid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', {
method: 'sendMessage',
});
}
const isTimestampFromClient = Boolean(!extraInfo?.ts && message.ts);
const now = new Date();
message.ts = extraInfo?.ts ?? message.ts ?? now;
if (isTimestampFromClient) {
const tsDiff = Math.abs(moment(message.ts).diff(Date.now()));
if (tsDiff > 60000) {
throw new Meteor.Error('error-message-ts-out-of-sync', 'Message timestamp is out of sync', {
method: 'sendMessage',
message_ts: message.ts,
server_ts: new Date().getTime(),
});
}
if (tsDiff > 10000) {
message.ts = now;
}
}
if (message.msg) {
if (message.msg.length > (settings.get<number>('Message_MaxAllowedSize') ?? 0)) {
throw new Meteor.Error('error-message-size-exceeded', 'Message size exceeds Message_MaxAllowedSize', {
method: 'sendMessage',
});
}
}
const user = typeof uid === 'string' ? await Users.findOneById(uid) : uid;
if (!user?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
let { rid } = message;
// do not allow nested threads
if (message.tmid) {
const parentMessage = await Messages.findOneById(message.tmid, { projection: { rid: 1, tmid: 1 } });
message.tmid = parentMessage?.tmid || message.tmid;
if (parentMessage?.rid) {
rid = parentMessage?.rid;
}
}
if (!rid) {
throw new Error("The 'rid' property on the message object is missing.");
}
check(rid, String);
try {
const room = await canSendMessageAsync(rid, user);
if (room.encrypted && settings.get<boolean>('E2E_Enable') && !settings.get<boolean>('E2E_Allow_Unencrypted_Messages')) {
if (message.t !== 'e2e') {
throw new Meteor.Error('error-not-allowed', 'Not allowed to send un-encrypted messages in an encrypted room', {
method: 'sendMessage',
});
}
}
const result = await sendMessage(user, message, room, { previewUrls: extraInfo?.previewUrls });
metrics.messagesSent.inc();
metrics.messagesSentTotal.inc();
return result;
} catch (err: any) {
SystemLogger.error({ msg: 'Error sending message:', err });
const errorMessage: RocketchatI18nKeys = typeof err === 'string' ? err : err.error || err.message;
const errorContext: TOptions = err.details ?? {};
void api.broadcast('notify.ephemeralMessage', user._id, message.rid, {
msg: i18n.t(errorMessage, { ...errorContext, lng: user.language }),
});
if (typeof err === 'string') {
throw new Error(err);
}
throw err;
}
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendMessage(message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>, previewUrls?: string[]): any;
}
}
Meteor.methods<ServerMethods>({
async sendMessage(message, previewUrls) {
methodDeprecationLogger.method('sendMessage', '9.0.0', '/v1/chat.sendMessage');
check(message, {
_id: Match.Maybe(String),
rid: Match.Maybe(String),
msg: Match.Maybe(String),
tmid: Match.Maybe(String),
tshow: Match.Maybe(Boolean),
ts: Match.Maybe(Date),
t: Match.Maybe(String),
bot: Match.Maybe(Object),
content: Match.Maybe(Object),
e2e: Match.Maybe(String),
e2eMentions: Match.Maybe(Object),
customFields: Match.Maybe(Object),
federation: Match.Maybe(Object),
groupable: Match.Maybe(Boolean),
sentByEmail: Match.Maybe(Boolean),
});
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'sendMessage',
});
}
if (MessageTypes.isSystemMessage(message)) {
throw new Error("Cannot send system messages using 'sendMessage'");
}
try {
return await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls }));
} catch (error: any) {
if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) {
throw new Meteor.Error(error.error || error.message, error.reason, {
method: 'sendMessage',
});
}
}
},
});
// Limit a user, who does not have the "bot" role, to sending 5 msgs/second
RateLimiter.limitMethod('sendMessage', 5, 1000, {
async userId(userId: IUser['_id']) {
return !(await hasPermissionAsync(userId, 'send-many-messages'));
},
});

View File

@@ -0,0 +1,70 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { unfollow } from '../../lib/messaging/threads/functions';
import { notifyOnMessageChange } from '../../lib/notifyListener';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
unfollowMessage(message: { mid: IMessage['_id'] }): false | undefined;
}
}
export const unfollowMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise<false | undefined> => {
if (mid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' });
}
const message = await Messages.findOneById(mid);
if (!message) {
throw new Meteor.Error('error-invalid-message', 'Invalid message', {
method: 'unfollowMessage',
});
}
if (!(await canAccessRoomIdAsync(message.rid, user._id))) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' });
}
const id = message.tmid || message._id;
const unfollowResult = await unfollow({ rid: message.rid, tmid: id, uid: user._id });
void notifyOnMessageChange({
id,
});
const isFollowed = false;
await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed);
return unfollowResult;
};
Meteor.methods<ServerMethods>({
async unfollowMessage({ mid }) {
methodDeprecationLogger.method('unfollowMessage', '9.0.0', '/v1/chat.unfollowMessage');
check(mid, String);
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unfollowMessage' });
}
return unfollowMessage(user, { mid });
},
});
RateLimiter.limitMethod('unfollowMessage', 5, 5000, {
userId() {
return true;
},
});

View File

@@ -0,0 +1,121 @@
import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper';
import { updateMessage } from '../../lib/messages/updateMessage';
import { settings } from '../../settings';
const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content', 'e2eMentions'];
export async function executeUpdateMessage(
uid: IUser['_id'],
message: AtLeast<IMessage, '_id' | 'rid' | 'msg' | 'customFields'> | AtLeast<IMessage, '_id' | 'rid' | 'content'>,
previewUrls?: string[],
) {
const originalMessage = await Messages.findOneById(message._id);
if (!originalMessage?._id) {
return;
}
Object.entries(message).forEach(([key, value]) => {
if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) {
throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, {
method: 'updateMessage',
});
}
});
// IF the message has custom fields, always update
// Ideally, we'll compare the custom fields to check for change, but since we don't know the shape of
// custom fields, as it's user defined, we're gonna update
const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg;
if (msgText === message.msg && !previewUrls && !message.customFields) {
return;
}
if (!!message.tmid && originalMessage._id === message.tmid) {
throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', {
method: 'updateMessage',
});
}
if (!originalMessage.tmid && !!message.tmid) {
throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' });
}
const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = originalMessage.u && originalMessage.u._id === uid;
if (!_hasPermission && (!editAllowed || !editOwn)) {
throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', {
method: 'updateMessage',
action: 'Message_editing',
});
}
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes');
const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete', message.rid);
if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) {
let currentTsDiff = 0;
let msgTs;
if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) {
msgTs = moment(originalMessage.ts);
}
if (msgTs) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
if (currentTsDiff >= blockEditInMinutes) {
throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', {
method: 'updateMessage',
});
}
}
const user = await Users.findOneById(uid);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user });
// It is possible to have an empty array as the attachments property, so ensure both things exist
if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) {
originalMessage.attachments[0].description = message.msg;
message.attachments = originalMessage.attachments;
message.msg = originalMessage.msg;
}
message.u = originalMessage.u;
return updateMessage(message, user, originalMessage, previewUrls);
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateMessage(message: IEditedMessage, previewUrls?: string[]): void;
}
}
Meteor.methods<ServerMethods>({
async updateMessage(message: IEditedMessage, previewUrls?: string[]) {
check(message, Match.ObjectIncluding({ _id: String }));
check(previewUrls, Match.Maybe([String]));
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
return applyAirGappedRestrictionsValidation(() => executeUpdateMessage(uid, message, previewUrls));
},
});