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, `
`, `` 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 = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
+
+const MAX_IMAGES = 10;
+
+type Tags = Record;
+
+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 {
+ return fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: accept }, redirect: 'follow' })
+ .then((response) => (response.ok ? response : null))
+ .catch(() => null);
+}
+
+async function fetchJson(url: URL): Promise {
+ 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 {
+ 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 {
+ 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 {
+ const endpoint = OEMBED_ENDPOINTS.find(([host]) => host.test(url.hostname))?.[1];
+ const known = endpoint ? fetchJson(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(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 {
+ 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 {
+ const toMxc = (image: Image) => `mxc://${serverName}/${toMatrixID(image.url, 'og_')}`;
+ const preview: Record = { ...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 {
+ 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 {
const decodedId = decodeMatrixId(rawId);
if (typeof decodedId !== 'string') {
@@ -121,12 +133,15 @@ export async function proxyMediaCall(rawId: string): Promise {
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 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(`
+
+
+
+
+
+
+ `);
+
+ 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(' 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);
+ });
+});