diff --git a/.changeset/gold-kids-move.md b/.changeset/gold-kids-move.md new file mode 100644 index 0000000000000..9a3c402b231e5 --- /dev/null +++ b/.changeset/gold-kids-move.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-services": patch +"@rocket.chat/i18n": patch +--- + +Changes OEmbed URL processing. Now, the processing is done asynchronously and has a configurable timeout for each request. Additionally, the `API_EmbedIgnoredHosts` setting now accepts wildcard domains. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 9884afc7e7367..f289960f4f411 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,3 +1,4 @@ +import { Message } from '@rocket.chat/core-services'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; @@ -48,7 +49,6 @@ import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage' import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { pinMessage, unpinMessage } from '../../../message-pin/server/pinMessage'; import { starMessage } from '../../../message-star/server/starMessage'; -import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; import { followMessage } from '../../../threads/server/methods/followMessage'; @@ -914,7 +914,7 @@ API.v1.addRoute( throw new Meteor.Error('error-not-allowed', 'Not allowed'); } - const { urlPreview } = await OEmbed.parseUrl(url); + const { urlPreview } = await Message.parseOEmbedUrl(url); urlPreview.ignoreParse = true; return API.v1.success({ urlPreview }); diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 184c2d9ce62ee..c41b52d959ad4 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -11,7 +11,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; import { afterSaveMessage } from '../lib/afterSaveMessage'; -import { notifyOnRoomChangedById, notifyOnMessageChange } from '../lib/notifyListener'; +import { notifyOnRoomChangedById } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; // TODO: most of the types here are wrong, but I don't want to change them now @@ -285,11 +285,8 @@ export const sendMessage = async function (user: any, message: any, room: any, u void Apps.self?.triggerEvent(messageEvent, message); } - // TODO: is there an opportunity to send returned data to notifyOnMessageChange? await afterSaveMessage(message, room, user); - void notifyOnMessageChange({ id: message._id }); - void notifyOnRoomChangedById(message.rid); return message; diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index 73894d136ce03..baf2628e73394 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -7,7 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { parseUrlsInMessage } from './parseUrlsInMessage'; import { settings } from '../../../settings/server'; import { afterSaveMessage } from '../lib/afterSaveMessage'; -import { notifyOnRoomChangedById, notifyOnMessageChange } from '../lib/notifyListener'; +import { notifyOnRoomChangedById } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; export const updateMessage = async function ( @@ -97,14 +97,7 @@ export const updateMessage = async function ( return; } - // although this is an "afterSave" kind callback, we know they can extend message's properties - // so we wait for it to run before broadcasting - const data = await afterSaveMessage(msg, room, user); - - void notifyOnMessageChange({ - id: msg._id, - data, - }); + await afterSaveMessage(msg, room, user); if (room?.lastMessage?._id === msg._id) { void notifyOnRoomChangedById(message.rid); diff --git a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts index b320c2d87e8d6..3ab96e1ab7478 100644 --- a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts +++ b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts @@ -1,3 +1,4 @@ +import { Message } from '@rocket.chat/core-services'; import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Rooms } from '@rocket.chat/models'; @@ -6,14 +7,16 @@ import { callbacks } from '../../../../server/lib/callbacks'; export async function afterSaveMessage(message: IMessage, room: IRoom, user: IUser, roomUpdater?: Updater): Promise { const updater = roomUpdater ?? Rooms.getUpdater(); - const data = await callbacks.run('afterSaveMessage', message, { room, user, roomUpdater: updater }); + const data: IMessage = (await callbacks.run('afterSaveMessage', message, { room, user, roomUpdater: updater })) as unknown as IMessage; if (!roomUpdater && updater.hasChanges()) { await Rooms.updateFromUpdater({ _id: room._id }, updater); } + void Message.afterSave({ message: data }); + // TODO: Fix type - callback configuration needs to be updated - return data as unknown as IMessage; + return data; } export function afterSaveMessageAsync(message: IMessage, room: IRoom, user: IUser, roomUpdater: Updater = Rooms.getUpdater()): void { @@ -22,4 +25,6 @@ export function afterSaveMessageAsync(message: IMessage, room: IRoom, user: IUse if (roomUpdater.hasChanges()) { void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); } + + void Message.afterSave({ message }); } diff --git a/apps/meteor/app/oembed/server/index.ts b/apps/meteor/app/oembed/server/index.ts deleted file mode 100644 index 245f608c6b94e..0000000000000 --- a/apps/meteor/app/oembed/server/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './providers'; -import './server'; diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index ad07a20c2a8a5..61cb32f15958d 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -42,7 +42,6 @@ import '../app/message-pin/server'; import '../app/message-star/server'; import '../app/nextcloud/server'; import '../app/oauth2-server-config/server'; -import '../app/oembed/server'; import '../app/push-notifications/server'; import '../app/retention-policy/server'; import '../app/slackbridge/server'; diff --git a/apps/meteor/server/lib/callbacks.ts b/apps/meteor/server/lib/callbacks.ts index 791df468f5da0..d9fa18e0491b7 100644 --- a/apps/meteor/server/lib/callbacks.ts +++ b/apps/meteor/server/lib/callbacks.ts @@ -9,8 +9,6 @@ import type { ILivechatInquiryRecord, ILivechatVisitor, VideoConference, - OEmbedMeta, - OEmbedUrlContent, IOmnichannelRoom, ILivechatTag, ILivechatTagRecord, @@ -164,16 +162,6 @@ type ChainedCallbackSignatures = { BusinessHourBehaviorClass: { new (): IBusinessHourBehavior }; }; 'renderMessage': (message: T) => T; - 'oembed:beforeGetUrlContent': (data: { urlObj: URL }) => { - urlObj: URL; - headerOverrides?: { [k: string]: string }; - }; - 'oembed:afterParseContent': (data: { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; content: OEmbedUrlContent }) => { - url: string; - meta: OEmbedMeta; - headers: { [k: string]: string }; - content: OEmbedUrlContent; - }; 'livechat.beforeListTags': () => ILivechatTag[]; 'livechat.offlineMessage': (data: { name: string; email: string; message: string; department?: string; host?: string }) => void; 'livechat.leadCapture': (room: IOmnichannelRoom) => IOmnichannelRoom; diff --git a/apps/meteor/app/oembed/server/server.ts b/apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts similarity index 82% rename from apps/meteor/app/oembed/server/server.ts rename to apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts index 1018e7426d1d9..3623b3ddd90ae 100644 --- a/apps/meteor/app/oembed/server/server.ts +++ b/apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts @@ -1,25 +1,25 @@ import type { OEmbedUrlContentResult, + MessageUrl, OEmbedUrlWithMetadata, - IMessage, - MessageAttachment, OEmbedMeta, - MessageUrl, + IMessage, + OEmbedUrlContent, } from '@rocket.chat/core-typings'; import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { Messages, OEmbedCache } from '@rocket.chat/models'; +import { OEmbedCache, Messages } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import { camelCase } from 'change-case'; import he from 'he'; import iconv from 'iconv-lite'; import ipRangeCheck from 'ip-range-check'; import jschardet from 'jschardet'; +import { camelCase } from 'lodash'; -import { isURL } from '../../../lib/utils/isURL'; -import { callbacks } from '../../../server/lib/callbacks'; -import { settings } from '../../settings/server'; -import { Info } from '../../utils/rocketchat.info'; +import { settings } from '../../../../app/settings/server'; +import { Info } from '../../../../app/utils/rocketchat.info'; +import { isURL } from '../../../../lib/utils/isURL'; +import { afterParseUrlContent, beforeGetUrlContent } from '../lib/oembed/providers'; const MAX_EXTERNAL_URL_PREVIEWS = 5; const log = new Logger('OEmbed'); @@ -65,7 +65,7 @@ const toUtf8 = function (contentType: string, body: Buffer): string { return iconv.decode(body, getCharset(contentType, body)); }; -const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise => { +const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise => { const portsProtocol = new Map( Object.entries({ 80: 'http:', @@ -74,9 +74,48 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise('API_EmbedIgnoredHosts').replace(/\s/g, '').split(',') || []; - if (urlObj.hostname && (ignoredHosts.includes(urlObj.hostname) || ipRangeCheck(urlObj.hostname, ignoredHosts))) { - throw new Error('invalid host'); + const ignoredHosts = + settings + .get('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('API_EmbedSafePorts').replace(/\s/g, '').split(',') || []; @@ -91,14 +130,13 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise('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 }, settings.get('Allow_Invalid_SelfSigned_Certs'), ); + const end = Date.now(); let totalSize = 0; const chunks = []; @@ -126,13 +166,14 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise { @@ -233,7 +274,7 @@ const getUrlMeta = async function ( if (content && content.statusCode !== 200) { return; } - return callbacks.run('oembed:afterParseContent', { + return afterParseUrlContent({ url, meta: metas, headers, @@ -289,6 +330,10 @@ const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined => const rocketUrlParser = async function (message: IMessage): Promise { log.debug({ msg: 'Parsing message URLs' }); + if (!settings.get('API_Embed')) { + return message; + } + if (!Array.isArray(message.urls)) { return message; } @@ -303,8 +348,6 @@ const rocketUrlParser = async function (message: IMessage): Promise { return message; } - const attachments: MessageAttachment[] = []; - let changed = false; for await (const item of message.urls) { if (item.ignoreParse === true) { @@ -318,10 +361,6 @@ const rocketUrlParser = async function (message: IMessage): Promise { changed = changed || foundMeta; } - if (attachments.length) { - await Messages.setMessageAttachments(message._id, attachments); - } - if (changed === true) { await Messages.setUrlsById(message._id, message.urls); } @@ -329,23 +368,10 @@ const rocketUrlParser = async function (message: IMessage): Promise { return message; }; -const OEmbed: { - getUrlMeta: (url: string, withFragment?: boolean) => Promise; - getUrlMetaWithCache: (url: string, withFragment?: boolean) => Promise; +export const OEmbed: { rocketUrlParser: (message: IMessage) => Promise; parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>; } = { rocketUrlParser, - getUrlMetaWithCache, - getUrlMeta, parseUrl, }; - -settings.watch('API_Embed', (value) => { - if (value) { - return callbacks.add('afterSaveMessage', (message) => OEmbed.rocketUrlParser(message), callbacks.priority.LOW, 'API_Embed'); - } - return callbacks.remove('afterSaveMessage', 'API_Embed'); -}); - -export { OEmbed }; diff --git a/apps/meteor/app/oembed/server/providers.ts b/apps/meteor/server/services/messages/lib/oembed/providers.ts similarity index 67% rename from apps/meteor/app/oembed/server/providers.ts rename to apps/meteor/server/services/messages/lib/oembed/providers.ts index 5feb6b8ff078c..cb6dfc7cdb013 100644 --- a/apps/meteor/app/oembed/server/providers.ts +++ b/apps/meteor/server/services/messages/lib/oembed/providers.ts @@ -1,10 +1,9 @@ import type { OEmbedMeta, OEmbedUrlContent, OEmbedProvider } from '@rocket.chat/core-typings'; import { camelCase } from 'change-case'; -import { callbacks } from '../../../server/lib/callbacks'; -import { SystemLogger } from '../../../server/lib/logger/system'; -import { settings } from '../../settings/server'; -import { Info } from '../../utils/rocketchat.info'; +import { settings } from '../../../../../app/settings/server'; +import { Info } from '../../../../../app/utils/rocketchat.info'; +import { SystemLogger } from '../../../../lib/logger/system'; class Providers { private providers: OEmbedProvider[]; @@ -101,32 +100,32 @@ providers.registerProvider({ endPoint: 'https://www.loom.com/v1/oembed?format=json', }); -callbacks.add( - 'oembed:beforeGetUrlContent', - (data) => { - if (!data.urlObj) { - return data; - } +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); + const url = data.urlObj.toString(); + const provider = providers.getProviderForUrl(url); - if (!provider) { - return data; - } + if (!provider) { + return data; + } - const consumerUrl = Providers.getConsumerUrl(provider, url); + const consumerUrl = Providers.getConsumerUrl(provider, url); - const headerOverrides = Providers.getCustomHeaders(provider); - if (!consumerUrl) { - return { ...data, headerOverrides }; - } + const headerOverrides = Providers.getCustomHeaders(provider); + if (!consumerUrl) { + return { ...data, headerOverrides }; + } - return { ...data, headerOverrides, urlObj: new URL(consumerUrl) }; - }, - callbacks.priority.MEDIUM, - 'oembed-providers-before', -); + return { ...data, headerOverrides, urlObj: new URL(consumerUrl) }; +}; const cleanupOembed = (data: { url: string; @@ -135,7 +134,7 @@ const cleanupOembed = (data: { content: OEmbedUrlContent; }): { url: string; - meta: Omit; + meta: OEmbedMeta; headers: { [k: string]: string }; content: OEmbedUrlContent; } => { @@ -148,37 +147,42 @@ const cleanupOembed = (data: { return { ...data, - meta, + meta: meta as OEmbedMeta, }; }; -callbacks.add( - 'oembed:afterParseContent', - (data) => { - if (!data?.url || !data.content?.body) { - return cleanupOembed(data); - } +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); + const provider = providers.getProviderForUrl(data.url); - if (!provider) { - return cleanupOembed(data); - } + 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 (error) { - SystemLogger.error(error); - } - return data; - }, - callbacks.priority.MEDIUM, - 'oembed-providers-after', -); + 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 (error) { + SystemLogger.error(error); + } + return data; +}; diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index df64348d00a99..30dbe92b518ca 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -1,9 +1,11 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import type { IMessageService } from '@rocket.chat/core-services'; import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services'; -import { type IMessage, type MessageTypesValues, type IUser, type IRoom, isEditedMessage, type AtLeast } from '@rocket.chat/core-typings'; +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 { deleteMessage } from '../../../app/lib/server/functions/deleteMessage'; import { sendMessage } from '../../../app/lib/server/functions/sendMessage'; import { updateMessage } from '../../../app/lib/server/functions/updateMessage'; @@ -253,6 +255,17 @@ export class MessageService extends ServiceClassInternal implements IMessageServ 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 { + 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 customDomains = settings.get('Message_CustomDomain_AutoLink') ? settings @@ -308,4 +321,11 @@ export class MessageService extends ServiceClassInternal implements IMessageServ throw new FederationMatrixInvalidConfigurationError('Unable to delete message'); } } + + async parseOEmbedUrl(url: string): Promise<{ + urlPreview: MessageUrl; + foundMeta: boolean; + }> { + return OEmbed.parseUrl(url); + } } diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 520af87d2333a..85a829a6f06e1 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -166,6 +166,10 @@ export const createMessageSettings = () => await this.add('API_EmbedSafePorts', '80, 443', { type: 'string', }); + await this.add('API_EmbedTimeout', 10, { + type: 'int', + enableQuery: { _id: 'API_Embed', value: true }, + }); await this.add('Message_TimeFormat', 'LT', { type: 'string', public: true, diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 87242f88b058e..d7012f1347055 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -5,6 +5,7 @@ import { expect } from 'chai'; import { after, before, beforeEach, describe, it } from 'mocha'; import type { Response } from 'supertest'; +import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials, apiUrl } from '../../data/api-data'; import { followMessage, sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; @@ -1284,6 +1285,9 @@ describe('[Chat]', () => { msgId = res.body.message._id; }); + // process is async now so wait for a sec + await sleep(1000); + await request .get(api('chat.getMessage')) .set(credentials) diff --git a/packages/core-services/src/types/IMessageService.ts b/packages/core-services/src/types/IMessageService.ts index c9490e81bb01e..4be232027707c 100644 --- a/packages/core-services/src/types/IMessageService.ts +++ b/packages/core-services/src/types/IMessageService.ts @@ -1,4 +1,4 @@ -import type { IMessage, MessageTypesValues, IUser, IRoom, AtLeast } from '@rocket.chat/core-typings'; +import type { IMessage, MessageTypesValues, IUser, IRoom, AtLeast, MessageUrl } from '@rocket.chat/core-typings'; export interface IMessageService { sendMessage({ fromId, rid, msg }: { fromId: string; rid: string; msg: string }): Promise; @@ -49,4 +49,9 @@ export interface IMessageService { reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise; beforeReacted(message: IMessage, room: AtLeast): Promise; beforeDelete(message: IMessage, room: IRoom): Promise; + afterSave(param: { message: IMessage }): Promise; + parseOEmbedUrl(url: string): Promise<{ + urlPreview: MessageUrl; + foundMeta: boolean; + }>; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9ae2d019be0ea..02ae126a8d8cc 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -107,6 +107,7 @@ "API_EmbedSafePorts_Description": "Comma-separated list of ports allowed for previewing.", "API_Embed_Description": "Whether embedded link previews are enabled or not when a user posts a link to a website.", "API_Embed_UserAgent": "Embed Request User Agent", + "API_EmbedTimeout": "Embed Request default timeout (in seconds)", "API_Enable_CORS": "Enable CORS", "API_Enable_Direct_Message_History_EndPoint": "Enable Direct Message History Endpoint", "API_Enable_Direct_Message_History_EndPoint_Description": "This enables the `/api/v1/im.messages.others` which allows the viewing of direct messages sent by other users that the caller is not part of.",