Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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)
28 changes: 28 additions & 0 deletions apps/meteor/app/api/server/v1/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,30 @@ API.v1.addRoute(
},
},
);

API.v1.addRoute(
'chat.getURLPreview',
{ authRequired: true },
{
async get() {
const { roomId, url } = this.queryParams;

if (!roomId) {
Comment thread
matheusbsilva137 marked this conversation as resolved.
Resolved
throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.');
}

if (!url) {
throw new Meteor.Error('error-url-param-not-provided', 'The required "url" query param is missing.');
}

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.
Resolved

return API.v1.success({ urlPreview });
},
},
);
80 changes: 44 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,36 @@ 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) {
if (isOEmbedUrlWithMetadata(data) && data.meta) {
parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {};
if (parsedUrlObject.meta?.oembedHtml) {
parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || '';
}
}

if (data.headers?.contentLength) {
parsedUrlObject.headers = { ...parsedUrlObject.headers, contentLength: data.headers.contentLength };
}

if (data.headers?.contentType) {
parsedUrlObject.headers = { ...parsedUrlObject.headers, contentType: data.headers.contentType };
Comment thread
matheusbsilva137 marked this conversation as resolved.
Resolved
}
foundMeta = true;
}

return { urlPreview: parsedUrlObject, foundMeta };
};

const getUrlMeta = async function (
url: string,
withFragment?: boolean,
Expand All @@ -151,10 +188,6 @@ const getUrlMeta = async function (
return;
}

if (content.attachments) {
Comment thread
MarcosSpessatto marked this conversation as resolved.
Resolved
return content;
}

log.debug('Parsing metadata for URL', url);
const metas: { [k: string]: string } = {};

Expand Down Expand Up @@ -273,37 +306,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.
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 +327,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('error-roomId-param-not-provided');
});
});
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('error-url-param-not-provided');
});
});
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');
});
});
});
});
});
2 changes: 1 addition & 1 deletion packages/core-typings/src/IMessage/IMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { MessageAttachment } from './MessageAttachment/MessageAttachment';

type MentionType = 'user' | 'team';

type MessageUrl = {
export type MessageUrl = {
url: string;
source?: string;
meta: Record<string, string>;
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 @@ -773,6 +773,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 @@ -919,4 +940,7 @@ export type ChatEndpoints = {
'/v1/chat.otr': {
POST: (params: { roomId: string; type: OtrSystemMessages }) => void;
};
'/v1/chat.getURLPreview': {
GET: (params: ChatGetURLPreview) => { urlPreview: MessageUrl };
};
};