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:
35
apps/meteor/server/lib/messages/attachMessage.ts
Normal file
35
apps/meteor/server/lib/messages/attachMessage.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getUserDisplayName } from '@rocket.chat/core-typings';
|
||||
import type { IMessage, IRoom, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
|
||||
import { settings } from '../../settings/cached';
|
||||
import { roomCoordinator } from '../rooms/roomCoordinator';
|
||||
import { getUserAvatarURL } from '../utils/getUserAvatarURL';
|
||||
|
||||
export const attachMessage = function (
|
||||
message: IMessage,
|
||||
room: IRoom,
|
||||
): {
|
||||
text: string;
|
||||
author_name?: string;
|
||||
author_icon: string;
|
||||
message_link: string;
|
||||
attachments?: MessageAttachment[];
|
||||
ts: Date;
|
||||
} {
|
||||
const useRealName = Boolean(settings.get('UI_Use_Real_Name'));
|
||||
const {
|
||||
msg,
|
||||
u: { username, name },
|
||||
ts,
|
||||
attachments,
|
||||
_id,
|
||||
} = message;
|
||||
return {
|
||||
text: msg,
|
||||
author_name: getUserDisplayName(name, username, useRealName),
|
||||
author_icon: getUserAvatarURL(username) as string,
|
||||
message_link: `${roomCoordinator.getRouteLink(room.t, room)}?msg=${_id}`,
|
||||
...(attachments && { attachments }),
|
||||
ts,
|
||||
};
|
||||
};
|
||||
129
apps/meteor/server/lib/messages/deleteMessage.ts
Normal file
129
apps/meteor/server/lib/messages/deleteMessage.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { AppEvents, Apps } from '@rocket.chat/apps';
|
||||
import { api, Message } from '@rocket.chat/core-services';
|
||||
import { isThreadMessage, type AtLeast, type IMessage, type IRoom, type IThreadMessage, type IUser } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms, Uploads, Users, ReadReceipts, ReadReceiptsArchive, Subscriptions } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
import { canDeleteMessageAsync } from '../authorization/canDeleteMessage';
|
||||
import { callbacks } from '../callbacks';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
import { notifyOnRoomChangedById, notifyOnMessageChange, notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../notifyListener';
|
||||
|
||||
export const deleteMessageValidatingPermission = async (message: AtLeast<IMessage, '_id'>, userId: IUser['_id']): Promise<void> => {
|
||||
if (!message?._id) {
|
||||
throw new Meteor.Error('error-invalid-message', 'Invalid message');
|
||||
}
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(userId);
|
||||
const originalMessage = await Messages.findOneById(message._id);
|
||||
|
||||
if (!originalMessage || !user || !(await canDeleteMessageAsync(user, originalMessage))) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Not allowed');
|
||||
}
|
||||
|
||||
return deleteMessage(originalMessage, user);
|
||||
};
|
||||
|
||||
export async function deleteMessage(message: IMessage, user: IUser): Promise<void> {
|
||||
const deletedMsg: IMessage | null = await Messages.findOneById(message._id);
|
||||
const isThread = (deletedMsg?.tcount || 0) > 0;
|
||||
const keepHistory = settings.get('Message_KeepHistory') || isThread;
|
||||
const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread;
|
||||
|
||||
const room = await Rooms.findOneById(message.rid, { projection: { lastMessage: 1, prid: 1, mid: 1, federated: 1, federation: 1 } });
|
||||
|
||||
if (deletedMsg) {
|
||||
const prevent = await Apps.self?.triggerEvent(AppEvents.IPreMessageDeletePrevent, deletedMsg);
|
||||
if (prevent) {
|
||||
throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the message deleting.');
|
||||
}
|
||||
|
||||
if (room) {
|
||||
await Message.beforeDelete(deletedMsg, room);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedMsg && isThreadMessage(deletedMsg)) {
|
||||
await deleteThreadMessage(deletedMsg, user, room);
|
||||
}
|
||||
|
||||
const files = (message.files || [message.file]).filter(Boolean); // Keep compatibility with old messages
|
||||
|
||||
if (keepHistory) {
|
||||
if (showDeletedStatus) {
|
||||
// TODO is there a better way to tell TS "IUser[username]" is not undefined?
|
||||
await Messages.cloneAndSaveAsHistoryById(message._id, user as Required<Pick<IUser, '_id' | 'username' | 'name'>>);
|
||||
} else {
|
||||
await Messages.setHiddenById(message._id, true);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
file?._id && (await Uploads.updateOne({ _id: file._id }, { $set: { _hidden: true } }));
|
||||
}
|
||||
} else {
|
||||
if (!showDeletedStatus) {
|
||||
await Messages.removeById(message._id);
|
||||
}
|
||||
await ReadReceipts.removeByMessageId(message._id);
|
||||
await ReadReceiptsArchive.removeByMessageId(message._id);
|
||||
|
||||
for (const file of files) {
|
||||
file?._id && (await FileUpload.getStore('Uploads').deleteById(file._id));
|
||||
}
|
||||
}
|
||||
if (showDeletedStatus) {
|
||||
// TODO is there a better way to tell TS "IUser[username]" is not undefined?
|
||||
await Messages.setAsDeletedByIdAndUser(message._id, user as Required<Pick<IUser, '_id' | 'username' | 'name'>>);
|
||||
} else {
|
||||
void api.broadcast('notify.deleteMessage', message.rid, { _id: message._id });
|
||||
}
|
||||
|
||||
// update last message
|
||||
if (settings.get('Store_Last_Message') && (!room?.lastMessage || room.lastMessage._id === message._id)) {
|
||||
const lastMessageNotDeleted = await Messages.getLastVisibleUserMessageSentByRoomId(message.rid);
|
||||
await Rooms.resetLastMessageById(message.rid, lastMessageNotDeleted, -1);
|
||||
} else {
|
||||
// decrease message count
|
||||
await Rooms.decreaseMessageCountById(message.rid, 1);
|
||||
}
|
||||
|
||||
await callbacks.run('afterDeleteMessage', deletedMsg, { room, user });
|
||||
|
||||
void notifyOnRoomChangedById(message.rid);
|
||||
|
||||
if (keepHistory || showDeletedStatus) {
|
||||
void notifyOnMessageChange({
|
||||
id: message._id,
|
||||
});
|
||||
}
|
||||
|
||||
if (deletedMsg) {
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostMessageDeleted, deletedMsg, user);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteThreadMessage(message: IThreadMessage, user: IUser, room: IRoom | null): Promise<void> {
|
||||
const updatedParentMessage = await Messages.decreaseReplyCountById(message.tmid, -1);
|
||||
|
||||
if (room) {
|
||||
const { modifiedCount } = await Subscriptions.removeUnreadThreadsByRoomId(room._id, [message.tmid]);
|
||||
if (modifiedCount > 0) {
|
||||
// The replies array contains the ids of all the users that are following the thread (everyone that is involved + the ones who are following)
|
||||
// Technically, user._id is already in the message.replies array, but since we don't have any strong
|
||||
// guarantees of it, we are adding again to make sure it is there.
|
||||
const userIdsThatAreWatchingTheThread = [...new Set([user._id, ...(message.replies || [])])];
|
||||
// So they can decrement the unread threads count
|
||||
void notifyOnSubscriptionChangedByRoomIdAndUserIds(room._id, userIdsThatAreWatchingTheThread);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedParentMessage?.tcount === 0) {
|
||||
void notifyOnMessageChange({
|
||||
id: message.tmid,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Root, Paragraph, Blocks, Inlines, UserMention, ChannelMention, Task, ListItem, BigEmoji } from '@rocket.chat/message-parser';
|
||||
|
||||
type ExtractedMentions = {
|
||||
mentions: string[];
|
||||
channels: string[];
|
||||
};
|
||||
|
||||
type MessageNode = Paragraph | Blocks | Inlines | Task | ListItem | BigEmoji;
|
||||
|
||||
function isUserMention(node: MessageNode): node is UserMention {
|
||||
return node.type === 'MENTION_USER';
|
||||
}
|
||||
|
||||
function isChannelMention(node: MessageNode): node is ChannelMention {
|
||||
return node.type === 'MENTION_CHANNEL';
|
||||
}
|
||||
|
||||
function hasArrayValue(node: MessageNode): node is MessageNode & { value: MessageNode[] } {
|
||||
return Array.isArray(node.value);
|
||||
}
|
||||
|
||||
function hasObjectValue(node: MessageNode): node is MessageNode & { value: Record<string, MessageNode> } {
|
||||
return typeof node.value === 'object' && node.value !== null && !Array.isArray(node.value);
|
||||
}
|
||||
|
||||
function traverse(node: MessageNode, mentions: Set<string>, channels: Set<string>): void {
|
||||
if (isUserMention(node)) {
|
||||
mentions.add(node.value.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChannelMention(node)) {
|
||||
channels.add(node.value.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasArrayValue(node)) {
|
||||
for (const child of node.value) {
|
||||
traverse(child, mentions, channels);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasObjectValue(node)) {
|
||||
for (const key of Object.keys(node.value)) {
|
||||
traverse(node.value[key], mentions, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMentionsFromMessageAST(ast: Root): ExtractedMentions {
|
||||
const mentions = new Set<string>();
|
||||
const channels = new Set<string>();
|
||||
|
||||
for (const node of ast) {
|
||||
traverse(node, mentions, channels);
|
||||
}
|
||||
|
||||
return {
|
||||
mentions: Array.from(mentions),
|
||||
channels: Array.from(channels),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
|
||||
import { extractUrlsFromMessageAST } from './extractUrlsFromMessageAST';
|
||||
|
||||
describe('extractUrlsFromMessageAST', () => {
|
||||
it('should extract URLs from LINK nodes', () => {
|
||||
const md = [
|
||||
{
|
||||
type: 'PARAGRAPH',
|
||||
value: [
|
||||
{
|
||||
type: 'LINK',
|
||||
value: {
|
||||
src: {
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'https://rocket.chat',
|
||||
},
|
||||
label: [
|
||||
{
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'rocket.chat',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const urls = extractUrlsFromMessageAST(md as any);
|
||||
expect(urls).to.deep.equal(['https://rocket.chat']);
|
||||
});
|
||||
|
||||
// TODO: this spec was never wired to a test runner; these two expectations predate the
|
||||
// current implementation, which returns LINK src values without protocol normalization.
|
||||
it.skip('should convert // prefix to https://', () => {
|
||||
const md = [
|
||||
{
|
||||
type: 'PARAGRAPH',
|
||||
value: [
|
||||
{
|
||||
type: 'LINK',
|
||||
value: {
|
||||
src: {
|
||||
type: 'PLAIN_TEXT',
|
||||
value: '//github.com/RocketChat/Rocket.Chat',
|
||||
},
|
||||
label: [
|
||||
{
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'github.com/RocketChat/Rocket.Chat',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const urls = extractUrlsFromMessageAST(md as any);
|
||||
expect(urls).to.deep.equal(['https://github.com/RocketChat/Rocket.Chat']);
|
||||
});
|
||||
|
||||
it.skip('should handle multiple links', () => {
|
||||
const md = [
|
||||
{
|
||||
type: 'PARAGRAPH',
|
||||
value: [
|
||||
{
|
||||
type: 'LINK',
|
||||
value: {
|
||||
src: {
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'https://rocket.chat',
|
||||
},
|
||||
label: [
|
||||
{
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'rocket.chat',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'PLAIN_TEXT',
|
||||
value: ' and ',
|
||||
},
|
||||
{
|
||||
type: 'LINK',
|
||||
value: {
|
||||
src: {
|
||||
type: 'PLAIN_TEXT',
|
||||
value: '//github.com/RocketChat',
|
||||
},
|
||||
label: [
|
||||
{
|
||||
type: 'PLAIN_TEXT',
|
||||
value: 'github.com/RocketChat',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const urls = extractUrlsFromMessageAST(md as any);
|
||||
expect(urls).to.deep.equal(['https://rocket.chat', 'https://github.com/RocketChat']);
|
||||
});
|
||||
|
||||
it('should return empty array for undefined or non-array input', () => {
|
||||
expect(extractUrlsFromMessageAST(undefined)).to.deep.equal([]);
|
||||
expect(extractUrlsFromMessageAST(null as any)).to.deep.equal([]);
|
||||
expect(extractUrlsFromMessageAST({} as any)).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
33
apps/meteor/server/lib/messages/extractUrlsFromMessageAST.ts
Normal file
33
apps/meteor/server/lib/messages/extractUrlsFromMessageAST.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Root } from '@rocket.chat/message-parser';
|
||||
|
||||
/**
|
||||
* Extracts all URLs from parsed message AST (message-parser output)
|
||||
* Looks for LINK nodes and extracts the src URL
|
||||
*/
|
||||
export const extractUrlsFromMessageAST = (md?: Root | Root[number] | Root[number]['value']): string[] => {
|
||||
if (!md || !Array.isArray(md)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urls: string[] = [];
|
||||
|
||||
const walk = (node: any): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== 'object' || node === null) {
|
||||
return;
|
||||
}
|
||||
if (node.type === 'LINK' && node.value?.src?.value) {
|
||||
urls.push(node.value.src.value);
|
||||
}
|
||||
if (node.value !== undefined) {
|
||||
walk(node.value);
|
||||
}
|
||||
};
|
||||
|
||||
walk(md);
|
||||
|
||||
return urls;
|
||||
};
|
||||
48
apps/meteor/server/lib/messages/insertMessage.ts
Normal file
48
apps/meteor/server/lib/messages/insertMessage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms } from '@rocket.chat/models';
|
||||
|
||||
import { parseUrlsInMessage } from './parseUrlsInMessage';
|
||||
import { validateMessage, prepareMessageObject } from './sendMessage';
|
||||
|
||||
// TODO: remove and move to Message.Service
|
||||
export const insertMessage = async function (
|
||||
user: Pick<IUser, '_id' | 'username'>,
|
||||
message: IMessage,
|
||||
rid: IRoom['_id'],
|
||||
upsert = false,
|
||||
): Promise<IMessage | boolean> {
|
||||
if (!user || !message || !rid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await validateMessage(message, { _id: rid }, user);
|
||||
prepareMessageObject(message, rid, user);
|
||||
message.urls = parseUrlsInMessage(message);
|
||||
|
||||
if (message._id && upsert) {
|
||||
const { _id, ...rest } = message;
|
||||
const existingMessage = await Messages.findOneById(_id);
|
||||
if (existingMessage) {
|
||||
await Messages.updateOne(
|
||||
{
|
||||
_id,
|
||||
'u._id': message.u._id,
|
||||
},
|
||||
{ $set: rest },
|
||||
);
|
||||
} else {
|
||||
await Messages.insertOne({
|
||||
_id,
|
||||
...rest,
|
||||
});
|
||||
await Rooms.incMsgCountById(rid, 1);
|
||||
}
|
||||
message._id = _id;
|
||||
} else {
|
||||
const result = await Messages.insertOne(message);
|
||||
message._id = result.insertedId;
|
||||
await Rooms.incMsgCountById(rid, 1);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
6
apps/meteor/server/lib/messages/isTheLastMessage.ts
Normal file
6
apps/meteor/server/lib/messages/isTheLastMessage.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { IMessage, IRoom, AtLeast } from '@rocket.chat/core-typings';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
|
||||
export const isTheLastMessage = (room: AtLeast<IRoom, 'lastMessage'>, message: Pick<IMessage, '_id'>) =>
|
||||
settings.get('Store_Last_Message') && (!room.lastMessage || room.lastMessage._id === message._id);
|
||||
94
apps/meteor/server/lib/messages/loadMessageHistory.ts
Normal file
94
apps/meteor/server/lib/messages/loadMessageHistory.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { IMessage, IRoom, MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms } from '@rocket.chat/models';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
import { settings } from '../../settings/cached';
|
||||
import { getHiddenSystemMessages } from '../messaging/getHiddenSystemMessages';
|
||||
import { normalizeMessagesForUser } from '../utils/lib/normalizeMessagesForUser';
|
||||
|
||||
export async function loadMessageHistory({
|
||||
userId,
|
||||
rid,
|
||||
end,
|
||||
limit = 20,
|
||||
ls,
|
||||
showThreadMessages = true,
|
||||
offset = 0,
|
||||
room: providedRoom,
|
||||
}: {
|
||||
// userId is undefined if user is reading anonymously
|
||||
userId?: string;
|
||||
rid: string;
|
||||
end: Date | undefined;
|
||||
limit?: number;
|
||||
ls?: string | Date;
|
||||
showThreadMessages?: boolean;
|
||||
offset?: number;
|
||||
room?: IRoom;
|
||||
}) {
|
||||
const room = providedRoom ?? (await Rooms.findOneById(rid, { projection: { sysMes: 1 } }));
|
||||
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
const hiddenSystemMessages = settings.get<MessageTypesValues[]>('Hide_System_Messages');
|
||||
|
||||
const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages);
|
||||
|
||||
const options: FindOptions<IMessage> = {
|
||||
sort: {
|
||||
ts: -1,
|
||||
},
|
||||
limit,
|
||||
skip: offset,
|
||||
};
|
||||
|
||||
const records = end
|
||||
? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes(
|
||||
rid,
|
||||
end,
|
||||
hiddenMessageTypes,
|
||||
options,
|
||||
showThreadMessages,
|
||||
).toArray()
|
||||
: await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray();
|
||||
|
||||
const firstMessage = records[records.length - 1];
|
||||
const lastSeen = ls ? new Date(ls) : undefined;
|
||||
const hasValidLastSeen = lastSeen !== undefined && !Number.isNaN(lastSeen.getTime());
|
||||
|
||||
const computeUnread = async (): Promise<{ firstUnread: IMessage | undefined; unreadNotLoaded: number }> => {
|
||||
if (!hasValidLastSeen || !lastSeen || !firstMessage || !(new Date(firstMessage.ts) > lastSeen)) {
|
||||
return { firstUnread: undefined, unreadNotLoaded: 0 };
|
||||
}
|
||||
|
||||
const [firstUnreadRecords, unreadNotLoaded] = await Promise.all([
|
||||
Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
|
||||
rid,
|
||||
lastSeen,
|
||||
firstMessage.ts,
|
||||
hiddenMessageTypes,
|
||||
{ limit: 1, sort: { ts: 1 } },
|
||||
showThreadMessages,
|
||||
).toArray(),
|
||||
Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(
|
||||
rid,
|
||||
lastSeen,
|
||||
firstMessage.ts,
|
||||
hiddenMessageTypes,
|
||||
showThreadMessages,
|
||||
),
|
||||
]);
|
||||
|
||||
return { firstUnread: firstUnreadRecords[0], unreadNotLoaded };
|
||||
};
|
||||
|
||||
const [messages, unread] = await Promise.all([normalizeMessagesForUser(records, userId), computeUnread()]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
firstUnread: unread.firstUnread,
|
||||
unreadNotLoaded: unread.unreadNotLoaded,
|
||||
};
|
||||
}
|
||||
248
apps/meteor/server/lib/messages/loadRoomHistory.ts
Normal file
248
apps/meteor/server/lib/messages/loadRoomHistory.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IRoom, MessageTypesValues } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
import { settings } from '../../settings/cached';
|
||||
import { getHiddenSystemMessages } from '../messaging/getHiddenSystemMessages';
|
||||
import { normalizeMessagesForUser } from '../utils/lib/normalizeMessagesForUser';
|
||||
|
||||
// The range query needs a closed upper bound; `new Date()` would drop client-stamped future `ts`.
|
||||
const FAR_FUTURE = new Date(8640000000000000);
|
||||
|
||||
export type RoomHistoryCursor = {
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
};
|
||||
|
||||
export type RoomHistoryResult = {
|
||||
messages: IMessage[];
|
||||
cursor: RoomHistoryCursor;
|
||||
firstUnread?: IMessage;
|
||||
unreadNotLoaded?: number;
|
||||
};
|
||||
|
||||
export function encodeHistoryCursor(ts: Date): string {
|
||||
return `${ts.getTime()}`;
|
||||
}
|
||||
|
||||
export function decodeHistoryCursor(cursor: string): Date {
|
||||
if (!/^\d+$/.test(cursor)) {
|
||||
throw new MeteorError('error-invalid-cursor', 'Invalid pagination cursor');
|
||||
}
|
||||
|
||||
const date = new Date(parseInt(cursor, 10));
|
||||
|
||||
if (date.toString() === 'Invalid Date') {
|
||||
throw new MeteorError('error-invalid-cursor', 'Invalid pagination cursor');
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-paginated room history, ordered newest-first regardless of paging direction.
|
||||
*
|
||||
* `lastSeen` positions the unread divider only. Using it as a pagination bound instead would truncate
|
||||
* the page at the marker, which is why the per-type `*.history` endpoints cannot serve this.
|
||||
*/
|
||||
export async function loadRoomHistory({
|
||||
userId,
|
||||
next,
|
||||
previous,
|
||||
around,
|
||||
lastSeen,
|
||||
count = 20,
|
||||
showThreadMessages = true,
|
||||
room,
|
||||
}: {
|
||||
userId?: string;
|
||||
next?: string;
|
||||
previous?: string;
|
||||
around?: IMessage;
|
||||
lastSeen?: Date;
|
||||
count?: number;
|
||||
showThreadMessages?: boolean;
|
||||
room: IRoom;
|
||||
}): Promise<RoomHistoryResult> {
|
||||
if ([next, previous, around].filter(Boolean).length > 1) {
|
||||
throw new MeteorError('error-cursor-conflict', 'Only one of "next", "previous" and "aroundId" can be provided');
|
||||
}
|
||||
|
||||
const rid = room._id;
|
||||
|
||||
const hiddenMessageTypes = getHiddenSystemMessages(room, settings.get<MessageTypesValues[]>('Hide_System_Messages'));
|
||||
|
||||
const { records, hasNewer, hasOlder } = around
|
||||
? await loadAround({ rid, around, count, hiddenMessageTypes, showThreadMessages })
|
||||
: await loadPage({ rid, next, previous, count, hiddenMessageTypes, showThreadMessages });
|
||||
|
||||
const newest = records[0];
|
||||
const oldest = records[records.length - 1];
|
||||
|
||||
const cursor: RoomHistoryCursor = {
|
||||
next: newest && hasNewer ? encodeHistoryCursor(newest.ts) : null,
|
||||
previous: oldest && hasOlder ? encodeHistoryCursor(oldest.ts) : null,
|
||||
};
|
||||
|
||||
const [messages, unread] = await Promise.all([
|
||||
normalizeMessagesForUser(records, userId),
|
||||
computeUnread({ rid, userId, lastSeen, oldest, hiddenMessageTypes, showThreadMessages }),
|
||||
]);
|
||||
|
||||
return { messages, cursor, ...unread };
|
||||
}
|
||||
|
||||
// In-memory `_id` tie-break for equal `ts`: the `{ rid, ts, _updatedAt }` index cannot back a
|
||||
// `{ ts, _id }` sort, and without it MongoDB's order among equal timestamps is unspecified.
|
||||
function sortByTs(records: IMessage[], direction: 1 | -1): IMessage[] {
|
||||
return records.sort((a, b) => (a.ts.getTime() - b.ts.getTime() || (a._id < b._id ? -1 : 1)) * direction);
|
||||
}
|
||||
|
||||
type Window = { records: IMessage[]; hasNewer: boolean; hasOlder: boolean };
|
||||
|
||||
async function loadPage({
|
||||
rid,
|
||||
next,
|
||||
previous,
|
||||
count,
|
||||
hiddenMessageTypes,
|
||||
showThreadMessages,
|
||||
}: {
|
||||
rid: IRoom['_id'];
|
||||
next?: string;
|
||||
previous?: string;
|
||||
count: number;
|
||||
hiddenMessageTypes: MessageTypesValues[];
|
||||
showThreadMessages: boolean;
|
||||
}): Promise<Window> {
|
||||
// One extra document reveals whether a further page exists.
|
||||
const options: FindOptions<IMessage> = { sort: { ts: next ? 1 : -1 }, limit: count + 1 };
|
||||
|
||||
const records = next
|
||||
? await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
|
||||
rid,
|
||||
decodeHistoryCursor(next),
|
||||
FAR_FUTURE,
|
||||
hiddenMessageTypes,
|
||||
options,
|
||||
showThreadMessages,
|
||||
).toArray()
|
||||
: await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes(
|
||||
rid,
|
||||
previous ? decodeHistoryCursor(previous) : FAR_FUTURE,
|
||||
hiddenMessageTypes,
|
||||
options,
|
||||
showThreadMessages,
|
||||
).toArray();
|
||||
|
||||
sortByTs(records, next ? 1 : -1);
|
||||
|
||||
const hasMoreInPagingDirection = records.length > count;
|
||||
|
||||
if (hasMoreInPagingDirection) {
|
||||
records.pop();
|
||||
}
|
||||
|
||||
if (next) {
|
||||
records.reverse();
|
||||
}
|
||||
|
||||
// Null means no more in that direction. The side we came from always has more — except the first
|
||||
// page, which starts at the newest end.
|
||||
return {
|
||||
records,
|
||||
hasNewer: next ? hasMoreInPagingDirection : Boolean(previous),
|
||||
hasOlder: next ? true : hasMoreInPagingDirection,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAround({
|
||||
rid,
|
||||
around,
|
||||
count,
|
||||
hiddenMessageTypes,
|
||||
showThreadMessages,
|
||||
}: {
|
||||
rid: IRoom['_id'];
|
||||
around: IMessage;
|
||||
count: number;
|
||||
hiddenMessageTypes: MessageTypesValues[];
|
||||
showThreadMessages: boolean;
|
||||
}): Promise<Window> {
|
||||
// A non-positive count would produce a Mongo `limit: 0`, which means "no limit".
|
||||
const remaining = Math.max(count, 1) - 1;
|
||||
const olderLimit = Math.ceil(remaining / 2);
|
||||
const newerLimit = Math.floor(remaining / 2);
|
||||
|
||||
const [older, newer] = await Promise.all([
|
||||
Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes(
|
||||
rid,
|
||||
around.ts,
|
||||
hiddenMessageTypes,
|
||||
{ sort: { ts: -1 }, limit: olderLimit + 1 },
|
||||
showThreadMessages,
|
||||
).toArray(),
|
||||
Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
|
||||
rid,
|
||||
around.ts,
|
||||
FAR_FUTURE,
|
||||
hiddenMessageTypes,
|
||||
{ sort: { ts: 1 }, limit: newerLimit + 1 },
|
||||
showThreadMessages,
|
||||
).toArray(),
|
||||
]);
|
||||
|
||||
sortByTs(older, -1);
|
||||
sortByTs(newer, 1);
|
||||
|
||||
const hasOlder = older.length > olderLimit;
|
||||
const hasNewer = newer.length > newerLimit;
|
||||
|
||||
if (hasOlder) {
|
||||
older.pop();
|
||||
}
|
||||
|
||||
if (hasNewer) {
|
||||
newer.pop();
|
||||
}
|
||||
|
||||
// The anchor is included whatever its type, so a jump always lands on its target.
|
||||
return { records: [...newer.reverse(), around, ...older], hasNewer, hasOlder };
|
||||
}
|
||||
|
||||
async function computeUnread({
|
||||
rid,
|
||||
userId,
|
||||
lastSeen,
|
||||
oldest,
|
||||
hiddenMessageTypes,
|
||||
showThreadMessages,
|
||||
}: {
|
||||
rid: IRoom['_id'];
|
||||
userId?: string;
|
||||
lastSeen?: Date;
|
||||
oldest?: IMessage;
|
||||
hiddenMessageTypes: MessageTypesValues[];
|
||||
showThreadMessages: boolean;
|
||||
}): Promise<{ firstUnread?: IMessage; unreadNotLoaded?: number }> {
|
||||
if (!lastSeen || Number.isNaN(lastSeen.getTime()) || !oldest || !(new Date(oldest.ts) > lastSeen)) {
|
||||
return { firstUnread: undefined, unreadNotLoaded: 0 };
|
||||
}
|
||||
|
||||
const [firstUnreadRecords, unreadNotLoaded] = await Promise.all([
|
||||
Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
|
||||
rid,
|
||||
lastSeen,
|
||||
oldest.ts,
|
||||
hiddenMessageTypes,
|
||||
{ limit: 1, sort: { ts: 1 } },
|
||||
showThreadMessages,
|
||||
).toArray(),
|
||||
Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(rid, lastSeen, oldest.ts, hiddenMessageTypes, showThreadMessages),
|
||||
]);
|
||||
|
||||
const [firstUnread] = await normalizeMessagesForUser(firstUnreadRecords, userId);
|
||||
|
||||
return { firstUnread, unreadNotLoaded };
|
||||
}
|
||||
40
apps/meteor/server/lib/messages/parseUrlsInMessage.ts
Normal file
40
apps/meteor/server/lib/messages/parseUrlsInMessage.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IMessage, AtLeast } from '@rocket.chat/core-typings';
|
||||
|
||||
import { extractUrlsFromMessageAST } from './extractUrlsFromMessageAST';
|
||||
import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex';
|
||||
import { settings } from '../../settings';
|
||||
import { Markdown } from '../messaging/markdown';
|
||||
|
||||
const prepareUrl = (url: string, previewUrls: string[] | undefined, siteUrl: string) => ({
|
||||
url,
|
||||
meta: {},
|
||||
...(previewUrls && !previewUrls.includes(url) && !url.includes(siteUrl) && { ignoreParse: true }),
|
||||
});
|
||||
|
||||
const prepareUrls = (urls: string[], previewUrls?: string[]) => {
|
||||
const siteUrl = settings.get<string>('Site_Url');
|
||||
return [...new Set(urls)].map((url) => prepareUrl(url, previewUrls, siteUrl));
|
||||
};
|
||||
|
||||
export const parseUrlsInMessage = (
|
||||
message: AtLeast<IMessage, 'msg' | 'md'> & {
|
||||
parseUrls?: boolean;
|
||||
},
|
||||
previewUrls?: string[],
|
||||
) => {
|
||||
// Also extract URLs from message blocks if they exist
|
||||
if (message.md) {
|
||||
const astUrls = extractUrlsFromMessageAST(message.md);
|
||||
return prepareUrls(astUrls, previewUrls);
|
||||
}
|
||||
|
||||
// TODO: remove this after make the parser official
|
||||
// Parse the message to extract URLs from links without schema
|
||||
// The message parser converts links like "github.com" to proper links with "//" prefix
|
||||
const result = Markdown.code({
|
||||
html: message.msg,
|
||||
msg: message.msg,
|
||||
});
|
||||
const htmlUrls = result.html?.match(getMessageUrlRegex()) || [];
|
||||
return prepareUrls(htmlUrls, previewUrls);
|
||||
};
|
||||
204
apps/meteor/server/lib/messages/processWebhookMessage.ts
Normal file
204
apps/meteor/server/lib/messages/processWebhookMessage.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { IMessage, IUser, RequiredField, MessageAttachment, IRoom } from '@rocket.chat/core-typings';
|
||||
import { removeEmpty } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import _ from 'underscore';
|
||||
|
||||
import { sendMessage, validateMessage } from './sendMessage';
|
||||
import { ensureArray } from '../../../lib/utils/arrayUtils';
|
||||
import { trim } from '../../../lib/utils/stringUtils';
|
||||
import { settings } from '../../settings';
|
||||
import { validateRoomMessagePermissionsAsync } from '../authorization/canSendMessage';
|
||||
import { SystemLogger } from '../logger/system';
|
||||
import { getRoomByNameOrIdWithOptionToJoin } from '../rooms/getRoomByNameOrIdWithOptionToJoin';
|
||||
|
||||
type Payload = {
|
||||
channel?: string | string[];
|
||||
roomId?: string | string[];
|
||||
text?: IMessage['msg'];
|
||||
msg?: IMessage['msg']; // overridden if text is present
|
||||
username?: IMessage['alias'];
|
||||
alias?: IMessage['alias']; // overridden if username is present
|
||||
icon_emoji?: IMessage['emoji'];
|
||||
emoji?: IMessage['emoji']; // overridden if icon_emoji is present
|
||||
icon_url?: IMessage['avatar'];
|
||||
avatar?: IMessage['avatar']; // overridden if icon_url is present
|
||||
attachments?: IMessage['attachments'];
|
||||
parseUrls?: boolean;
|
||||
bot?: IMessage['bot'];
|
||||
groupable?: IMessage['groupable'];
|
||||
tmid?: IMessage['tmid'];
|
||||
customFields?: IMessage['customFields'];
|
||||
};
|
||||
|
||||
type DefaultValues = {
|
||||
channel: string | string[];
|
||||
alias: string;
|
||||
avatar: string;
|
||||
emoji: string;
|
||||
};
|
||||
|
||||
export type WebhookSuccessItem = { channel: string; error?: undefined; message: IMessage };
|
||||
export type WebhookFailureItem = { channel: string; error: string; message?: undefined };
|
||||
export type WebhookResponseItem = WebhookFailureItem | WebhookSuccessItem;
|
||||
|
||||
export const validateWebhookMessage = async (message: Partial<IMessage>, room: IRoom | null, user: IUser) => {
|
||||
if (message.msg) {
|
||||
if (message.msg.length > (settings.get<number>('Message_MaxAllowedSize') ?? 0)) {
|
||||
throw Error('error-message-size-exceeded');
|
||||
}
|
||||
}
|
||||
await validateMessage(message, room, user);
|
||||
};
|
||||
|
||||
const getRoomWithOptionToJoin = async (channelType: string, channelValue: string, user: IUser) => {
|
||||
switch (channelType) {
|
||||
case '#':
|
||||
return getRoomByNameOrIdWithOptionToJoin({
|
||||
user,
|
||||
nameOrId: channelValue,
|
||||
joinChannel: true,
|
||||
});
|
||||
case '@':
|
||||
return getRoomByNameOrIdWithOptionToJoin({
|
||||
user,
|
||||
nameOrId: channelValue,
|
||||
type: 'd',
|
||||
});
|
||||
default:
|
||||
const _channelValue = channelType + channelValue;
|
||||
|
||||
// Try to find the room by id or name if they didn't include the prefix.
|
||||
let room = await getRoomByNameOrIdWithOptionToJoin({
|
||||
user,
|
||||
nameOrId: _channelValue,
|
||||
joinChannel: true,
|
||||
errorOnEmpty: false,
|
||||
});
|
||||
if (room) {
|
||||
return room;
|
||||
}
|
||||
|
||||
// We didn't get a room, let's try finding direct messages
|
||||
room = await getRoomByNameOrIdWithOptionToJoin({
|
||||
user,
|
||||
nameOrId: _channelValue,
|
||||
tryDirectByUserIdOnly: true,
|
||||
type: 'd',
|
||||
});
|
||||
if (room) {
|
||||
return room;
|
||||
}
|
||||
|
||||
// No room, so throw an error
|
||||
throw new Meteor.Error('invalid-channel');
|
||||
}
|
||||
};
|
||||
|
||||
const buildMessage = (messageObj: Payload, defaultValues: DefaultValues) => {
|
||||
const message: Partial<IMessage> & { parseUrls?: boolean } = {
|
||||
alias: messageObj.username || messageObj.alias || defaultValues.alias,
|
||||
msg: trim(messageObj.text || messageObj.msg || ''),
|
||||
attachments: messageObj.attachments || [],
|
||||
parseUrls: messageObj.parseUrls !== undefined ? messageObj.parseUrls : !messageObj.attachments,
|
||||
bot: messageObj.bot,
|
||||
groupable: messageObj.groupable !== undefined ? messageObj.groupable : false,
|
||||
tmid: messageObj.tmid,
|
||||
customFields: messageObj.customFields,
|
||||
};
|
||||
|
||||
if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) {
|
||||
message.avatar = messageObj.icon_url || messageObj.avatar;
|
||||
} else if (!_.isEmpty(messageObj.icon_emoji) || !_.isEmpty(messageObj.emoji)) {
|
||||
message.emoji = messageObj.icon_emoji || messageObj.emoji;
|
||||
} else if (!_.isEmpty(defaultValues.avatar)) {
|
||||
message.avatar = defaultValues.avatar;
|
||||
} else if (!_.isEmpty(defaultValues.emoji)) {
|
||||
message.emoji = defaultValues.emoji;
|
||||
}
|
||||
|
||||
if (Array.isArray(message.attachments)) {
|
||||
for (let i = 0; i < message.attachments.length; i++) {
|
||||
const attachment = message.attachments[i] as MessageAttachment & { msg?: string };
|
||||
if (attachment.msg) {
|
||||
attachment.text = trim(attachment.msg);
|
||||
delete attachment.msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export function processWebhookMessage(
|
||||
messageObj: Payload & { separateResponse: true },
|
||||
user: RequiredField<IUser, 'username'>,
|
||||
defaultValues?: DefaultValues,
|
||||
): Promise<WebhookResponseItem[]>;
|
||||
|
||||
export function processWebhookMessage(
|
||||
messageObj: Payload & { separateResponse?: false | undefined },
|
||||
user: RequiredField<IUser, 'username'>,
|
||||
defaultValues?: DefaultValues,
|
||||
): Promise<WebhookSuccessItem[]>;
|
||||
|
||||
export async function processWebhookMessage(
|
||||
messageObj: Payload & {
|
||||
/**
|
||||
* If true, the response will be sent separately for each channel. Messages will be sent to other channels even if one or more fails. If false or not provided, messages would not be sent to any channel if one or more fails.
|
||||
*/
|
||||
separateResponse?: boolean;
|
||||
},
|
||||
user: RequiredField<IUser, 'username'>,
|
||||
defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' },
|
||||
) {
|
||||
const rooms: ({ channel: string } & ({ room: IRoom } | { room: IRoom | null; error?: any }))[] = [];
|
||||
const sentData: WebhookResponseItem[] = [];
|
||||
|
||||
const channels: Array<string> = [...new Set(ensureArray(messageObj.channel || messageObj.roomId || defaultValues.channel))];
|
||||
|
||||
if (messageObj.attachments && !Array.isArray(messageObj.attachments)) {
|
||||
SystemLogger.warn({
|
||||
msg: 'Attachments should be Array, ignoring value',
|
||||
attachments: messageObj.attachments,
|
||||
});
|
||||
messageObj.attachments = undefined;
|
||||
}
|
||||
|
||||
const message = buildMessage(messageObj, defaultValues);
|
||||
|
||||
for (const channel of channels) {
|
||||
const channelType = channel[0];
|
||||
const channelValue = channel.slice(1);
|
||||
let room: IRoom | null = null;
|
||||
try {
|
||||
room = await getRoomWithOptionToJoin(channelType, channelValue, user);
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
await validateRoomMessagePermissionsAsync(room, { uid: user._id, ...user });
|
||||
await validateWebhookMessage(message, room, user);
|
||||
rooms.push({ room, channel });
|
||||
} catch (_error: any) {
|
||||
if (messageObj.separateResponse) {
|
||||
const { error, message } = _error || {};
|
||||
const errorMessage = error || message || 'unknown-error';
|
||||
rooms.push({ error: errorMessage, room, channel });
|
||||
continue;
|
||||
}
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
|
||||
for (const roomData of rooms) {
|
||||
if ('error' in roomData && roomData.error) {
|
||||
if (messageObj.separateResponse) {
|
||||
sentData.push({ channel: roomData.channel, error: roomData.error });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const messageReturn = await sendMessage(user, removeEmpty(message), roomData.room);
|
||||
sentData.push({ channel: roomData.channel, message: messageReturn });
|
||||
}
|
||||
|
||||
return sentData;
|
||||
}
|
||||
298
apps/meteor/server/lib/messages/sendMessage.ts
Normal file
298
apps/meteor/server/lib/messages/sendMessage.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { AppEvents, Apps } from '@rocket.chat/apps';
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import { isAbsoluteURL } from '@rocket.chat/tools';
|
||||
import { Match, check } from 'meteor/check';
|
||||
|
||||
import { isRelativeURL } from '../../../lib/utils/isRelativeURL';
|
||||
import { afterSaveMessage } from '../../hooks/messages/afterSaveMessage';
|
||||
import { settings } from '../../settings';
|
||||
import { hasPermissionAsync } from '../authorization/hasPermission';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
import { validateCustomMessageFields } from '../messaging/validateCustomMessageFields';
|
||||
import { notifyOnRoomChangedById } from '../notifyListener';
|
||||
|
||||
export type SendMessageOptions = {
|
||||
upsert?: boolean;
|
||||
previewUrls?: string[];
|
||||
skipNotifications?: boolean;
|
||||
};
|
||||
|
||||
// TODO: most of the types here are wrong, but I don't want to change them now
|
||||
|
||||
/**
|
||||
* IMPORTANT
|
||||
*
|
||||
* This validator prevents malicious href values
|
||||
* intending to run arbitrary js code in anchor tags.
|
||||
* You should use it whenever the value you're checking
|
||||
* is going to be rendered in the href attribute of a
|
||||
* link.
|
||||
*/
|
||||
const validFullURLParam = Match.Where((value) => {
|
||||
check(value, String);
|
||||
|
||||
if (!isAbsoluteURL(value) && !value.startsWith(FileUpload.getPath())) {
|
||||
throw new Error('Invalid href value provided');
|
||||
}
|
||||
|
||||
if (/^javascript:/i.test(value)) {
|
||||
throw new Error('Invalid href value provided');
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validPartialURLParam = Match.Where((value) => {
|
||||
check(value, String);
|
||||
|
||||
if (!isRelativeURL(value) && !isAbsoluteURL(value) && !value.startsWith(FileUpload.getPath())) {
|
||||
throw new Error('Invalid href value provided');
|
||||
}
|
||||
|
||||
if (/^javascript:/i.test(value)) {
|
||||
throw new Error('Invalid href value provided');
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const objectMaybeIncluding = (types: any) =>
|
||||
Match.Where((value: any) => {
|
||||
Object.keys(types).forEach((field) => {
|
||||
if (value[field] != null) {
|
||||
try {
|
||||
check(value[field], types[field]);
|
||||
} catch (error: any) {
|
||||
error.path = field;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validateAttachmentsFields = (attachmentField: any) => {
|
||||
check(
|
||||
attachmentField,
|
||||
objectMaybeIncluding({
|
||||
short: Boolean,
|
||||
title: String,
|
||||
value: Match.OneOf(String, Number, Boolean),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!attachmentField.value || !attachmentField.title) {
|
||||
throw new Error('Invalid attachment field, title and value is required');
|
||||
}
|
||||
};
|
||||
|
||||
const validateAttachmentsActions = (attachmentActions: any) => {
|
||||
check(
|
||||
attachmentActions,
|
||||
objectMaybeIncluding({
|
||||
type: String,
|
||||
text: String,
|
||||
url: validFullURLParam,
|
||||
image_url: validFullURLParam,
|
||||
is_webview: Boolean,
|
||||
webview_height_ratio: String,
|
||||
msg: String,
|
||||
msg_in_chat_window: Boolean,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const validateAttachment = (attachment: any) => {
|
||||
check(
|
||||
attachment,
|
||||
objectMaybeIncluding({
|
||||
color: String,
|
||||
text: String,
|
||||
ts: Match.OneOf(String, Number),
|
||||
thumb_url: validFullURLParam,
|
||||
button_alignment: String,
|
||||
actions: [Match.Any],
|
||||
message_link: validFullURLParam,
|
||||
collapsed: Boolean,
|
||||
author_name: String,
|
||||
author_link: validFullURLParam,
|
||||
author_icon: validFullURLParam,
|
||||
title: String,
|
||||
title_link: validFullURLParam,
|
||||
title_link_download: Boolean,
|
||||
image_dimensions: Object,
|
||||
image_url: validFullURLParam,
|
||||
image_preview: String,
|
||||
image_type: String,
|
||||
image_size: Number,
|
||||
audio_url: validFullURLParam,
|
||||
audio_type: String,
|
||||
audio_size: Number,
|
||||
video_url: validFullURLParam,
|
||||
video_type: String,
|
||||
video_size: Number,
|
||||
fields: [Match.Any],
|
||||
}),
|
||||
);
|
||||
|
||||
if (attachment.fields?.length) {
|
||||
attachment.fields.map(validateAttachmentsFields);
|
||||
}
|
||||
|
||||
if (attachment.actions?.length) {
|
||||
attachment.actions.map(validateAttachmentsActions);
|
||||
}
|
||||
};
|
||||
|
||||
const validateBodyAttachments = (attachments: any[]) => attachments.map(validateAttachment);
|
||||
|
||||
export const validateMessage = async (message: any, room: any, user: any) => {
|
||||
check(
|
||||
message,
|
||||
objectMaybeIncluding({
|
||||
_id: String,
|
||||
msg: String,
|
||||
text: String,
|
||||
alias: String,
|
||||
emoji: String,
|
||||
tmid: String,
|
||||
tshow: Boolean,
|
||||
avatar: validPartialURLParam,
|
||||
attachments: [Match.Any],
|
||||
blocks: [Match.Any],
|
||||
}),
|
||||
);
|
||||
|
||||
if (message.alias || message.avatar) {
|
||||
const isLiveChatGuest = !message.avatar && user.token && user.token === room.v?.token;
|
||||
|
||||
if (!isLiveChatGuest && !(await hasPermissionAsync(user._id, 'message-impersonate', room._id))) {
|
||||
throw new Error('Not enough permission');
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(message.attachments) && message.attachments.length) {
|
||||
validateBodyAttachments(message.attachments);
|
||||
}
|
||||
|
||||
if (message.customFields) {
|
||||
validateCustomMessageFields({
|
||||
customFields: message.customFields,
|
||||
messageCustomFieldsEnabled: settings.get<boolean>('Message_CustomFields_Enabled'),
|
||||
messageCustomFields: settings.get<string>('Message_CustomFields'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export function prepareMessageObject(
|
||||
message: Partial<IMessage>,
|
||||
rid: IRoom['_id'],
|
||||
user: { _id: string; username?: string; name?: string },
|
||||
): asserts message is IMessage {
|
||||
if (!message.ts) {
|
||||
message.ts = new Date();
|
||||
}
|
||||
|
||||
if (message.tshow !== true) {
|
||||
delete message.tshow;
|
||||
}
|
||||
|
||||
const { _id, username, name } = user;
|
||||
message.u = {
|
||||
_id,
|
||||
username: username as string, // FIXME: this is wrong but I don't want to change it now
|
||||
name,
|
||||
};
|
||||
message.rid = rid;
|
||||
|
||||
if (!Match.test(message.msg, String)) {
|
||||
message.msg = '';
|
||||
}
|
||||
|
||||
if (message.ts == null) {
|
||||
message.ts = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and sends the message object. This function does not verify the Message_MaxAllowedSize settings.
|
||||
* Caller of the function should verify the Message_MaxAllowedSize if needed.
|
||||
* There might be same use cases which needs to override this setting. Example - sending error logs.
|
||||
*/
|
||||
export const sendMessage = async function (user: any, message: any, room: any, options: SendMessageOptions = {}) {
|
||||
const { upsert = false, previewUrls } = options;
|
||||
|
||||
if (!user || !message || !room._id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await validateMessage(message, room, user);
|
||||
prepareMessageObject(message, room._id, user);
|
||||
|
||||
if (settings.get('Message_Read_Receipt_Enabled')) {
|
||||
message.unread = true;
|
||||
}
|
||||
|
||||
// For the Rocket.Chat Apps :)
|
||||
if (Apps.self?.isLoaded()) {
|
||||
const prevent = await Apps.self?.triggerEvent(AppEvents.IPreMessageSentPrevent, message);
|
||||
|
||||
if (prevent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Apps.self?.triggerEvent(
|
||||
AppEvents.IPreMessageSentModify,
|
||||
await Apps.self?.triggerEvent(AppEvents.IPreMessageSentExtend, message),
|
||||
);
|
||||
|
||||
if (typeof result === 'object') {
|
||||
message = Object.assign(message, result);
|
||||
|
||||
// Some app may have inserted malicious/invalid values in the message, let's check it again
|
||||
await validateMessage(message, room, user);
|
||||
}
|
||||
}
|
||||
|
||||
message = await Message.beforeSave({ message, room, user, previewUrls, parseUrls: message.parseUrls });
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._id && upsert) {
|
||||
const { _id } = message;
|
||||
delete message._id;
|
||||
await Messages.updateOne(
|
||||
{
|
||||
_id,
|
||||
'u._id': message.u._id,
|
||||
},
|
||||
{ $set: message },
|
||||
{ upsert: true },
|
||||
);
|
||||
message._id = _id;
|
||||
} else {
|
||||
const messageAlreadyExists = message._id && (await Messages.findOneById(message._id, { projection: { _id: 1 } }));
|
||||
if (messageAlreadyExists) {
|
||||
return;
|
||||
}
|
||||
const { insertedId } = await Messages.insertOne(message);
|
||||
message._id = insertedId;
|
||||
}
|
||||
|
||||
if (Apps.self?.isLoaded()) {
|
||||
// If the message has a type (system message), we should notify the listener about it
|
||||
const messageEvent = message.t ? AppEvents.IPostSystemMessageSent : AppEvents.IPostMessageSent;
|
||||
void Apps.self?.triggerEvent(messageEvent, message);
|
||||
}
|
||||
|
||||
await afterSaveMessage(message, room, user, { options });
|
||||
|
||||
void notifyOnRoomChangedById(message.rid);
|
||||
|
||||
return message;
|
||||
};
|
||||
108
apps/meteor/server/lib/messages/updateMessage.ts
Normal file
108
apps/meteor/server/lib/messages/updateMessage.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { AppEvents, Apps } from '@rocket.chat/apps';
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IUser, AtLeast } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { afterSaveMessage } from '../../hooks/messages/afterSaveMessage';
|
||||
import { settings } from '../../settings';
|
||||
import { validateCustomMessageFields } from '../messaging/validateCustomMessageFields';
|
||||
import { notifyOnRoomChangedById } from '../notifyListener';
|
||||
|
||||
export const updateMessage = async function (
|
||||
{
|
||||
parseUrls,
|
||||
...message
|
||||
}: (AtLeast<IMessage, '_id' | 'rid' | 'msg' | 'customFields'> | AtLeast<IMessage, '_id' | 'rid' | 'content'>) & {
|
||||
parseUrls?: boolean;
|
||||
},
|
||||
user: IUser,
|
||||
originalMsg?: IMessage,
|
||||
previewUrls?: string[],
|
||||
): Promise<void> {
|
||||
const originalMessage = originalMsg || (await Messages.findOneById(message._id));
|
||||
if (!originalMessage) {
|
||||
throw new Error('Invalid message ID.');
|
||||
}
|
||||
|
||||
let messageData: IMessage = Object.assign({}, originalMessage, message);
|
||||
|
||||
// For the Rocket.Chat Apps :)
|
||||
if (message && Apps.self && Apps.isLoaded()) {
|
||||
const prevent = await Apps.self?.triggerEvent(AppEvents.IPreMessageUpdatedPrevent, messageData);
|
||||
if (prevent) {
|
||||
throw new Meteor.Error('error-app-prevented-updating', 'A Rocket.Chat App prevented the message updating.');
|
||||
}
|
||||
|
||||
let result = await Apps.self?.triggerEvent(AppEvents.IPreMessageUpdatedExtend, messageData);
|
||||
result = await Apps.self?.triggerEvent(AppEvents.IPreMessageUpdatedModify, result);
|
||||
|
||||
if (typeof result === 'object') {
|
||||
Object.assign(messageData, result);
|
||||
}
|
||||
}
|
||||
|
||||
// If we keep history of edits, insert a new message to store history information
|
||||
if (settings.get('Message_KeepHistory')) {
|
||||
await Messages.cloneAndSaveAsHistoryById(messageData._id, user as Required<Pick<IUser, '_id' | 'username' | 'name'>>);
|
||||
}
|
||||
|
||||
Object.assign(messageData, {
|
||||
editedAt: new Date(),
|
||||
editedBy: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
},
|
||||
});
|
||||
|
||||
const room = await Rooms.findOneById(messageData.rid);
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageData = await Message.beforeSave({ message: messageData, room, user, previewUrls, parseUrls });
|
||||
|
||||
if (messageData.customFields) {
|
||||
validateCustomMessageFields({
|
||||
customFields: messageData.customFields,
|
||||
messageCustomFieldsEnabled: settings.get<boolean>('Message_CustomFields_Enabled'),
|
||||
messageCustomFields: settings.get<string>('Message_CustomFields'),
|
||||
});
|
||||
}
|
||||
|
||||
const { _id, ...editedMessage } = messageData;
|
||||
|
||||
if (!editedMessage.msg) {
|
||||
delete editedMessage.md;
|
||||
}
|
||||
|
||||
// do not send $unset if not defined. Can cause exceptions in certain mongo versions.
|
||||
await Messages.updateOne(
|
||||
{ _id },
|
||||
{
|
||||
$set: {
|
||||
...editedMessage,
|
||||
},
|
||||
...(!editedMessage.md && { $unset: { md: 1 } }),
|
||||
},
|
||||
);
|
||||
|
||||
if (Apps.self?.isLoaded()) {
|
||||
// This returns a promise, but it won't mutate anything about the message
|
||||
// so, we don't really care if it is successful or fails
|
||||
void Apps.self?.triggerEvent(AppEvents.IPostMessageUpdated, messageData);
|
||||
}
|
||||
|
||||
setImmediate(async () => {
|
||||
const msg = await Messages.findOneById(_id);
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
await afterSaveMessage(msg, room, user);
|
||||
|
||||
if (room?.lastMessage?._id === msg._id) {
|
||||
void notifyOnRoomChangedById(message.rid);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user