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:
379
apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts
Normal file
379
apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts
Normal 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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
196
apps/meteor/server/services/messages/lib/oembed/providers.ts
Normal file
196
apps/meteor/server/services/messages/lib/oembed/providers.ts
Normal 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;
|
||||
};
|
||||
352
apps/meteor/server/services/messages/service.ts
Normal file
352
apps/meteor/server/services/messages/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user