diff options
author | Madeline <46743919+MaddyUnderStars@users.noreply.github.com> | 2022-10-01 14:44:32 +1000 |
---|---|---|
committer | Madeline <46743919+MaddyUnderStars@users.noreply.github.com> | 2022-10-01 14:44:32 +1000 |
commit | aef662521c1e6a607f6c4848f7c6af96da8900b6 (patch) | |
tree | 25d6256d4e8ae4496bc0f68c42a66fb20cbb8806 | |
parent | Make `afk` optional in ActivitySchema (diff) | |
download | server-aef662521c1e6a607f6c4848f7c6af96da8900b6.tar.xz |
Better embed handling
-rw-r--r-- | package-lock.json | 140 | ||||
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/api/util/handlers/Message.ts | 143 | ||||
-rw-r--r-- | src/api/util/index.ts | 1 | ||||
-rw-r--r-- | src/api/util/utility/EmbedHandlers.ts | 140 |
5 files changed, 298 insertions, 128 deletions
diff --git a/package-lock.json b/package-lock.json index 4b19a8bf..5f7abc85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "node-fetch": "^2.6.7", "node-os-utils": "^1.3.7", "picocolors": "^1.0.0", + "probe-image-size": "^7.2.3", "proxy-agent": "^5.0.0", "sharp": "^0.31.0", "sqlite3": "^5.1.1", @@ -57,6 +58,7 @@ "@types/node": "^18.7.20", "@types/node-fetch": "^2.6.2", "@types/node-os-utils": "^1.3.0", + "@types/probe-image-size": "^7.2.0", "@types/sharp": "^0.31.0", "@types/ws": "^8.5.3", "express": "^4.18.1", @@ -1710,6 +1712,15 @@ "@types/express": "*" } }, + "node_modules/@types/needle": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/needle/-/needle-2.5.3.tgz", + "integrity": "sha512-RwgTwMRaedfyCBe5SSWMpm1Yqzc5UPZEMw0eAd09OSyV93nLRj9/evMGZmgFeHKzUOd4xxtHvgtc+rjcBjI1Qg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "18.7.20", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.20.tgz", @@ -1753,6 +1764,16 @@ "@types/node": "*" } }, + "node_modules/@types/probe-image-size": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/probe-image-size/-/probe-image-size-7.2.0.tgz", + "integrity": "sha512-R5H3vw62gHNHrn+JGZbKejb+Z2D/6E5UNVlhCzIaBBLroMQMOFqy5Pap2gM+ZZHdqBtVU0/cx/M6to+mOJcoew==", + "dev": true, + "dependencies": { + "@types/needle": "*", + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3892,6 +3913,11 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -4295,6 +4321,35 @@ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/needle/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -4731,6 +4786,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/probe-image-size": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", + "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -5341,6 +5406,14 @@ "node": ">= 0.8" } }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "dependencies": { + "debug": "2" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -7541,6 +7614,15 @@ "@types/express": "*" } }, + "@types/needle": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/needle/-/needle-2.5.3.tgz", + "integrity": "sha512-RwgTwMRaedfyCBe5SSWMpm1Yqzc5UPZEMw0eAd09OSyV93nLRj9/evMGZmgFeHKzUOd4xxtHvgtc+rjcBjI1Qg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "18.7.20", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.20.tgz", @@ -7583,6 +7665,16 @@ "@types/node": "*" } }, + "@types/probe-image-size": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/probe-image-size/-/probe-image-size-7.2.0.tgz", + "integrity": "sha512-R5H3vw62gHNHrn+JGZbKejb+Z2D/6E5UNVlhCzIaBBLroMQMOFqy5Pap2gM+ZZHdqBtVU0/cx/M6to+mOJcoew==", + "dev": true, + "requires": { + "@types/needle": "*", + "@types/node": "*" + } + }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -9221,6 +9313,11 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -9542,6 +9639,31 @@ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, + "needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -9868,6 +9990,16 @@ "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", "dev": true }, + "probe-image-size": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", + "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", + "requires": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -10325,6 +10457,14 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, + "stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "requires": { + "debug": "2" + } + }, "streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/package.json b/package.json index 9c86f918..303324f5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/node": "^18.7.20", "@types/node-fetch": "^2.6.2", "@types/node-os-utils": "^1.3.0", + "@types/probe-image-size": "^7.2.0", "@types/sharp": "^0.31.0", "@types/ws": "^8.5.3", "express": "^4.18.1", @@ -72,6 +73,7 @@ "node-fetch": "^2.6.7", "node-os-utils": "^1.3.7", "picocolors": "^1.0.0", + "probe-image-size": "^7.2.3", "proxy-agent": "^5.0.0", "sharp": "^0.31.0", "sqlite3": "^5.1.1", diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index c0bdb6b0..09b86fc2 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -24,9 +24,8 @@ import { MessageCreateSchema, } from "@fosscord/util"; import { HTTPError } from "lambert-server"; -import fetch from "node-fetch"; -import cheerio from "cheerio"; import { In } from "typeorm"; +import { EmbedHandlers } from "@fosscord/api"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images @@ -34,18 +33,6 @@ const allow_empty = false; const LINK_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g; -const DEFAULT_FETCH_OPTIONS: any = { - redirect: "follow", - follow: 1, - headers: { - "user-agent": - "Mozilla/5.0 (compatible; Fosscord/1.0; +https://github.com/fosscord/fosscord)", - }, - // size: 1024 * 1024 * 5, // grabbed from config later - compress: true, - method: "GET", -}; - export async function handleMessage(opts: MessageOptions): Promise<Message> { const channel = await Channel.findOneOrFail({ where: { id: opts.channel_id }, @@ -200,124 +187,24 @@ export async function postHandleMessage(message: Message) { links = links.slice(0, 20) as RegExpMatchArray; // embed max 20 links — TODO: make this configurable with instance policies - const { endpointPublic, resizeWidthMax, resizeHeightMax } = - Config.get().cdn; - for (const link of links) { - try { - const request = await fetch(link, { - ...DEFAULT_FETCH_OPTIONS, - size: Config.get().limits.message.maxEmbedDownloadSize, - }); - - let embed: Embed; - - const type = request.headers.get("content-type"); - if (type?.indexOf("image") == 0) { - embed = { - provider: { - url: link, - name: new URL(link).hostname, - }, - image: { - // can't be bothered rn - proxy_url: `${endpointPublic}/external/resize/${encodeURIComponent( - link, - )}?width=500&height=400`, - url: link, - width: 500, - height: 400, - }, - }; - data.embeds.push(embed); - } else { - const text = await request.text(); - const $ = cheerio.load(text); - - const title = $('meta[property="og:title"]').attr("content"); - const provider_name = $('meta[property="og:site_name"]').text(); - const author_name = $('meta[property="article:author"]').attr( - "content", - ); - const description = - $('meta[property="og:description"]').attr("content") || - $('meta[property="description"]').attr("content"); + let embed: Embed; + const url = new URL(link); - const image = $('meta[property="og:image"]').attr("content"); - const width = - parseInt( - $('meta[property="og:image:width"]').attr("content") || - "", - ) || undefined; - const height = - parseInt( - $('meta[property="og:image:height"]').attr("content") || - "", - ) || undefined; + // bit gross, but whatever! + const { endpointPublic } = Config.get().cdn; + const handler = url.hostname == new URL(endpointPublic!).hostname ? EmbedHandlers["self"] : EmbedHandlers[url.hostname] || EmbedHandlers["default"]; - const url = $('meta[property="og:url"]').attr("content"); - // TODO: color - embed = { - provider: { - url: link, - name: provider_name, - }, - }; - - const resizeWidth = Math.min(resizeWidthMax ?? 1, width ?? 100); - const resizeHeight = Math.min( - resizeHeightMax ?? 1, - height ?? 100, - ); - if (author_name) embed.author = { name: author_name }; - if (image) - embed.thumbnail = { - proxy_url: `${endpointPublic}/external/resize/${encodeURIComponent( - image, - )}?width=${resizeWidth}&height=${resizeHeight}`, - url: image, - width: width, - height: height, - }; - if (title) embed.title = title; - if (url) embed.url = url; - if (description) embed.description = description; - - const approvedProviders = [ - "media4.giphy.com", - "c.tenor.com", - // todo: make configurable? don't really care tho - ]; - - // very bad code below - // don't care lol - if ( - embed?.thumbnail?.url && - approvedProviders.indexOf( - new URL(embed.thumbnail.url).hostname, - ) !== -1 - ) { - embed = { - provider: { - url: link, - name: new URL(link).hostname, - }, - image: { - proxy_url: `${endpointPublic}/external/resize/${encodeURIComponent( - image!, - )}?width=${resizeWidth}&height=${resizeHeight}`, - url: image, - width: width, - height: height, - }, - }; - } + try { + const res = await handler(url); + if (!res) continue; + embed = res; + } + catch (e) { + continue; + } - if (title || description) { - data.embeds.push(embed); - } - } - } catch (error) { } + data.embeds.push(embed); } await Promise.all([ diff --git a/src/api/util/index.ts b/src/api/util/index.ts index 9f375f72..ffad0607 100644 --- a/src/api/util/index.ts +++ b/src/api/util/index.ts @@ -7,3 +7,4 @@ export * from "./handlers/route"; export * from "./utility/String"; export * from "./handlers/Voice"; export * from "./utility/captcha"; +export * from "./utility/EmbedHandlers"; \ No newline at end of file diff --git a/src/api/util/utility/EmbedHandlers.ts b/src/api/util/utility/EmbedHandlers.ts new file mode 100644 index 00000000..d6b69d86 --- /dev/null +++ b/src/api/util/utility/EmbedHandlers.ts @@ -0,0 +1,140 @@ +import { Config, Embed, EmbedType } from "@fosscord/util"; +import fetch, { Response } from "node-fetch"; +import * as cheerio from "cheerio"; +import probe from "probe-image-size"; + +export const DEFAULT_FETCH_OPTIONS: any = { + redirect: "follow", + follow: 1, + headers: { + "user-agent": + "Mozilla/5.0 (compatible; Fosscord/1.0; +https://github.com/fosscord/fosscord)", + }, + // size: 1024 * 1024 * 5, // grabbed from config later + compress: true, + method: "GET", +}; + +export const getProxyUrl = (url: URL, width: number, height: number) => { + const { endpointPublic, resizeWidthMax, resizeHeightMax } = Config.get().cdn; + width = Math.min(width || 500, resizeWidthMax || width); + height = Math.min(height || 500, resizeHeightMax || width); + return `${endpointPublic}/external/resize/${encodeURIComponent(url.href)}?width=${width}&height=${height}`; +}; + +export const getMetaDescriptions = async (url: URL) => { + let response: Response; + try { + response = await fetch(url, { + ...DEFAULT_FETCH_OPTIONS, + size: Config.get().limits.message.maxEmbedDownloadSize, + }); + } + catch (e) { + return null; + } + + const text = await response.text(); + const $ = cheerio.load(text); + + return { + title: $('meta[property="og:title"]').attr("content"), + provider_name: $('meta[property="og:site_name"]').text(), + author: $('meta[property="article:author"]').attr("content"), + description: + $('meta[property="og:description"]').attr("content") || + $('meta[property="description"]').attr("content"), + image: $('meta[property="og:image"]').attr("content"), + width: parseInt( + $('meta[property="og:image:width"]').attr("content") || + "", + ) || undefined, + height: parseInt( + $('meta[property="og:image:height"]').attr("content") || + "", + ) || undefined, + url: $('meta[property="og:url"]').attr("content"), + youtube_embed: $(`meta[property="og:video:secure_url"]`).attr("content") + }; +}; + +const genericImageHandler = async (url: URL): Promise<Embed | null> => { + const metas = await getMetaDescriptions(url); + if (!metas) return null; + + const result = await probe(url.href); + + const width = metas.width || result.width; + const height = metas.height || result.height; + + return { + url: url.href, + type: EmbedType.image, + thumbnail: { + width: width, + height: height, + url: url.href, + proxy_url: getProxyUrl(url, result.width, result.height), + } + }; +}; + +export const EmbedHandlers: { [key: string]: (url: URL) => Promise<Embed | null>; } = { + // the url does not have a special handler + "default": genericImageHandler, + + "giphy.com": genericImageHandler, + "media4.giphy.com": genericImageHandler, + "tenor.com": genericImageHandler, + "c.tenor.com": genericImageHandler, + "media.tenor.com": genericImageHandler, + + "www.youtube.com": async (url: URL): Promise<Embed | null> => { + const metas = await getMetaDescriptions(url); + if (!metas) return null; + + return { + video: { + // TODO: does this adjust with aspect ratio? + width: metas.width, + height: metas.height, + url: metas.youtube_embed!, + }, + url: url.href, + type: EmbedType.video, + title: metas.title, + thumbnail: { + width: metas.width, + height: metas.height, + url: metas.image, + proxy_url: getProxyUrl(new URL(metas.image!), metas.width!, metas.height!), + }, + provider: { + url: "https://www.youtube.com", + name: "YouTube", + }, + description: metas.description, + color: 16711680, + author: { + name: metas.author, + // TODO: author channel url + } + }; + }, + + // the url is an image from this instance + "self": async (url: URL): Promise<Embed | null> => { + const result = await probe(url.href); + + return { + url: url.href, + type: EmbedType.image, + thumbnail: { + width: result.width, + height: result.height, + url: url.href, + proxy_url: url.href, + } + }; + }, +}; \ No newline at end of file |