Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Comment thread
sampaiodiego marked this conversation as resolved.

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) {
Comment thread
MarcosSpessatto marked this conversation as resolved.
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) {
Comment thread
MarcosSpessatto marked this conversation as resolved.
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 };
};
};