snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

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

View File

@@ -0,0 +1,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,
};
};

View 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,
});
}
}

View File

@@ -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),
};
}

View File

@@ -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([]);
});
});

View 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;
};

View 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;
};

View 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);

View 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,
};
}

View 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 };
}

View 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);
};

View 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;
}

View 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;
};

View 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);
}
});
};