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,379 @@
import type {
OEmbedUrlContentResult,
MessageUrl,
OEmbedUrlWithMetadata,
OEmbedMeta,
IMessage,
OEmbedUrlContent,
} from '@rocket.chat/core-typings';
import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { OEmbedCache, Messages } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { isAbsoluteURL } from '@rocket.chat/tools';
import he from 'he';
import iconv from 'iconv-lite';
import ipRangeCheck from 'ip-range-check';
import jschardet from 'jschardet';
import { camelCase } from 'lodash';
import { Info } from '../../../../app/utils/rocketchat.info';
import { settings } from '../../../settings';
import { afterParseUrlContent, beforeGetUrlContent } from '../lib/oembed/providers';
const MAX_EXTERNAL_URL_PREVIEWS = 5;
const log = new Logger('OEmbed');
// Detect encoding
// Priority:
// Detected == HTTP Header > Detected == HTML meta > HTTP Header > HTML meta > Detected > Default (utf-8)
// See also: https://www.w3.org/International/questions/qa-html-encoding-declarations.en#quickanswer
const getCharset = function (contentType: string, body: Buffer): string {
let detectedCharset;
let httpHeaderCharset;
let htmlMetaCharset;
let result;
contentType = contentType || '';
const binary = body.toString('binary');
const detected = jschardet.detect(binary);
if (detected.confidence > 0.8) {
detectedCharset = detected.encoding.toLowerCase();
}
const m1 = contentType.match(/charset=([\w\-]+)/i);
if (m1) {
httpHeaderCharset = m1[1].toLowerCase();
}
const m2 = binary.match(/<meta\b[^>]*charset=["']?([\w\-]+)/i);
if (m2) {
htmlMetaCharset = m2[1].toLowerCase();
}
if (detectedCharset) {
if (detectedCharset === httpHeaderCharset) {
result = httpHeaderCharset;
} else if (detectedCharset === htmlMetaCharset) {
result = htmlMetaCharset;
}
}
if (!result) {
result = httpHeaderCharset || htmlMetaCharset || detectedCharset;
}
return result || 'utf-8';
};
const toUtf8 = function (contentType: string, body: Buffer): string {
return iconv.decode(body, getCharset(contentType, body));
};
const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise<OEmbedUrlContent> => {
const portsProtocol = new Map<string, string>(
Object.entries({
80: 'http:',
8080: 'http:',
443: 'https:',
}),
);
const ignoredHosts =
settings
.get<string>('API_EmbedIgnoredHosts')
.replace(/\s/g, '')
.split(',')
.filter(Boolean)
.map((host) => host.toLowerCase()) || [];
const isIgnoredHost = (hostname: string | undefined): boolean => {
hostname = hostname?.toLowerCase();
if (!hostname || !ignoredHosts.length) {
return false;
}
const exactHosts = ignoredHosts.filter((h) => !h.includes('*'));
if (exactHosts.includes(hostname) || ipRangeCheck(hostname, exactHosts)) {
return true;
}
return ignoredHosts
.filter((h) => h.includes('*'))
.some((pattern) => {
const validationRegex = /^(?:\*\.)?(?:\*|[a-z0-9-]+)(?:\.(?:\*|[a-z0-9-]+))*$/i;
if (!validationRegex.test(pattern) || pattern === '*') {
return false;
}
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
const source = `^${escaped.replace(/\*/g, '[^.]*')}$`;
try {
const regex = new RegExp(source, 'i');
return regex.test(hostname);
} catch {
// fail safe on invalid patterns
return false;
}
});
};
if (isIgnoredHost(urlObj.hostname)) {
throw new Error('host is ignored');
}
const safePorts = settings.get<string>('API_EmbedSafePorts').replace(/\s/g, '').split(',') || [];
// checks if the URL port is in the safe ports list
if (safePorts.length > 0 && urlObj.port && !safePorts.includes(urlObj.port)) {
throw new Error('invalid/unsafe port');
}
// if port is not detected, use protocol to verify instead
if (safePorts.length > 0 && !urlObj.port && !safePorts.some((port) => portsProtocol.get(port) === urlObj.protocol)) {
throw new Error('invalid/unsafe port');
}
const data = beforeGetUrlContent({ urlObj });
const url = data.urlObj.toString();
const sizeLimit = 250000;
log.debug({ msg: 'Fetching URL for OEmbed', url, redirectCount });
const start = Date.now();
const response = await fetch(
url,
{
compress: true,
follow: redirectCount,
headers: {
'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version}`,
'Accept-Language': settings.get('Language') || 'en',
...data.headerOverrides,
},
timeout: settings.get<number>('API_EmbedTimeout') * 1000,
size: sizeLimit, // max size of the response body, this was not working as expected so I'm also manually verifying that on the iterator
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
},
settings.get('Allow_Invalid_SelfSigned_Certs'),
);
const end = Date.now();
let totalSize = 0;
const chunks = [];
for await (const chunk of response.body) {
totalSize += chunk.length;
chunks.push(chunk as Buffer);
if (totalSize > sizeLimit) {
log.warn({ msg: 'OEmbed request size exceeded', url });
break;
}
}
log.debug({ msg: 'Obtained response from server', length: totalSize, timeSpent: `${end - start}ms` });
const buffer = Buffer.concat(chunks);
return {
headers: Object.fromEntries(response.headers),
body: toUtf8(response.headers.get('content-type') || 'text/plain', buffer),
statusCode: response.status,
urlObj,
};
};
const parseUrl = async function (url: string): Promise<{ urlPreview: MessageUrl; foundMeta: boolean }> {
const parsedUrlObject: MessageUrl = { url, meta: {} };
let foundMeta = false;
if (!isAbsoluteURL(url)) {
return { urlPreview: parsedUrlObject, foundMeta };
}
const data = await getUrlMetaWithCache(url);
if (!data) {
return { urlPreview: parsedUrlObject, foundMeta };
}
if (isOEmbedUrlWithMetadata(data) && data.meta) {
parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {};
if (parsedUrlObject.meta?.oembedHtml) {
parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || '';
}
}
foundMeta = true;
return {
urlPreview: {
...parsedUrlObject,
...((parsedUrlObject.headers || data.headers) && {
headers: {
...parsedUrlObject.headers,
...(data.headers?.contentLength && { contentLength: data.headers.contentLength }),
...(data.headers?.contentType && { contentType: data.headers.contentType }),
},
}),
},
foundMeta,
};
};
const getUrlMeta = async function (
url: string,
withFragment?: boolean,
): Promise<OEmbedUrlWithMetadata | OEmbedUrlContentResult | undefined> {
log.debug({ msg: 'Obtaining metadata for URL', url });
const urlObj = new URL(url);
if (withFragment) {
urlObj.searchParams.set('_escaped_fragment_', '');
}
log.debug({ msg: 'Fetching URL content', url: urlObj.toString() });
let content: OEmbedUrlContent | undefined;
try {
content = await getUrlContent(urlObj, 5);
} catch (err) {
log.error({ msg: 'Error fetching URL content', url: urlObj.toString(), err });
}
if (!content) {
return;
}
log.debug({ msg: 'Parsing metadata for URL', url });
const metas: OEmbedMeta = {} as any;
if (content?.body) {
const escapeMeta = (name: string, value: string): string => {
metas[name] = metas[name] || he.unescape(value);
return metas[name];
};
content.body.replace(/<title[^>]*>([^<]*)<\/title>/gim, (_meta, title) => {
return escapeMeta('pageTitle', title);
});
content.body.replace(/<meta[^>]*(?:name|property)=[']([^']*)['][^>]*\scontent=[']([^']*)['][^>]*>/gim, (_meta, name, value) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*(?:name|property)=["]([^"]*)["][^>]*\scontent=["]([^"]*)["][^>]*>/gim, (_meta, name, value) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*\scontent=[']([^']*)['][^>]*(?:name|property)=[']([^']*)['][^>]*>/gim, (_meta, value, name) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*\scontent=["]([^"]*)["][^>]*(?:name|property)=["]([^"]*)["][^>]*>/gim, (_meta, value, name) => {
return escapeMeta(camelCase(name), value);
});
if (metas.fragment === '!' && withFragment == null) {
return getUrlMeta(url, true);
}
delete metas.oembedHtml;
}
const headers: { [k: string]: string } = {};
if (content?.headers) {
const headerObj = content.headers;
Object.keys(headerObj).forEach((header) => {
headers[camelCase(header)] = headerObj[header];
});
}
if (content && content.statusCode !== 200) {
return;
}
return afterParseUrlContent({
url,
meta: metas,
headers,
content,
});
};
const getUrlMetaWithCache = async function (
url: string,
withFragment?: boolean,
): Promise<OEmbedUrlWithMetadata | undefined | OEmbedUrlContentResult> {
log.debug({ msg: 'Getting OEmbed metadata for URL', url });
const cache = await OEmbedCache.findOneById(url);
if (cache) {
log.debug({ msg: 'Found OEmbed metadata in cache for URL', url });
return cache.data;
}
const data = await getUrlMeta(url, withFragment);
if (!data) {
return;
}
try {
log.debug({ msg: 'Saving OEmbed metadata in cache for URL', url });
await OEmbedCache.createWithIdAndData(url, data);
} catch (_error) {
log.error({ msg: 'OEmbed duplicated record', url });
}
return data;
};
const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record<string, string> | void {
const tags: Record<string, string> = {};
Object.keys(metaObj).forEach((key) => {
const value = metaObj[key];
if (/^(og|fb|twitter|oembed|msapplication).+|description|title|pageTitle$/.test(key.toLowerCase()) && value && value.trim() !== '') {
tags[key] = value;
}
});
if (Object.keys(tags).length > 0) {
return tags;
}
};
const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined =>
oembedHtml?.replace('iframe', 'iframe style="max-width: 100%;width:400px;height:225px"');
const rocketUrlParser = async function (message: IMessage): Promise<IMessage> {
log.debug({ msg: 'Parsing message URLs' });
if (!settings.get('API_Embed')) {
return message;
}
if (!Array.isArray(message.urls)) {
return message;
}
log.debug({ msg: 'URLs found in message', count: message.urls.length });
if (
(message.attachments && message.attachments.length > 0) ||
message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS
) {
log.debug({ msg: 'All URLs ignored for OEmbed' });
return message;
}
let changed = false;
for await (const item of message.urls) {
if (item.ignoreParse === true) {
log.debug({ msg: 'URL ignored for OEmbed', url: item.url });
continue;
}
const { urlPreview, foundMeta } = await parseUrl(item.url);
Object.assign(item, foundMeta ? urlPreview : {});
changed = changed || foundMeta;
}
if (changed === true) {
await Messages.setUrlsById(message._id, message.urls);
}
return message;
};
export const OEmbed: {
rocketUrlParser: (message: IMessage) => Promise<IMessage>;
parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>;
} = {
rocketUrlParser,
parseUrl,
};

View File

@@ -0,0 +1,18 @@
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import type { AtLeast, IMessage, IRoom } from '@rocket.chat/core-typings';
import { isFederationEnabled } from '../../federation/utils';
export class FederationActions {
public static shouldPerformAction(_message: IMessage, room: AtLeast<IRoom, 'federated'>): boolean {
if (!isRoomFederated(room)) {
return true;
}
if (!isRoomNativeFederated(room)) {
return false;
}
return isFederationEnabled();
}
}

View File

@@ -0,0 +1,71 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import type BadWordsFilter from 'bad-words';
export class BeforeSaveBadWords {
badWords: BadWordsFilter | null = null;
badWordsRegex: RegExp | null = null;
protected logger: Logger;
constructor() {
this.logger = new Logger('BadWordsFilter');
}
async configure(badWordsList?: string, goodWordsList?: string) {
const { default: Filter } = await import('bad-words');
const badWords =
badWordsList
?.split(',')
.map((word) => word.trim())
.filter(Boolean) || [];
const options = {
list: badWords,
// library definition does not allow optional definition
exclude: undefined,
splitRegex: undefined,
placeHolder: undefined,
regex: undefined,
replaceRegex: undefined,
emptyList: undefined,
};
this.badWords = new Filter(options);
try {
this.badWordsRegex = new RegExp(`(?<=^|[\\p{P}\\p{Z}])(${badWords.join('|')})(?=$|[\\p{P}\\p{Z}])`, 'gmiu');
} catch (err) {
this.badWordsRegex = null;
this.logger.error({ msg: 'Error when initializing bad words filter', err });
}
if (goodWordsList?.length) {
this.badWords.removeWords(...goodWordsList.split(',').map((word) => word.trim()));
}
}
disable() {
this.badWords = null;
this.badWordsRegex = null;
}
async filterBadWords({ message }: { message: IMessage }): Promise<IMessage> {
if (!message.msg || !this.badWords) {
return message;
}
try {
message.msg = this.badWords.clean(message.msg);
} catch (error) {
// ignore
} finally {
if (this.badWordsRegex) {
message.msg = message.msg.replace(this.badWordsRegex, (match) => '*'.repeat(match.length));
}
}
return message;
}
}

View File

@@ -0,0 +1,24 @@
import { MeteorError, Omnichannel } from '@rocket.chat/core-services';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
export class BeforeSaveCheckMAC {
async isWithinLimits({ message, room }: { message: IMessage; room: IRoom }): Promise<void> {
if (!isOmnichannelRoom(room)) {
return;
}
if (message.token) {
return;
}
if (message.t) {
return;
}
const canSendMessage = await Omnichannel.isWithinMACLimit(room);
if (!canSendMessage) {
throw new MeteorError('error-mac-limit-reached');
}
}
}

View File

@@ -0,0 +1,172 @@
import QueryString from 'node:querystring';
import URL from 'node:url';
import type { MessageAttachment, IMessage, IUser, IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, isQuoteAttachment } from '@rocket.chat/core-typings';
import { createQuoteAttachment } from '../../../../lib/createQuoteAttachment';
const recursiveRemoveAttachments = (attachments: MessageAttachment, deep = 1, quoteChainLimit: number): MessageAttachment => {
if (attachments && isQuoteAttachment(attachments)) {
if (deep < quoteChainLimit - 1) {
attachments.attachments?.map((msg) => recursiveRemoveAttachments(msg, deep + 1, quoteChainLimit));
} else if (attachments.attachments) {
attachments.attachments = attachments.attachments.filter((attachment) => !isQuoteAttachment(attachment));
if (attachments.attachments.length === 0) {
delete attachments.attachments;
}
}
}
return attachments;
};
const validateAttachmentDeepness = (message: IMessage, quoteChainLimit: number): IMessage => {
if (!message?.attachments) {
return message;
}
if ((message.attachments && quoteChainLimit < 2) || isNaN(quoteChainLimit)) {
message.attachments = message.attachments.filter((attachment) => !isQuoteAttachment(attachment));
if (message.attachments.length === 0) {
delete message.attachments;
}
}
message.attachments = message.attachments?.map((attachment) => recursiveRemoveAttachments(attachment, 1, quoteChainLimit));
return message;
};
const removeQuoteAttachments = (message: IMessage) => {
if (!message.attachments) {
return;
}
message.attachments = message.attachments.filter((attachment) => !isQuoteAttachment(attachment));
};
type JumpToMessageInit = {
getMessages(messageIds: IMessage['_id'][]): Promise<IMessage[]>;
getRooms(roomIds: IRoom['_id'][]): Promise<IRoom[] | IOmnichannelRoom[] | null>;
canAccessRoom(room: IRoom, user: Pick<IUser, '_id' | 'username' | 'name' | 'language'>): Promise<boolean>;
getUserAvatarURL(user?: string): string;
};
/**
* Transform URLs in messages into quote attachments
*/
export class BeforeSaveJumpToMessage {
private getMessages: JumpToMessageInit['getMessages'];
private getRooms: JumpToMessageInit['getRooms'];
private canAccessRoom: JumpToMessageInit['canAccessRoom'];
private getUserAvatarURL: JumpToMessageInit['getUserAvatarURL'];
constructor(options: JumpToMessageInit) {
this.getMessages = options.getMessages;
this.getRooms = options.getRooms;
this.canAccessRoom = options.canAccessRoom;
this.getUserAvatarURL = options.getUserAvatarURL;
}
async createAttachmentForMessageURLs({
message,
user: currentUser,
config,
}: {
message: IMessage;
user: Pick<IUser, '_id' | 'username' | 'name' | 'language'>;
config: {
chainLimit: number;
siteUrl: string;
useRealName: boolean;
};
}): Promise<IMessage> {
// Quote attachments are always rebuilt. Do not keep old ones since they may not still be linked to the message
removeQuoteAttachments(message);
// if no message is present, or the message doesn't have any URL, skip
if (!message?.urls?.length) {
return message;
}
const linkedMessages = message.urls
.filter((item) => item.url.includes(config.siteUrl))
.map((item) => {
const urlObj = URL.parse(item.url);
// if the URL doesn't have query params (doesn't reference message) skip
if (!urlObj.query) {
return;
}
const { msg: msgId } = QueryString.parse(urlObj.query);
if (typeof msgId !== 'string') {
return;
}
return { msgId, url: item.url };
})
.filter(Boolean);
const msgs = await this.getMessages(linkedMessages.map((linkedMsg) => linkedMsg?.msgId) as string[]);
const validMessages = msgs.filter((msg) => validateAttachmentDeepness(msg, config.chainLimit));
const rooms = await this.getRooms(validMessages.map((msg) => msg.rid));
const roomsWithPermission =
rooms &&
(await Promise.all(
rooms.map(async (room) => {
if (!!message.token && isOmnichannelRoom(room) && !!room.v?.token && message.token === room.v.token) {
return room;
}
if (currentUser && (await this.canAccessRoom(room, currentUser))) {
return room;
}
}),
));
const validRooms = roomsWithPermission?.filter((room) => !!room);
const { useRealName } = config;
const quotes = [];
for (const item of message.urls) {
if (!item.url.includes(config.siteUrl)) {
continue;
}
const linkedMessage = linkedMessages.find((msg) => msg?.url === item.url);
if (!linkedMessage) {
continue;
}
const messageFromUrl = validMessages.find((msg) => msg._id === linkedMessage.msgId);
if (!messageFromUrl) {
continue;
}
if (!validRooms?.find((room) => room?._id === messageFromUrl.rid)) {
continue;
}
item.ignoreParse = true;
quotes.push(createQuoteAttachment(messageFromUrl, item.url, useRealName, this.getUserAvatarURL(messageFromUrl.u.username)));
}
if (quotes.length > 0) {
const currentAttachments = message.attachments || [];
message.attachments = [...currentAttachments, ...quotes];
}
return message;
}
}

View File

@@ -0,0 +1,51 @@
import { isE2EEMessage } from '@rocket.chat/core-typings';
import type { IMessage } from '@rocket.chat/core-typings';
import { parse } from '@rocket.chat/message-parser';
import { getMessageMaxParseLength } from '../../../../lib/getMessageMaxParseLength';
type ParserConfig = {
colors?: boolean;
emoticons?: boolean;
customDomains?: string[];
katex?: {
dollarSyntax: boolean;
parenthesisSyntax: boolean;
};
};
export class BeforeSaveMarkdownParser {
constructor(private enabled: boolean = true) {
// no op
}
async parseMarkdown({ message, config }: { message: IMessage; config: ParserConfig }): Promise<IMessage> {
if (!this.enabled) {
return message;
}
if (isE2EEMessage(message)) {
return message;
}
const messageMaxParseLength = getMessageMaxParseLength();
if (messageMaxParseLength > 0 && message.msg && message.msg.length > messageMaxParseLength) {
delete message.md;
return message;
}
try {
if (message.msg) {
message.md = parse(message.msg, config);
}
if (message.attachments?.[0]?.description) {
message.attachments[0].descriptionMd = parse(message.attachments[0].description, config);
}
} catch (e) {
console.error(e); // errors logged while the parser is at experimental stage
}
return message;
}
}

View File

@@ -0,0 +1,87 @@
import { api, Team, MeteorError } from '@rocket.chat/core-services';
import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { i18n } from '../../../lib/i18n';
import { MentionsServer } from '../../../lib/messaging/mentions/Mentions';
import { settings } from '../../../settings';
class MentionQueries {
async getUsers(usernames: string[]): Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]> {
const uniqueUsernames = [...new Set(usernames)];
const teams = await Team.listByNames(uniqueUsernames, { projection: { name: 1 } });
const users = await Users.find<Pick<IUser, '_id' | 'username' | 'name'>>(
{ username: { $in: uniqueUsernames } },
{ projection: { _id: 1, username: 1, name: 1 } },
).toArray();
const taggedUsers = users.map((user) => ({
...user,
type: 'user' as const,
}));
if (settings.get<boolean>('Troubleshoot_Disable_Teams_Mention')) {
return taggedUsers;
}
const taggedTeams = teams.map((team) => ({
...team,
type: 'team' as const,
}));
return [...taggedUsers, ...taggedTeams];
}
async getUser(userId: string): Promise<IUser | null> {
return Users.findOneById(userId);
}
getTotalChannelMembers(rid: string): Promise<number> {
return Subscriptions.countByRoomId(rid);
}
getChannels(channels: string[]): Promise<Pick<IRoom, '_id' | 'name' | 'fname' | 'federated'>[]> {
return Rooms.find(
{
$and: [
{
$or: [
{ $and: [{ $or: [{ federated: { $exists: false } }, { federated: false }], name: { $in: [...new Set(channels)] } }] },
{ federated: true, fname: { $in: [...new Set(channels)] } },
],
},
],
t: { $in: ['c', 'p'] },
},
{ projection: { _id: 1, name: 1, fname: 1, federated: 1 } },
).toArray();
}
}
const queries = new MentionQueries();
export const mentionServer = new MentionsServer({
pattern: () => settings.get<string>('UTF8_User_Names_Validation'),
messageMaxAll: () => settings.get<number>('Message_MaxAll'),
getUsers: async (usernames: string[]) => queries.getUsers(usernames),
getUser: async (userId: string) => queries.getUser(userId),
getTotalChannelMembers: (rid: string) => queries.getTotalChannelMembers(rid),
getChannels: (channels: string[]) => queries.getChannels(channels),
async onMaxRoomMembersExceeded({ sender, rid }: { sender: IMessage['u']; rid: string }): Promise<void> {
// Get the language of the user for the error notification.
const { language } = (await this.getUser(sender._id)) || {};
const msg = i18n.t('Group_mentions_disabled_x_members', { total: this.messageMaxAll(), lng: language });
void api.broadcast('notify.ephemeralMessage', sender._id, rid, {
msg,
});
// Also throw to stop propagation of 'sendMessage'.
throw new MeteorError('error-action-not-allowed', msg, {
method: 'filterATAllTag',
action: msg,
});
},
});

View File

@@ -0,0 +1,38 @@
import { Authorization, MeteorError } from '@rocket.chat/core-services';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import { i18n } from '../../../lib/i18n';
export class BeforeSavePreventMention {
async preventMention({
message,
user,
mention,
permission,
}: {
message: IMessage;
user: Pick<IUser, '_id' | 'username' | 'name' | 'language'>;
mention: 'here' | 'all';
permission: string;
}): Promise<boolean> {
if (!message.mentions?.some(({ _id }) => _id === mention)) {
return true;
}
// Check if the user has permissions to use @all in both global and room scopes.
if (await Authorization.hasPermission(message.u._id, permission)) {
return true;
}
if (await Authorization.hasPermission(message.u._id, permission, message.rid)) {
return true;
}
const action = i18n.t('Notify_all_in_this_room', { lng: user.language });
// Also throw to stop propagation of 'sendMessage'.
throw new MeteorError('error-action-not-allowed', `Notify ${mention} in this room not allowed`, {
action,
});
}
}

View File

@@ -0,0 +1,38 @@
import type { IMessage } from '@rocket.chat/core-typings';
// look for spotify syntax (e.g.: spotify:track:1q6IK1l4qpYykOaWaLJkWG) on the message and add them to the urls array
export class BeforeSaveSpotify {
async convertSpotifyLinks({ message }: { message: IMessage }): Promise<IMessage> {
if (!message.msg?.trim()) {
return message;
}
const urls = Array.isArray(message.urls) ? message.urls : [];
let changed = false;
const msgParts = message.msg.split(/(```\w*[\n ]?[\s\S]*?```+?)|(`(?:[^`]+)`)/);
for (let index = 0; index < msgParts.length; index++) {
const part = msgParts[index];
if (!/(?:```(\w*)[\n ]?([\s\S]*?)```+?)|(?:`(?:[^`]+)`)/.test(part)) {
const re = /(?:^|\s)spotify:([^:\s]+):([^:\s]+)(?::([^:\s]+))?(?::(\S+))?(?:\s|$)/g;
let match;
while ((match = re.exec(part)) != null) {
const data = match.slice(1).filter(Boolean);
const path = data.map((value) => encodeURI(value)).join('/');
const url = `https://open.spotify.com/${path}`;
urls.push({ url, source: `spotify:${data.join(':')}`, meta: {} });
changed = true;
}
}
}
if (changed) {
message.urls = urls;
}
return message;
}
}

View File

@@ -0,0 +1,196 @@
import type { OEmbedMeta, OEmbedUrlContent, OEmbedProvider } from '@rocket.chat/core-typings';
import { camelCase } from 'change-case';
import { Info } from '../../../../../app/utils/rocketchat.info';
import { SystemLogger } from '../../../../lib/logger/system';
import { settings } from '../../../../settings';
class Providers {
private providers: OEmbedProvider[];
constructor() {
this.providers = [];
}
static getConsumerUrl(provider: OEmbedProvider, url: string): string | undefined {
if (!provider.endPoint) {
return;
}
const urlObj = new URL(provider.endPoint);
urlObj.searchParams.set('url', url);
return urlObj.toString();
}
static getCustomHeaders(provider: OEmbedProvider): { [k: string]: string } {
return provider.getHeaderOverrides?.() || {};
}
registerProvider(provider: OEmbedProvider): number {
return this.providers.push(provider);
}
getProviders(): OEmbedProvider[] {
return this.providers;
}
getProviderForUrl(url: string): OEmbedProvider | undefined {
return this.providers?.find((provider) => {
return (
provider.urls?.some((re) => {
return re.test(url);
}) ?? false
);
});
}
}
const providers = new Providers();
providers.registerProvider({
urls: [new RegExp('https?://soundcloud\\.com/\\S+')],
endPoint: 'https://soundcloud.com/oembed?format=json&maxheight=150',
});
providers.registerProvider({
urls: [
new RegExp('https?://vimeo\\.com/[^/]+'),
new RegExp('https?://vimeo\\.com/channels/[^/]+/[^/]+'),
new RegExp('https://vimeo\\.com/groups/[^/]+/videos/[^/]+'),
],
endPoint: 'https://vimeo.com/api/oembed.json?maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.youtube\\.com/\\S+'), new RegExp('https?://youtu\\.be/\\S+')],
endPoint: 'https://www.youtube.com/oembed?maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.rdio\\.com/\\S+'), new RegExp('https?://rd\\.io/\\S+')],
endPoint: 'https://www.rdio.com/api/oembed/?format=json&maxheight=150',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.slideshare\\.net/[^/]+/[^/]+')],
endPoint: 'https://www.slideshare.net/api/oembed/2?format=json&maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.dailymotion\\.com/video/\\S+')],
endPoint: 'https://www.dailymotion.com/services/oembed?maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://(twitter|x)\\.com/[^/]+/status/\\S+')],
getHeaderOverrides: () => {
return {
'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version} Googlebot/2.1`,
};
},
});
providers.registerProvider({
urls: [new RegExp('https?://(play|open)\\.spotify\\.com/(track|album|playlist|show)/\\S+')],
endPoint: 'https://open.spotify.com/oembed',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.loom\\.com/\\S+')],
endPoint: 'https://www.loom.com/v1/oembed?format=json',
});
if (process.env.TEST_MODE) {
// resolves the oembed payload from the CI mock-server instead of a real provider — see the oembed suite in tests/end-to-end/api/chat.ts
providers.registerProvider({
urls: [new RegExp('https?://mock-server\\.dev(:\\d+)?/video/\\S+')],
endPoint: 'http://mock-server.dev:8080/oembed',
});
}
export const beforeGetUrlContent = (data: {
urlObj: URL;
}): {
urlObj: URL;
headerOverrides?: { [k: string]: string };
} => {
if (!data.urlObj) {
return data;
}
const url = data.urlObj.toString();
const provider = providers.getProviderForUrl(url);
if (!provider) {
return data;
}
const consumerUrl = Providers.getConsumerUrl(provider, url);
const headerOverrides = Providers.getCustomHeaders(provider);
if (!consumerUrl) {
return { ...data, headerOverrides };
}
return { ...data, headerOverrides, urlObj: new URL(consumerUrl) };
};
const cleanupOembed = (data: {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
}): {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
} => {
if (!data?.meta) {
return data;
}
// remove oembedHtml key from original meta
const { oembedHtml, ...meta } = data.meta;
return {
...data,
meta: meta as OEmbedMeta,
};
};
export const afterParseUrlContent = (data: {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
}): {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
} => {
if (!data?.url || !data.content?.body) {
return cleanupOembed(data);
}
const provider = providers.getProviderForUrl(data.url);
if (!provider) {
return cleanupOembed(data);
}
data.meta.oembedUrl = data.url;
try {
const metas = JSON.parse(data.content.body);
Object.entries(metas).forEach(([key, value]) => {
if (value && typeof value === 'string') {
data.meta[camelCase(`oembed_${key}`)] = value;
}
});
} catch (err) {
SystemLogger.error({ err });
}
return data;
};

View File

@@ -0,0 +1,352 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import type { IMessageService } from '@rocket.chat/core-services';
import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services';
import { isEditedMessage } from '@rocket.chat/core-typings';
import type { MessageUrl, IMessage, MessageTypesValues, IUser, IRoom, AtLeast } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';
import { OEmbed } from './hooks/AfterSaveOEmbed';
import { BeforeSaveCannedResponse } from '../../../ee/server/hooks/messages/BeforeSaveCannedResponse';
import { FederationMatrixInvalidConfigurationError } from '../federation/utils';
import { FederationActions } from './hooks/BeforeFederationActions';
import { BeforeSaveBadWords } from './hooks/BeforeSaveBadWords';
import { BeforeSaveCheckMAC } from './hooks/BeforeSaveCheckMAC';
import { BeforeSaveJumpToMessage } from './hooks/BeforeSaveJumpToMessage';
import { BeforeSaveMarkdownParser } from './hooks/BeforeSaveMarkdownParser';
import { mentionServer } from './hooks/BeforeSaveMentions';
import { BeforeSavePreventMention } from './hooks/BeforeSavePreventMention';
import { BeforeSaveSpotify } from './hooks/BeforeSaveSpotify';
import { closeUnclosedCodeBlock } from '../../../lib/utils/closeUnclosedCodeBlock';
import { notifyUsersOnSystemMessage } from '../../hooks/messages/notifyUsersOnMessage';
import { SystemLogger } from '../../lib/logger/system';
import { deleteMessage } from '../../lib/messages/deleteMessage';
import { parseUrlsInMessage } from '../../lib/messages/parseUrlsInMessage';
import { sendMessage } from '../../lib/messages/sendMessage';
import { updateMessage } from '../../lib/messages/updateMessage';
import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage';
import { executeSetReaction } from '../../lib/messaging/reactions/setReaction';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/notifyListener';
import { shouldBreakInVersion } from '../../lib/shouldBreakInVersion';
import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL';
import { executeSendMessage } from '../../meteor-methods/messages/sendMessage';
import { settings } from '../../settings';
const disableMarkdownParser = ['yes', 'true'].includes(String(process.env.DISABLE_MESSAGE_PARSER).toLowerCase());
export class MessageService extends ServiceClassInternal implements IMessageService {
protected name = 'message';
private preventMention: BeforeSavePreventMention;
private badWords: BeforeSaveBadWords;
private spotify: BeforeSaveSpotify;
private jumpToMessage: BeforeSaveJumpToMessage;
private cannedResponse: BeforeSaveCannedResponse;
private markdownParser: BeforeSaveMarkdownParser;
private checkMAC: BeforeSaveCheckMAC;
override async created() {
this.preventMention = new BeforeSavePreventMention();
this.badWords = new BeforeSaveBadWords();
this.spotify = new BeforeSaveSpotify();
this.jumpToMessage = new BeforeSaveJumpToMessage({
getMessages(messageIds) {
return Messages.findVisibleByIds(messageIds).toArray();
},
getRooms(roomIds) {
return Rooms.findByIds(roomIds).toArray();
},
canAccessRoom(room: IRoom, user: IUser): Promise<boolean> {
return Authorization.canAccessRoom(room, user);
},
getUserAvatarURL(user?: string): string {
return (user && getUserAvatarURL(user)) || '';
},
});
this.cannedResponse = new BeforeSaveCannedResponse();
this.markdownParser = new BeforeSaveMarkdownParser(!disableMarkdownParser);
this.checkMAC = new BeforeSaveCheckMAC();
await this.configureBadWords();
}
private async configureBadWords() {
settings.watchMultiple(
['Message_AllowBadWordsFilter', 'Message_BadWordsFilterList', 'Message_BadWordsWhitelist'],
async ([enabled, badWordsList, whiteList]) => {
if (!enabled) {
this.badWords.disable();
return;
}
await this.badWords.configure(badWordsList as string, whiteList as string);
},
);
}
async sendMessage({ fromId, rid, msg }: { fromId: string; rid: string; msg: string }): Promise<IMessage> {
return executeSendMessage(fromId, { rid, msg });
}
async saveMessageFromFederation({
fromId,
rid,
federation_event_id,
msg,
e2e_content,
file,
files,
attachments,
thread,
ts,
}: {
fromId: string;
rid: string;
federation_event_id: string;
msg?: string;
e2e_content?: {
algorithm: 'm.megolm.v1.aes-sha2';
ciphertext: string;
};
file?: IMessage['file'];
files?: IMessage['files'];
attachments?: IMessage['attachments'];
thread?: { tmid: string; tshow: boolean };
ts: Date;
}): Promise<IMessage> {
return executeSendMessage(
fromId,
{
rid,
msg,
...thread,
federation: {
eventId: federation_event_id,
version: 1,
},
...(file && { file }),
...(files && { files }),
...(attachments && { attachments }),
...(e2e_content && {
t: 'e2e',
content: e2e_content,
}),
},
{ ts },
);
}
async sendMessageWithValidation(user: IUser, message: Partial<IMessage>, room: Partial<IRoom>, upsert = false): Promise<IMessage> {
return sendMessage(user, message, room, { upsert });
}
async deleteMessage(user: IUser, message: IMessage): Promise<void> {
return deleteMessage(message, user);
}
async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage, previewUrls?: string[]): Promise<void> {
return updateMessage(message, user, originalMsg, previewUrls);
}
async reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise<void> {
return executeSetReaction(userId, reaction, messageId, shouldReact);
}
async saveSystemMessageAndNotifyUser<T = IMessage>(
type: MessageTypesValues,
rid: string,
messageText: string,
owner: Pick<IUser, '_id' | 'username' | 'name'>,
extraData?: Partial<T>,
): Promise<IMessage> {
const createdMessage = await this.saveSystemMessage(type, rid, messageText, owner, extraData);
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Error('Failed to find the room.');
}
await notifyUsersOnSystemMessage(createdMessage, room);
return createdMessage;
}
async saveSystemMessage<T = IMessage>(
type: MessageTypesValues,
rid: string,
message: string,
owner: Pick<IUser, '_id' | 'username' | 'name'>,
extraData?: Partial<T>,
): Promise<IMessage> {
const { _id: userId, username, name } = owner;
if (!username) {
throw new Error('The username cannot be empty.');
}
const [{ insertedId }, room] = await Promise.all([
Messages.createWithTypeRoomIdMessageUserAndUnread(
type,
rid,
message,
{ _id: userId, username, name },
settings.get('Message_Read_Receipt_Enabled'),
extraData,
),
Rooms.findOneAndIncMsgCountById(rid, 1, { projection: { prid: 1, msgs: 1, lm: 1, sysMes: 1 } }),
]);
if (!insertedId) {
throw new Error('Failed to save system message.');
}
const createdMessage = await Messages.findOneById(insertedId);
if (!createdMessage) {
throw new Error('Failed to find the created message.');
}
if (Apps.self?.isLoaded()) {
void Apps.self?.triggerEvent(AppEvents.IPostSystemMessageSent, createdMessage);
}
void notifyOnMessageChange({ id: createdMessage._id, data: createdMessage });
void notifyOnRoomChangedById(rid);
if (room?.prid) {
try {
await updateAndNotifyParentRoomWithParentMessage(room);
} catch (err) {
SystemLogger.error({ msg: 'Failed to propagate discussion metadata', err, rid });
}
}
return createdMessage;
}
async beforeSave({
message,
room,
user,
previewUrls,
parseUrls = true,
}: {
message: IMessage;
room: IRoom;
user: Pick<IUser, '_id' | 'username' | 'name' | 'emails' | 'language'>;
previewUrls?: string[];
parseUrls?: boolean;
}): Promise<IMessage> {
// TODO looks like this one was not being used (so I'll left it commented)
// await this.joinDiscussionOnMessage({ message, room, user });
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to send message');
}
message = await this.cannedResponse.replacePlaceholders({ message, room, user });
message = await this.badWords.filterBadWords({ message });
// TODO: Auto-close unclosed markdown code blocks for server versions below 9.0.0
// In 9.0.0, this behavior is handled on the client side, so this block should be removed.
if (!shouldBreakInVersion('9.0.0') && message.msg) {
message = { ...message, msg: closeUnclosedCodeBlock(message.msg) };
}
message = await this.markdownParser.parseMarkdown({ message, config: this.getMarkdownConfig() });
message = await mentionServer.execute(message);
if (parseUrls) {
message.urls = parseUrlsInMessage(message, previewUrls);
}
message = await this.spotify.convertSpotifyLinks({ message });
message = await this.jumpToMessage.createAttachmentForMessageURLs({
message,
user,
config: {
chainLimit: settings.get<number>('Message_QuoteChainLimit'),
siteUrl: settings.get<string>('Site_Url'),
useRealName: settings.get<boolean>('UI_Use_Real_Name'),
},
});
if (!this.isEditedOrOld(message)) {
await Promise.all([
this.checkMAC.isWithinLimits({ message, room }),
this.preventMention.preventMention({ message, user, mention: 'all', permission: 'mention-all' }),
this.preventMention.preventMention({ message, user, mention: 'here', permission: 'mention-here' }),
]);
}
return message;
}
// The actions made on this event should be asynchronous
// That means, caller should not expect to receive updated message
// after calling
async afterSave({ message }: { message: IMessage }): Promise<void> {
await OEmbed.rocketUrlParser(message);
// Since this will happen after the message is sent and ack on the UI
// we'll notify until after these hooks are finished
void notifyOnMessageChange({ id: message._id });
}
private getMarkdownConfig() {
const customDomainAutoLink = settings.get<string>('Message_CustomDomain_AutoLink');
const customDomains = customDomainAutoLink ? customDomainAutoLink.split(',').map((domain) => domain.trim()) : [];
return {
colors: settings.get<boolean>('HexColorPreview_Enabled'),
emoticons: true,
customDomains,
...(settings.get<boolean>('Katex_Enabled') && {
katex: {
dollarSyntax: settings.get<boolean>('Katex_Dollar_Syntax'),
parenthesisSyntax: settings.get<boolean>('Katex_Parenthesis_Syntax'),
},
}),
};
}
private isEditedOrOld(message: IMessage): boolean {
return isEditedMessage(message) || !message.ts || Math.abs(Date.now() - message.ts.getTime()) > 60000;
}
// joinDiscussionOnMessage
// private async joinDiscussionOnMessage({ message, room, user }: { message: IMessage; room: IRoom; user: IUser }) {
// // abort if room is not a discussion
// if (!room.prid) {
// return;
// }
// // check if user already joined the discussion
// const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, message.u._id, {
// projection: { _id: 1 },
// });
// if (sub) {
// return;
// }
// await Room.join({ room, user });
// }
async beforeReacted(message: IMessage, room: AtLeast<IRoom, 'federated'>) {
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to react to message');
}
}
async beforeDelete(message: IMessage, room: IRoom) {
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to delete message');
}
}
async parseOEmbedUrl(url: string): Promise<{
urlPreview: MessageUrl;
foundMeta: boolean;
}> {
return OEmbed.parseUrl(url);
}
}