diff --git a/.changeset/lucky-bikes-enjoy.md b/.changeset/lucky-bikes-enjoy.md new file mode 100644 index 0000000000000..b616afebba83a --- /dev/null +++ b/.changeset/lucky-bikes-enjoy.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +--- + +Added `chat.getURLPreview` endpoint to enable users to retrieve previews for URL (ready to be provided in message send/update) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 75fc98e69c4d8..98fc278594aeb 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,7 +1,7 @@ import { Message } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; -import { isChatReportMessageProps } from '@rocket.chat/rest-typings'; +import { isChatReportMessageProps, isChatGetURLPreviewProps } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -15,6 +15,7 @@ import { deleteMessageValidatingPermission } from '../../../lib/server/functions import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; +import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -822,3 +823,22 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'chat.getURLPreview', + { authRequired: true, validateParams: isChatGetURLPreviewProps }, + { + async get() { + const { roomId, url } = this.queryParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const { urlPreview } = await OEmbed.parseUrl(url); + urlPreview.ignoreParse = true; + + return API.v1.success({ urlPreview }); + }, + }, +); diff --git a/apps/meteor/app/oembed/server/server.ts b/apps/meteor/app/oembed/server/server.ts index 79de0402043f5..1e758b1371e87 100644 --- a/apps/meteor/app/oembed/server/server.ts +++ b/apps/meteor/app/oembed/server/server.ts @@ -1,5 +1,12 @@ -import type { OEmbedUrlContentResult, OEmbedUrlWithMetadata, IMessage, MessageAttachment, OEmbedMeta } from '@rocket.chat/core-typings'; -import { isOEmbedUrlContentResult, isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; +import type { + OEmbedUrlContentResult, + OEmbedUrlWithMetadata, + IMessage, + MessageAttachment, + OEmbedMeta, + MessageUrl, +} 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 { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -128,6 +135,41 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise { + const parsedUrlObject: MessageUrl = { url, meta: {} }; + let foundMeta = false; + if (!isURL(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, @@ -151,10 +193,6 @@ const getUrlMeta = async function ( return; } - if (content.attachments) { - return content; - } - log.debug('Parsing metadata for URL', url); const metas: { [k: string]: string } = {}; @@ -273,37 +311,10 @@ const rocketUrlParser = async function (message: IMessage): Promise { continue; } - if (!isURL(item.url)) { - continue; - } - - const data = await getUrlMetaWithCache(item.url); - - if (!data) { - continue; - } - - if (isOEmbedUrlContentResult(data) && data.attachments) { - attachments.push(...data.attachments); - break; - } - - if (isOEmbedUrlWithMetadata(data) && data.meta) { - item.meta = getRelevantMetaTags(data.meta) || {}; - if (item.meta?.oembedHtml) { - item.meta.oembedHtml = insertMaxWidthInOembedHtml(item.meta.oembedHtml) || ''; - } - } - - if (data.headers?.contentLength) { - item.headers = { ...item.headers, contentLength: data.headers.contentLength }; - } - - if (data.headers?.contentType) { - item.headers = { ...item.headers, contentType: data.headers.contentType }; - } + const { urlPreview, foundMeta } = await parseUrl(item.url); - changed = true; + Object.assign(item, foundMeta ? urlPreview : {}); + changed = changed || foundMeta; } if (attachments.length) { @@ -321,10 +332,12 @@ const OEmbed: { getUrlMeta: (url: string, withFragment?: boolean) => Promise; getUrlMetaWithCache: (url: string, withFragment?: boolean) => Promise; rocketUrlParser: (message: IMessage) => Promise; + parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>; } = { rocketUrlParser, getUrlMetaWithCache, getUrlMeta, + parseUrl, }; settings.watch('API_Embed', (value) => { diff --git a/apps/meteor/tests/end-to-end/api/05-chat.js b/apps/meteor/tests/end-to-end/api/05-chat.js index a41d78dd7bf64..0fa52cf3392dc 100644 --- a/apps/meteor/tests/end-to-end/api/05-chat.js +++ b/apps/meteor/tests/end-to-end/api/05-chat.js @@ -3105,4 +3105,71 @@ describe('Threads', () => { }); }); }); + + describe('[/chat.getURLPreview]', () => { + const url = 'https://www.youtube.com/watch?v=no050HN4ojo'; + it('should return the URL preview with metadata and headers', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'GENERAL', + url, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('urlPreview').and.to.be.an('object').that.is.not.empty; + expect(res.body.urlPreview).to.have.property('url', url); + expect(res.body.urlPreview).to.have.property('headers').and.to.be.an('object').that.is.not.empty; + }); + }); + + describe('when an error occurs', () => { + it('should return statusCode 400 and an error when "roomId" is not provided', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + url, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + it('should return statusCode 400 and an error when "url" is not provided', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + it('should return statusCode 400 and an error when "roomId" is provided but user is not in the room', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'undefined', + url, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + }); + }); + }); + }); }); diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 190446502f022..8b76d76f1b41a 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -13,11 +13,11 @@ import type { IUser } from '../IUser'; import type { FileProp } from './MessageAttachment/Files/FileProp'; import type { MessageAttachment } from './MessageAttachment/MessageAttachment'; -type MessageUrl = { +export type MessageUrl = { url: string; source?: string; meta: Record; - headers?: { contentLength: string } | { contentType: string } | { contentLength: string; contentType: string }; + headers?: { contentLength?: string; contentType?: string }; ignoreParse?: boolean; parsedUrl?: Pick; }; diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index c29e420a47f35..0af9fabced582 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, MessageAttachment, ReadReceipt, OtrSystemMessages } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, MessageAttachment, ReadReceipt, OtrSystemMessages, MessageUrl } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -789,6 +789,27 @@ const ChatPostMessageSchema = { export const isChatPostMessageProps = ajv.compile(ChatPostMessageSchema); +type ChatGetURLPreview = { + roomId: IRoom['_id']; + url: string; +}; + +const ChatGetURLPreviewSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + url: { + type: 'string', + }, + }, + required: ['roomId', 'url'], + additionalProperties: false, +}; + +export const isChatGetURLPreviewProps = ajv.compile(ChatGetURLPreviewSchema); + export type ChatEndpoints = { '/v1/chat.sendMessage': { POST: (params: ChatSendMessage) => { @@ -935,4 +956,7 @@ export type ChatEndpoints = { '/v1/chat.otr': { POST: (params: { roomId: string; type: OtrSystemMessages }) => void; }; + '/v1/chat.getURLPreview': { + GET: (params: ChatGetURLPreview) => { urlPreview: MessageUrl }; + }; };