Skip to content

Commit

Permalink
feat: Add chat.getURLPreview endpoint (#30478)
Browse files Browse the repository at this point in the history
  • Loading branch information
matheusbsilva137 committed Jan 19, 2024
1 parent 6ca6871 commit fdd9852
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 40 deletions.
7 changes: 7 additions & 0 deletions .changeset/lucky-bikes-enjoy.md
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 21 additions & 1 deletion apps/meteor/app/api/server/v1/chat.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 });
},
},
);
85 changes: 49 additions & 36 deletions apps/meteor/app/oembed/server/server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -128,6 +135,41 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise<OEmbedUrlC
};
};

const parseUrl = async function (url: string): Promise<{ urlPreview: MessageUrl; foundMeta: boolean }> {
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,
Expand All @@ -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 } = {};

Expand Down Expand Up @@ -273,37 +311,10 @@ const rocketUrlParser = async function (message: IMessage): Promise<IMessage> {
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) {
Expand All @@ -321,10 +332,12 @@ const OEmbed: {
getUrlMeta: (url: string, withFragment?: boolean) => Promise<OEmbedUrlWithMetadata | undefined | OEmbedUrlContentResult>;
getUrlMetaWithCache: (url: string, withFragment?: boolean) => Promise<OEmbedUrlWithMetadata | OEmbedUrlContentResult | undefined>;
rocketUrlParser: (message: IMessage) => Promise<IMessage>;
parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>;
} = {
rocketUrlParser,
getUrlMetaWithCache,
getUrlMeta,
parseUrl,
};

settings.watch('API_Embed', (value) => {
Expand Down
67 changes: 67 additions & 0 deletions apps/meteor/tests/end-to-end/api/05-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
});
});
4 changes: 2 additions & 2 deletions packages/core-typings/src/IMessage/IMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
headers?: { contentLength: string } | { contentType: string } | { contentLength: string; contentType: string };
headers?: { contentLength?: string; contentType?: string };
ignoreParse?: boolean;
parsedUrl?: Pick<UrlWithStringQuery, 'host' | 'hash' | 'pathname' | 'protocol' | 'port' | 'query' | 'search' | 'hostname'>;
};
Expand Down
26 changes: 25 additions & 1 deletion packages/rest-typings/src/v1/chat.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -789,6 +789,27 @@ const ChatPostMessageSchema = {

export const isChatPostMessageProps = ajv.compile<ChatPostMessage>(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<ChatGetURLPreview>(ChatGetURLPreviewSchema);

export type ChatEndpoints = {
'/v1/chat.sendMessage': {
POST: (params: ChatSendMessage) => {
Expand Down Expand Up @@ -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 };
};
};

0 comments on commit fdd9852

Please sign in to comment.