snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -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'));
|
||||
},
|
||||
});
|
||||
277
apps/meteor/server/meteor-methods/messages/createDiscussion.ts
Normal file
277
apps/meteor/server/meteor-methods/messages/createDiscussion.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
70
apps/meteor/server/meteor-methods/messages/followMessage.ts
Normal file
70
apps/meteor/server/meteor-methods/messages/followMessage.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
173
apps/meteor/server/meteor-methods/messages/getChannelHistory.ts
Normal file
173
apps/meteor/server/meteor-methods/messages/getChannelHistory.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
45
apps/meteor/server/meteor-methods/messages/getMessages.ts
Normal file
45
apps/meteor/server/meteor-methods/messages/getMessages.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
},
|
||||
});
|
||||
45
apps/meteor/server/meteor-methods/messages/getThreadsList.ts
Normal file
45
apps/meteor/server/meteor-methods/messages/getThreadsList.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
67
apps/meteor/server/meteor-methods/messages/loadHistory.ts
Normal file
67
apps/meteor/server/meteor-methods/messages/loadHistory.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
121
apps/meteor/server/meteor-methods/messages/messageSearch.ts
Normal file
121
apps/meteor/server/meteor-methods/messages/messageSearch.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
39
apps/meteor/server/meteor-methods/messages/readMessages.ts
Normal file
39
apps/meteor/server/meteor-methods/messages/readMessages.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
53
apps/meteor/server/meteor-methods/messages/readThreads.ts
Normal file
53
apps/meteor/server/meteor-methods/messages/readThreads.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
255
apps/meteor/server/meteor-methods/messages/sendFileMessage.ts
Normal file
255
apps/meteor/server/meteor-methods/messages/sendFileMessage.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
187
apps/meteor/server/meteor-methods/messages/sendMessage.ts
Normal file
187
apps/meteor/server/meteor-methods/messages/sendMessage.ts
Normal 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'));
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
121
apps/meteor/server/meteor-methods/messages/updateMessage.ts
Normal file
121
apps/meteor/server/meteor-methods/messages/updateMessage.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user