/* Spacebar: A FOSS re-implementation and extension of the Discord.com backend. Copyright (C) 2023 Spacebar and Spacebar Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ import { Config, Embed, EmbedImage, EmbedType } from "@spacebar/util"; import * as cheerio from "cheerio"; import crypto from "crypto"; import fetch, { RequestInit } from "node-fetch"; import { yellow } from "picocolors"; import probe from "probe-image-size"; export const DEFAULT_FETCH_OPTIONS: RequestInit = { redirect: "follow", follow: 1, headers: { "user-agent": "Mozilla/5.0 (compatible; Spacebar/1.0; +https://github.com/spacebarchat/server)", }, // size: 1024 * 1024 * 5, // grabbed from config later compress: true, method: "GET", }; const makeEmbedImage = ( url: string | undefined, width: number | undefined, height: number | undefined, ): Required | undefined => { if (!url || !width || !height) return undefined; return { url, width, height, proxy_url: getProxyUrl(new URL(url), width, height), }; }; let hasWarnedAboutImagor = false; export const getProxyUrl = ( url: URL, width: number, height: number, ): string => { const { resizeWidthMax, resizeHeightMax, imagorServerUrl } = Config.get().cdn; const secret = Config.get().security.requestSignature; width = Math.min(width || 500, resizeWidthMax || width); height = Math.min(height || 500, resizeHeightMax || width); // Imagor if (imagorServerUrl) { const path = `${width}x${height}/${url.host}${url.pathname}`; const hash = crypto .createHmac("sha1", secret) .update(path) .digest("base64") .replace(/\+/g, "-") .replace(/\//g, "_"); return `${imagorServerUrl}/${hash}/${path}`; } if (!hasWarnedAboutImagor) { hasWarnedAboutImagor = true; console.log( "[Embeds]", yellow( "Imagor has not been set up correctly. https://docs.spacebar.chat/setup/server/configuration/imagor/", ), ); } return url.toString(); }; const getMeta = ($: cheerio.CheerioAPI, name: string): string | undefined => { let elem = $(`meta[property="${name}"]`); if (!elem.length) elem = $(`meta[name="${name}"]`); const ret = elem.attr("content") || elem.text(); return ret.trim().length == 0 ? undefined : ret; }; const tryParseInt = (str: string | undefined) => { if (!str) return undefined; try { return parseInt(str); } catch (e) { return undefined; } }; export const getMetaDescriptions = (text: string) => { const $ = cheerio.load(text); return { type: getMeta($, "og:type"), title: getMeta($, "og:title") || $("title").first().text(), provider_name: getMeta($, "og:site_name"), author: getMeta($, "article:author"), description: getMeta($, "og:description") || getMeta($, "description"), image: getMeta($, "og:image") || getMeta($, "twitter:image"), image_fallback: $(`image`).attr("src"), video_fallback: $(`video`).attr("src"), width: tryParseInt(getMeta($, "og:image:width")), height: tryParseInt(getMeta($, "og:image:height")), url: getMeta($, "og:url"), youtube_embed: getMeta($, "og:video:secure_url"), $, }; }; const doFetch = async (url: URL) => { try { return await fetch(url, { ...DEFAULT_FETCH_OPTIONS, size: Config.get().limits.message.maxEmbedDownloadSize, }); } catch (e) { return null; } }; const genericImageHandler = async (url: URL): Promise => { const type = await fetch(url, { ...DEFAULT_FETCH_OPTIONS, method: "HEAD", }); let image; if (type.headers.get("content-type")?.indexOf("image") !== -1) { const result = await probe(url.href); image = makeEmbedImage(url.href, result.width, result.height); } else if (type.headers.get("content-type")?.indexOf("video") !== -1) { // TODO return null; } else { // have to download the page, unfortunately const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); image = makeEmbedImage( metas.image || metas.image_fallback, metas.width, metas.height, ); } if (!image) return null; return { url: url.href, type: EmbedType.image, thumbnail: image, }; }; export const EmbedHandlers: { [key: string]: (url: URL) => Promise; } = { // the url does not have a special handler default: async (url: URL) => { const type = await fetch(url, { ...DEFAULT_FETCH_OPTIONS, method: "HEAD", }); if (type.headers.get("content-type")?.indexOf("image") !== -1) return await genericImageHandler(url); const response = await doFetch(url); if (!response) return null; const text = await response.text(); const metas = getMetaDescriptions(text); // TODO: handle video if (!metas.image) metas.image = metas.image_fallback; let image: Required | undefined; if (metas.image && (!metas.width || !metas.height)) { const result = await probe(metas.image); image = makeEmbedImage(metas.image, result.width, result.height); } if (!image && (!metas.title || !metas.description)) { // we don't have any content to display return null; } let embedType = EmbedType.link; if (metas.type == "article") embedType = EmbedType.article; if (metas.type == "object") embedType = EmbedType.article; // github if (metas.type == "rich") embedType = EmbedType.rich; return { url: url.href, type: embedType, title: metas.title, thumbnail: image, description: metas.description, }; }, "giphy.com": genericImageHandler, "media4.giphy.com": genericImageHandler, "tenor.com": genericImageHandler, "c.tenor.com": genericImageHandler, "media.tenor.com": genericImageHandler, "facebook.com": (url) => EmbedHandlers["www.facebook.com"](url), "www.facebook.com": async (url: URL) => { const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); return { url: url.href, type: EmbedType.link, title: metas.title, description: metas.description, thumbnail: makeEmbedImage(metas.image, 640, 640), color: 16777215, }; }, "twitter.com": (url) => EmbedHandlers["www.twitter.com"](url), "www.twitter.com": async (url: URL) => { const token = Config.get().external.twitter; if (!token) return null; if (!url.href.includes("/status/")) return null; // TODO; const id = url.pathname.split("/")[3]; // super bad lol if (!parseInt(id)) return null; const endpointUrl = `https://api.twitter.com/2/tweets/${id}` + `?expansions=author_id,attachments.media_keys` + `&media.fields=url,width,height` + `&tweet.fields=created_at,public_metrics` + `&user.fields=profile_image_url`; const response = await fetch(endpointUrl, { ...DEFAULT_FETCH_OPTIONS, headers: { authorization: `Bearer ${token}`, }, }); const json = await response.json(); if (json.errors) return null; const author = json.includes.users[0]; const text = json.data.text; const created_at = new Date(json.data.created_at); const metrics = json.data.public_metrics; const media = json.includes.media?.filter( (x: { type: string }) => x.type == "photo", ); const embed: Embed = { type: EmbedType.rich, url: `${url.origin}${url.pathname}`, description: text, author: { url: `https://twitter.com/${author.username}`, name: `${author.name} (@${author.username})`, proxy_icon_url: getProxyUrl( new URL(author.profile_image_url), 400, 400, ), icon_url: author.profile_image_url, }, timestamp: created_at, fields: [ { inline: true, name: "Likes", value: metrics.like_count.toString(), }, { inline: true, name: "Retweet", value: metrics.retweet_count.toString(), }, ], color: 1942002, footer: { text: "Twitter", proxy_icon_url: getProxyUrl( new URL( "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", ), 192, 192, ), icon_url: "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", }, // Discord doesn't send this? // provider: { // name: "Twitter", // url: "https://twitter.com" // }, }; if (media && media.length > 0) { embed.image = { width: media[0].width, height: media[0].height, url: media[0].url, proxy_url: getProxyUrl( new URL(media[0].url), media[0].width, media[0].height, ), }; media.shift(); } return embed; // TODO: Client won't merge these into a single embed, for some reason. // return [embed, ...media.map((x: any) => ({ // // generate new embeds for each additional attachment // type: EmbedType.rich, // url: url.href, // image: { // width: x.width, // height: x.height, // url: x.url, // proxy_url: getProxyUrl(new URL(x.url), x.width, x.height) // } // }))]; }, "open.spotify.com": async (url: URL) => { const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); return { url: url.href, type: EmbedType.link, title: metas.title, description: metas.description, thumbnail: makeEmbedImage(metas.image, 640, 640), provider: { url: "https://spotify.com", name: "Spotify", }, }; }, // TODO: docs: Pixiv won't work without Imagor "pixiv.net": (url) => EmbedHandlers["www.pixiv.net"](url), "www.pixiv.net": async (url: URL) => { const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); if (!metas.image) return null; return { url: url.href, type: EmbedType.image, title: metas.title, description: metas.description, image: makeEmbedImage( metas.image || metas.image_fallback, metas.width, metas.height, ), provider: { url: "https://pixiv.net", name: "Pixiv", }, }; }, "store.steampowered.com": async (url: URL) => { const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); const numReviews = metas.$("#review_summary_num_reviews").val() as | string | undefined; const price = metas .$(".game_purchase_price.price") .data("price-final") as number | undefined; const releaseDate = metas .$(".release_date") .find("div.date") .text() .trim(); const isReleased = new Date(releaseDate) < new Date(); const fields: Embed["fields"] = []; if (numReviews) fields.push({ name: "Reviews", value: numReviews, inline: true, }); if (price) fields.push({ name: "Price", value: `$${price / 100}`, inline: true, }); // if the release date is in the past, it's already out if (releaseDate && !isReleased) fields.push({ name: "Release Date", value: releaseDate, inline: true, }); return { url: url.href, type: EmbedType.rich, title: metas.title, description: metas.description, image: { // TODO: meant to be thumbnail. // isn't this standard across all of steam? width: 460, height: 215, url: metas.image, proxy_url: metas.image ? getProxyUrl(new URL(metas.image), 460, 215) : undefined, }, provider: { url: "https://store.steampowered.com", name: "Steam", }, fields, // TODO: Video }; }, "reddit.com": (url) => EmbedHandlers["www.reddit.com"](url), "www.reddit.com": async (url: URL) => { const res = await EmbedHandlers["default"](url); return { ...res, color: 16777215, provider: { name: "reddit", }, }; }, "youtu.be": (url) => EmbedHandlers["www.youtube.com"](url), "youtube.com": (url) => EmbedHandlers["www.youtube.com"](url), "www.youtube.com": async (url: URL): Promise => { const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); return { video: makeEmbedImage( metas.youtube_embed, metas.width, metas.height, ), url: url.href, type: metas.youtube_embed ? EmbedType.video : EmbedType.link, title: metas.title, thumbnail: makeEmbedImage( metas.image || metas.image_fallback, metas.width, metas.height, ), provider: { url: "https://www.youtube.com", name: "YouTube", }, description: metas.description, color: 16711680, author: metas.author ? { name: metas.author, // TODO: author channel url } : undefined, }; }, "www.xkcd.com": (url) => EmbedHandlers["xkcd.com"](url), "xkcd.com": async (url) => { const response = await doFetch(url); if (!response) return null; const metas = getMetaDescriptions(await response.text()); const hoverText = metas.$("#comic img").attr("title"); if (!metas.image) return null; const { width, height } = await probe(metas.image); return { url: url.href, type: EmbedType.rich, title: `xkcd: ${metas.title}`, image: makeEmbedImage(metas.image, width, height), footer: hoverText ? { text: hoverText, } : undefined, }; }, // the url is an image from this instance self: async (url: URL): Promise => { 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, }, }; }, };