diff --git a/README.md b/README.md index 97ca5cf..7b13e9f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ You need to generate a matrix id, using the algorithm as shown above. 2. mxc: `mxc://example.org/klipy_ZmZkNGFjMTQzZTYzMzVhYzY4OTUxYjc4N2QzYzE5MDIvZTgvM2EvNUxNMGpScEwuZ2lm` 3. will return a redirect to `https://static.klipy.com/ii/ffd4ac143e6335ac68951b787d3c1902/e8/3a/5LM0jRpL.gif` +## Link previews + +`GET /_soliditas/preview_url?url=[url]` answers in the shape of the Matrix `/preview_url` endpoint. Missing `og:` tags are filled from `twitter:` tags, ``, `<meta name="description">` and oEmbed. Twitter/X links are read from the [FxTwitter API](https://github.com/FxEmbed/FxEmbed), Reddit and TikTok titles from their oEmbed endpoints, Tumblr posts without an image get the [fxtumblr](https://github.com/knuxify/fxtumblr) render, and Misskey-family notes show their images from the ActivityPub object unless marked sensitive, instead of the author's avatar. + +`og:image` is returned as `mxc://[servername]/og_[base64url of the image url]`. When a post has more than one image, `com.sable.images` lists up to ten of them as `{ url, width, height }`, and `og:image` stays the single image other clients show (the FxTwitter mosaic for tweets). + ## License This project is licensed under Apache 2.0, see LICENSE. diff --git a/src/index.ts b/src/index.ts index e4e1723..ae78f09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { matrixEndpointNotImplemented, matrixInvalidParam } from './matrixError'; import { toMatrixID } from './mxcId'; +import { previewUrl } from './preview'; import { proxyMediaCall } from './proxy'; import { returnMatrixServerVers } from './serverversion'; import { MatrixWellKnownServer, SoliditasAddressConvertResponse } from './types'; @@ -37,6 +38,8 @@ export default { } else if (url.pathname.startsWith('/_matrix/federation/v1/media/download/')) { const mediaId = url.pathname.replace('/_matrix/federation/v1/media/download/', ''); return proxyMediaCall(mediaId); + } else if (url.pathname === '/_soliditas/preview_url') { + return previewUrl(url.searchParams.get('url'), serverName); } else if (url.pathname === '/_soliditas/adressconvert'){ // helper function mainly for debug reasons as it should be embeded in the client for production use const remoteType = url.searchParams.get('remoteType'); diff --git a/src/preview.ts b/src/preview.ts new file mode 100644 index 0000000..8e3aa04 --- /dev/null +++ b/src/preview.ts @@ -0,0 +1,303 @@ +/* + Apache License 2.0 + + Copyright 2026 Rye + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { matrixInvalidParam, matrixRessourceNotFound } from './matrixError'; +import { toMatrixID } from './mxcId'; + +const USER_AGENT = 'Soliditas (bot; +https://github.com/SableClient/soliditas) facebookexternalhit/1.1'; + +const CORS_HEADERS = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', +}; + +const TWITTER_HOSTS = /^(?:www\.|mobile\.)?(?:twitter|x|fxtwitter|fixupx|vxtwitter|fixvx)\.com$/; +const TWITTER_STATUS = /^\/(?:[^/]+|i\/web)\/status(?:es)?\/(\d+)/; + +const TUMBLR_BLOG_POST = /^([a-z\d-]+)\.tumblr\.com$/; +const TUMBLR_DASHBOARD_POST = /^\/([a-z\d-]+)\/(\d+)/; + +const OEMBED_ENDPOINTS: [RegExp, string][] = [ + [/(?:^|\.)reddit\.com$/, 'https://www.reddit.com/oembed'], + [/(?:^|\.)tiktok\.com$/, 'https://www.tiktok.com/oembed'], +]; + +const NAMED_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' }; + +const MAX_IMAGES = 10; + +type Tags = Record<string, string>; + +type Image = { url: string; width?: number; height?: number }; + +type Preview = { tags: Tags; images: Image[]; cover?: Image }; + +type OEmbed = { + title?: string; + author_name?: string; + provider_name?: string; + thumbnail_url?: string; + thumbnail_width?: number; + thumbnail_height?: number; +}; + +type ActivityNote = { + sensitive?: boolean; + attachment?: { mediaType?: string; url?: string; width?: number; height?: number }[]; +}; + +type FxMedia = { url?: string; thumbnail_url?: string; width?: number; height?: number }; + +type FxTweet = { + text?: string; + author?: { name?: string; screen_name?: string; avatar_url?: string }; + media?: { photos?: FxMedia[]; videos?: FxMedia[]; mosaic?: { formats?: { jpeg?: string } } }; +}; + +export function parsePublicUrl(value: string, base?: string | URL): URL | null { + let url: URL; + try { + url = new URL(value, base); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + const host = url.hostname.toLowerCase(); + if (!host.includes('.') || host.includes(':') || host.startsWith('[')) return null; + if (/^[\d.]+$/.test(host)) return null; + if (host === 'localhost' || /\.(localhost|local|internal|home\.arpa)$/.test(host)) return null; + return url; +} + +function decodeEntities(value: string): string { + return value.replace(/&(?:#x([\da-f]+)|#(\d+)|([a-z]+));/gi, (entity, hex, decimal, name) => { + const code = hex ? Number.parseInt(hex, 16) : decimal ? Number.parseInt(decimal, 10) : undefined; + if (code !== undefined) return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity; + return NAMED_ENTITIES[name.toLowerCase()] ?? entity; + }); +} + +function fetchPublic(url: URL, accept: string): Promise<Response | null> { + return fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: accept }, redirect: 'follow' }) + .then((response) => (response.ok ? response : null)) + .catch(() => null); +} + +async function fetchJson<T>(url: URL): Promise<T | null> { + const response = await fetchPublic(url, 'application/json'); + return response ? ((await response.json().catch(() => null)) as T | null) : null; +} + +function setTag(tags: Tags, key: string, value: string | number | undefined): void { + if (value === undefined || value === '' || tags[key]) return; + tags[key] = String(value); +} + +function toSize(value: string | number | undefined): number | undefined { + const size = typeof value === 'number' ? value : Number.parseInt(value ?? '', 10); + return Number.isFinite(size) && size > 0 ? size : undefined; +} + +function toImage(value: string | undefined, base?: string | URL, width?: string | number, height?: string | number): Image | null { + const url = value ? parsePublicUrl(value, base) : null; + if (!url || url.pathname === '/') return null; + return { url: url.href, width: toSize(width), height: toSize(height) }; +} + +function imageKey(image: Image): string { + const url = new URL(image.url); + if (!url.hostname.endsWith('.media.tumblr.com')) return url.href; + return url.pathname.split('/').slice(1, 3).join('/'); +} + +function addImages(images: Image[], candidates: (Image | null)[]): void { + for (const image of candidates) { + if (image && images.length < MAX_IMAGES && !images.some((known) => imageKey(known) === imageKey(image))) images.push(image); + } +} + +async function readPage(page: Response, base: string | URL): Promise<{ preview: Preview; oembed?: string; misskeyNote: boolean }> { + const tags: Tags = {}; + const fallback: Tags = {}; + const found: { url: string; width?: string; height?: string }[] = []; + let title = ''; + let oembed: string | undefined; + let misskeyNote = false; + + await new HTMLRewriter() + .on('meta', { + element(element) { + const key = element.getAttribute('property') ?? element.getAttribute('name'); + const raw = element.getAttribute('content'); + const content = raw && decodeEntities(raw).trim(); + if (!key || !content) return; + if (key === 'misskey:note-id') misskeyNote = true; + if (key === 'og:image' || key === 'og:image:url') found.push({ url: content }); + else if (key === 'og:image:width' || key === 'og:image:height') { + const last = found.at(-1); + const side = key === 'og:image:width' ? 'width' : 'height'; + if (last && !last[side]) last[side] = content; + } else if (key.startsWith('og:image')) return; + else if (key.startsWith('og:')) setTag(tags, key, content); + else if (key.startsWith('twitter:') || key === 'description') setTag(fallback, key, content); + }, + }) + .on('link[rel="alternate"][type="application/json+oembed"]', { + element(element) { + const href = element.getAttribute('href'); + oembed ??= href ? decodeEntities(href) : undefined; + }, + }) + .on('title', { + text(text) { + title += text.text; + }, + }) + .transform(page) + .arrayBuffer(); + + setTag(tags, 'og:title', fallback['twitter:title'] ?? decodeEntities(title).trim()); + setTag(tags, 'og:description', fallback['twitter:description'] ?? fallback['description']); + + const images: Image[] = []; + addImages(images, found.map((image) => toImage(image.url, base, image.width, image.height))); + if (!images.length) addImages(images, [toImage(fallback['twitter:image'] ?? fallback['twitter:image:src'], base)]); + return { preview: { tags, images }, oembed, misskeyNote }; +} + +async function noteImages(url: URL): Promise<Image[]> { + const response = await fetchPublic(url, 'application/activity+json'); + const note = response ? ((await response.json().catch(() => null)) as ActivityNote | null) : null; + if (!note || note.sensitive) return []; + const images: Image[] = []; + addImages( + images, + (note.attachment ?? []).filter((file) => file.mediaType?.startsWith('image/')).map((file) => toImage(file.url, url, file.width, file.height)), + ); + return images; +} + +function mergeOEmbed(preview: Preview, oembed: OEmbed, authoritative: boolean): void { + const { tags } = preview; + if (authoritative && oembed.title) tags['og:description'] = oembed.title; + setTag(tags, 'og:title', oembed.title ?? oembed.author_name); + setTag(tags, 'og:site_name', oembed.provider_name); + if (!preview.images.length) addImages(preview.images, [toImage(oembed.thumbnail_url, undefined, oembed.thumbnail_width, oembed.thumbnail_height)]); +} + +async function previewTweet(id: string): Promise<Preview | null> { + const body = await fetchJson<{ tweet?: FxTweet }>(new URL(`https://api.fxtwitter.com/status/${id}`)); + const tweet = body?.tweet; + if (!tweet) return null; + + const tags: Tags = { 'og:site_name': 'X' }; + const { name, screen_name: handle, avatar_url: avatar } = tweet.author ?? {}; + setTag(tags, 'og:title', name && handle ? `${name} (@${handle})` : (name ?? handle)); + setTag(tags, 'og:description', tweet.text); + + const images: Image[] = []; + const media = [...(tweet.media?.photos ?? []), ...(tweet.media?.videos ?? [])]; + addImages(images, media.map((item) => toImage(item.thumbnail_url ?? item.url, undefined, item.width, item.height))); + if (!images.length) addImages(images, [toImage(avatar)]); + + const mosaic = images.length > 1 ? toImage(tweet.media?.mosaic?.formats?.jpeg) : null; + return { tags, images, cover: mosaic ?? undefined }; +} + +async function previewPage(url: URL): Promise<Preview | null> { + const endpoint = OEMBED_ENDPOINTS.find(([host]) => host.test(url.hostname))?.[1]; + const known = endpoint ? fetchJson<OEmbed>(new URL(`${endpoint}?format=json&url=${encodeURIComponent(url.href)}`)) : null; + + const page = await fetchPublic(url, 'text/html'); + const base = page?.url || url; + const html = page?.headers.get('Content-Type')?.includes('text/html') ? await readPage(page, base) : null; + const knownOEmbed = await known; + if (!html && !knownOEmbed) return null; + + const preview = html?.preview ?? { tags: {}, images: [] }; + const { tags } = preview; + if (knownOEmbed) mergeOEmbed(preview, knownOEmbed, true); + else if (html?.oembed && !(tags['og:title'] && tags['og:description'] && preview.images.length)) { + const discovered = parsePublicUrl(html.oembed, base); + const found = discovered ? await fetchJson<OEmbed>(discovered) : null; + if (found) mergeOEmbed(preview, found, false); + } + if (html?.misskeyNote) { + const attachments = await noteImages(url); + if (attachments.length) preview.images = attachments; + } + return preview; +} + +function tumblrPost(url: URL): string | null { + const blog = TUMBLR_BLOG_POST.exec(url.hostname)?.[1]; + if (blog && blog !== 'www') { + const id = /^\/post\/(\d+)/.exec(url.pathname)?.[1]; + return id ? `${blog}/${id}` : null; + } + if (!/^(?:www\.)?tumblr\.com$/.test(url.hostname)) return null; + const [, dashboardBlog, id] = TUMBLR_DASHBOARD_POST.exec(url.pathname) ?? []; + return id ? `${dashboardBlog}/${id}` : null; +} + +async function previewTumblr(url: URL, post: string): Promise<Preview | null> { + const [preview, fxtumblr] = await Promise.all([previewPage(url), previewPage(new URL(`https://tpmblr.com/${post}`))]); + if (!preview || preview.images.length || !fxtumblr?.images.length) return preview ?? fxtumblr; + preview.images = fxtumblr.images; + return preview; +} + +function toMatrixPreview({ tags, images, cover }: Preview, serverName: string): Record<string, unknown> { + const toMxc = (image: Image) => `mxc://${serverName}/${toMatrixID(image.url, 'og_')}`; + const preview: Record<string, unknown> = { ...tags }; + + const main = cover ?? images[0]; + if (main) { + preview['og:image'] = toMxc(main); + if (main.width) preview['og:image:width'] = main.width; + if (main.height) preview['og:image:height'] = main.height; + } + if (images.length > 1) { + preview['com.sable.images'] = images.map((image) => ({ url: toMxc(image), width: image.width, height: image.height })); + } + return preview; +} + +export async function previewUrl(target: string | null, serverName: string): Promise<Response> { + const url = target ? parsePublicUrl(target) : null; + if (!url) { + return new Response(JSON.stringify(matrixInvalidParam('url must be a public http(s) url')), { + headers: CORS_HEADERS, + status: 400, + }); + } + + const tweet = TWITTER_HOSTS.test(url.hostname) ? TWITTER_STATUS.exec(url.pathname)?.[1] : undefined; + const tumblr = tumblrPost(url); + const preview = (tweet ? await previewTweet(tweet) : null) ?? (tumblr ? await previewTumblr(url, tumblr) : await previewPage(url)); + if (!preview) { + return new Response(JSON.stringify(matrixRessourceNotFound('no html preview for this url')), { + headers: CORS_HEADERS, + status: 404, + }); + } + + return new Response(JSON.stringify(toMatrixPreview(preview, serverName)), { + headers: { ...CORS_HEADERS, 'Cache-Control': 'public, max-age=86400' }, + }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 4e4df9c..aa29c28 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,6 +19,7 @@ import { MatrixError } from './types'; import { matrixInvalidParam } from './matrixError'; import { fromMatrixID } from './mxcId'; +import { parsePublicUrl } from './preview'; /** * helper function to decode matrix id, and either return the decoded id or a error response @@ -113,6 +114,17 @@ function proxyKlipy(path: string): Response { return buildMultipartRedirect(`https://static.klipy.com/ii/${path}`) } +function proxyOpenGraphImage(url: string): Response { + const image = parsePublicUrl(url); + if (!image) { + return new Response(JSON.stringify(matrixInvalidParam('the og image is not a public http(s) url')), { + status: 400, + statusText: 'the og image is not a public http(s) url', + }); + } + return buildMultipartRedirect(image.href); +} + export async function proxyMediaCall(rawId: string): Promise<Response> { const decodedId = decodeMatrixId(rawId); if (typeof decodedId !== 'string') { @@ -121,12 +133,15 @@ export async function proxyMediaCall(rawId: string): Promise<Response> { const isGiphy: boolean = rawId.startsWith('giphy_') || decodedId.startsWith('giphy_'); const isTenor: boolean = rawId.startsWith('tenor_') || decodedId.startsWith('tenor_'); const isKlipy: boolean = rawId.startsWith('klipy_') || decodedId.startsWith('klipy_'); + const isOpenGraph: boolean = rawId.startsWith('og_'); if (isTenor) { return proxyTenor(decodedId); } else if (isGiphy) { return proxyGiphy(decodedId); } else if (isKlipy) { return proxyKlipy(decodedId); + } else if (isOpenGraph) { + return proxyOpenGraphImage(decodedId); } return new Response(JSON.stringify(matrixInvalidParam("the identifier of the remote didn't match any supported remote identifier")), { status: 400, diff --git a/test/preview.spec.ts b/test/preview.spec.ts new file mode 100644 index 0000000..000dad3 --- /dev/null +++ b/test/preview.spec.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import worker from '../src/index'; +import { toMatrixID } from '../src/mxcId'; +import { proxyMediaCall } from '../src/proxy'; + +const env = {} as { SERVERNAME: any; HOSTNAME: any; PORT: any }; + +function preview(target: string) { + return worker.fetch({ url: `https://gifs.example/_soliditas/preview_url?url=${encodeURIComponent(target)}` }, env, {}); +} + +function servePage(html: string, contentType = 'text/html; charset=utf-8') { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(html, { headers: { 'Content-Type': contentType } })); +} + +function serveRoutes(routes: Record<string, () => Response>) { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input instanceof Request ? input.url : input); + const route = Object.keys(routes).find((prefix) => url.startsWith(prefix)); + return route ? routes[route]() : new Response(null, { status: 404 }); + }); +} + +function html(body: string) { + return new Response(body, { headers: { 'Content-Type': 'text/html' } }); +} + +function json(body: unknown) { + return Response.json(body); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('preview_url', () => { + it('answers with the open graph tags and an mxc for the image', async () => { + servePage(`<html><head> + <meta property="og:title" content="A post"> + <meta property="og:description" content="Something happened"> + <meta property="og:site_name" content="Example"> + <meta property="og:image" content="/cover.png"> + <meta property="og:image:width" content="640"> + <meta property="og:image:height" content="nope"> + </head></html>`); + + const response = await preview('https://news.example/post'); + + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(await response.json()).toEqual({ + 'og:title': 'A post', + 'og:description': 'Something happened', + 'og:site_name': 'Example', + 'og:image': `mxc://gifs.example/${toMatrixID('https://news.example/cover.png', 'og_')}`, + 'og:image:width': 640, + }); + }); + + it('falls back to the title and description tags', async () => { + servePage('<html><head><title> Plain & page '); + + expect(await (await preview('https://plain.example/')).json()).toEqual({ + 'og:title': 'Plain & page', + 'og:description': 'No og here', + }); + }); + + it('drops an image on a private host', async () => { + servePage(''); + + expect(await (await preview('https://news.example/post')).json()).toEqual({ 'og:title': 't' }); + }); + + it('refuses urls that are not public http(s)', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + for (const target of ['ftp://files.example/a', 'http://localhost/', 'http://10.0.0.1/', 'http://[::1]/', 'not a url']) { + expect((await preview(target)).status).toBe(400); + } + expect((await worker.fetch({ url: 'https://gifs.example/_soliditas/preview_url' }, env, {})).status).toBe(400); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('answers not found for a page that is not html', async () => { + servePage('{}', 'application/json'); + + expect((await preview('https://api.example/')).status).toBe(404); + }); + + it('answers not found when the page cannot be fetched', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('down')); + + expect((await preview('https://down.example/')).status).toBe(404); + }); +}); + +describe('preview_url with several images', () => { + it('lists every og:image with the sizes that follow it', async () => { + servePage(` + + + + + `); + + const first = `mxc://gifs.example/${toMatrixID('https://gallery.example/1.png', 'og_')}`; + expect(await (await preview('https://gallery.example/post')).json()).toEqual({ + 'og:title': 'Gallery', + 'og:image': first, + 'og:image:width': 100, + 'og:image:height': 50, + 'com.sable.images': [ + { url: first, width: 100, height: 50 }, + { url: `mxc://gifs.example/${toMatrixID('https://cdn.example/2.png', 'og_')}` }, + ], + }); + }); + + it('lists a tumblr gif once, not again as its first frame', async () => { + servePage(` + + `); + + const body = (await (await preview('https://staff.tumblr.com/')).json()) as { 'com.sable.images': { url: string }[] }; + expect(body['com.sable.images'].map((image) => image.url)).toEqual([ + `mxc://gifs.example/${toMatrixID('https://64.media.tumblr.com/abc/123-b0/s1280x1920/one.gif', 'og_')}`, + `mxc://gifs.example/${toMatrixID('https://64.media.tumblr.com/def/123-dd/s1280x1920/three.gif', 'og_')}`, + ]); + }); + + it('stops at ten images', async () => { + servePage(Array.from({ length: 12 }, (_, i) => ``).join('')); + + const body = (await (await preview('https://gallery.example/')).json()) as { 'com.sable.images': unknown[] }; + expect(body['com.sable.images']).toHaveLength(10); + }); +}); + +describe('preview_url fallbacks', () => { + it('fills gaps from the twitter card tags and decodes entities', async () => { + servePage(` + + `); + + expect(await (await preview('https://cartoons.example/')).json()).toEqual({ + 'og:title': 'Tom & Jerry', + 'og:description': 'Cat & mouse', + 'og:image': `mxc://gifs.example/${toMatrixID('https://cdn.example/card.png?a=1&b=2', 'og_')}`, + }); + }); + + it('prefers og tags over twitter card tags', async () => { + servePage(''); + + expect(await (await preview('https://site.example/')).json()).toEqual({ 'og:title': 'og' }); + }); + + it('fills missing fields from a discovered oembed document', async () => { + serveRoutes({ + 'https://site.example/post': () => + html(''), + 'https://site.example/oembed?id=1': () => + json({ title: 'ignored', provider_name: 'Site', thumbnail_url: 'https://cdn.example/t.jpg', thumbnail_width: 320, thumbnail_height: 180 }), + }); + + expect(await (await preview('https://site.example/post')).json()).toEqual({ + 'og:title': 'Post', + 'og:site_name': 'Site', + 'og:image': `mxc://gifs.example/${toMatrixID('https://cdn.example/t.jpg', 'og_')}`, + 'og:image:width': 320, + 'og:image:height': 180, + }); + }); + + it('takes the post title from the reddit oembed over its generic description', async () => { + serveRoutes({ + 'https://www.reddit.com/r/pics/comments/92dd8/': () => + html(''), + 'https://www.reddit.com/oembed?format=json&url=https%3A%2F%2Fwww.reddit.com%2Fr%2Fpics%2Fcomments%2F92dd8%2F': () => + json({ title: 'test post please ignore', provider_name: 'reddit' }), + }); + + expect(await (await preview('https://www.reddit.com/r/pics/comments/92dd8/')).json()).toEqual({ + 'og:title': 'From the pics community on Reddit', + 'og:description': 'test post please ignore', + 'og:site_name': 'reddit', + }); + }); + + it('answers from the oembed alone when the page is blocked', async () => { + serveRoutes({ + 'https://www.tiktok.com/oembed': () => json({ title: 'caption', author_name: 'Scout', provider_name: 'TikTok' }), + }); + + expect(await (await preview('https://www.tiktok.com/@scout/video/1')).json()).toEqual({ + 'og:title': 'caption', + 'og:description': 'caption', + 'og:site_name': 'TikTok', + }); + }); +}); + +describe('preview_url for tweets', () => { + const author = { name: 'NASA', screen_name: 'NASA', avatar_url: 'https://pbs.twimg.com/avatar.jpg' }; + + it('reads the tweet from the fxtwitter api', async () => { + const fetchSpy = serveRoutes({ + 'https://api.fxtwitter.com/status/123': () => + json({ + tweet: { + text: 'Liftoff', + author, + media: { photos: [{ url: 'https://pbs.twimg.com/media/a.jpg', width: 3000, height: 2000 }] }, + }, + }), + }); + + expect(await (await preview('https://x.com/NASA/status/123?s=20')).json()).toEqual({ + 'og:site_name': 'X', + 'og:title': 'NASA (@NASA)', + 'og:description': 'Liftoff', + 'og:image': `mxc://gifs.example/${toMatrixID('https://pbs.twimg.com/media/a.jpg', 'og_')}`, + 'og:image:width': 3000, + 'og:image:height': 2000, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('uses the mosaic as the main image and lists every photo', async () => { + serveRoutes({ + 'https://api.fxtwitter.com/status/1': () => + json({ + tweet: { + author, + media: { + photos: [{ url: 'https://pbs.twimg.com/a.jpg', width: 10, height: 20 }, { url: 'https://pbs.twimg.com/b.jpg' }], + mosaic: { formats: { jpeg: 'https://mosaic.example/1' } }, + }, + }, + }), + 'https://api.fxtwitter.com/status/2': () => json({ tweet: { author, text: 'hi' } }), + }); + + expect((await (await preview('https://twitter.com/NASA/status/1')).json()) as object).toMatchObject({ + 'og:image': `mxc://gifs.example/${toMatrixID('https://mosaic.example/1', 'og_')}`, + 'com.sable.images': [ + { url: `mxc://gifs.example/${toMatrixID('https://pbs.twimg.com/a.jpg', 'og_')}`, width: 10, height: 20 }, + { url: `mxc://gifs.example/${toMatrixID('https://pbs.twimg.com/b.jpg', 'og_')}` }, + ], + }); + const single = (await (await preview('https://fixupx.com/NASA/status/2')).json()) as Record; + expect(single['og:image']).toBe(`mxc://gifs.example/${toMatrixID('https://pbs.twimg.com/avatar.jpg', 'og_')}`); + expect(single).not.toHaveProperty('com.sable.images'); + }); + + it('falls back to the page when the api has nothing', async () => { + serveRoutes({ 'https://x.com/NASA/status/9': () => html('') }); + + expect(await (await preview('https://x.com/NASA/status/9')).json()).toEqual({ 'og:title': 'from the page' }); + }); +}); + +describe('preview_url for fediverse posts', () => { + const note = ''; + + function serveNote(activity: unknown) { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const accept = new Headers(init?.headers).get('Accept'); + return accept === 'application/activity+json' ? json(activity) : html(note); + }); + } + + it('takes the image of a misskey note from its activity', async () => { + serveNote({ + attachment: [ + { mediaType: 'image/webp', url: 'https://files.example/a.webp', width: 800, height: 600 }, + { mediaType: 'video/mp4', url: 'https://files.example/v.mp4' }, + { mediaType: 'image/png', url: 'https://files.example/b.png' }, + ], + }); + + const a = `mxc://gifs.example/${toMatrixID('https://files.example/a.webp', 'og_')}`; + expect(await (await preview('https://misskey.example/notes/n1')).json()).toEqual({ + 'og:title': 'Ann (@ann)', + 'og:image': a, + 'og:image:width': 800, + 'og:image:height': 600, + 'com.sable.images': [ + { url: a, width: 800, height: 600 }, + { url: `mxc://gifs.example/${toMatrixID('https://files.example/b.png', 'og_')}` }, + ], + }); + }); + + it('keeps the avatar for a sensitive note', async () => { + serveNote({ sensitive: true, attachment: [{ mediaType: 'image/webp', url: 'https://files.example/a.webp' }] }); + + expect((await (await preview('https://misskey.example/notes/n1')).json()) as object).toMatchObject({ + 'og:image': `mxc://gifs.example/${toMatrixID('https://misskey.example/avatar.webp', 'og_')}`, + }); + }); + + it('drops an image that is only the site root', async () => { + servePage(''); + + expect(await (await preview('https://friendica.example/display/1')).json()).toEqual({ 'og:title': 't' }); + }); +}); + +describe('preview_url for tumblr posts', () => { + const render = 'https://tpmblr.com/_api/renders/post/staff/42/render.png'; + + it('uses the fxtumblr render when the post has no image', async () => { + serveRoutes({ + 'https://www.tumblr.com/staff/42': () => html(''), + 'https://tpmblr.com/staff/42': () => html(``), + }); + + expect(await (await preview('https://www.tumblr.com/staff/42/some-slug')).json()).toEqual({ + 'og:title': 'Reblog by @staff', + 'og:description': 'text', + 'og:image': `mxc://gifs.example/${toMatrixID(render, 'og_')}`, + }); + }); + + it('keeps the image of the post itself', async () => { + serveRoutes({ + 'https://staff.tumblr.com/post/42': () => + html(''), + 'https://tpmblr.com/staff/42': () => html(``), + }); + + expect((await (await preview('https://staff.tumblr.com/post/42')).json()) as object).toMatchObject({ + 'og:image': `mxc://gifs.example/${toMatrixID('https://64.media.tumblr.com/a.png', 'og_')}`, + }); + }); + + it('answers from fxtumblr when tumblr is unreachable', async () => { + serveRoutes({ 'https://tpmblr.com/staff/42': () => html(``) }); + + expect(await (await preview('https://tumblr.com/staff/42')).json()).toEqual({ + 'og:title': 'staff', + 'og:image': `mxc://gifs.example/${toMatrixID(render, 'og_')}`, + }); + }); +}); + +describe('og image proxying', () => { + it('redirects to the image the id carries', async () => { + const response = await proxyMediaCall(toMatrixID('https://news.example/cover.png', 'og_')); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('Location: https://news.example/cover.png\r\n'); + }); + + it('refuses an image on a private host', async () => { + const response = await proxyMediaCall(toMatrixID('http://192.168.1.1/cover.png', 'og_')); + + expect(response.status).toBe(400); + }); +});