From e078e49f0a03da5fb3ae50d3879876f0385010c9 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 2 Oct 2026 20:23:34 +0200 Subject: [PATCH 1/3] feat(preview): preview a direct image link as the image itself --- src/preview.ts | 11 +++++++++++ test/preview.spec.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/preview.ts b/src/preview.ts index 8e3aa04..292cc63 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -219,12 +219,23 @@ async function previewTweet(id: string): Promise { return { tags, images, cover: mosaic ?? undefined }; } +function imageType(page: Response): string | null { + const type = /^\s*(image\/[\w.+-]+)/i.exec(page.headers.get('Content-Type') ?? '')?.[1].toLowerCase(); + return type && type !== 'image/svg+xml' ? type : null; +} + 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 type = page && imageType(page); + if (page && type) { + await page.body?.cancel(); + const image = toImage(base.toString()); + return image ? { tags: { 'og:image:type': type }, images: [image] } : null; + } const html = page?.headers.get('Content-Type')?.includes('text/html') ? await readPage(page, base) : null; const knownOEmbed = await known; if (!html && !knownOEmbed) return null; diff --git a/test/preview.spec.ts b/test/preview.spec.ts index 000dad3..b89d6b9 100644 --- a/test/preview.spec.ts +++ b/test/preview.spec.ts @@ -89,6 +89,18 @@ describe('preview_url', () => { expect((await preview('https://api.example/')).status).toBe(404); }); + it('previews a direct image link as the image itself', async () => { + servePage('', 'image/gif'); + + const response = await preview('https://cdn.example/clip.gif'); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body['og:image']).toBe(`mxc://gifs.example/${toMatrixID('https://cdn.example/clip.gif', 'og_')}`); + expect(body['og:image:type']).toBe('image/gif'); + expect(body['og:title']).toBeUndefined(); + }); + it('answers not found when the page cannot be fetched', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('down')); From 42e2ed9295ae8d0bfdc8eb68bbfce059aba5144b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 2 Oct 2026 20:28:50 +0200 Subject: [PATCH 2/3] feat(preview): preview direct video links and og:video --- src/preview.ts | 49 +++++++++++++++++++++++++++++++++++++++---- test/preview.spec.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/preview.ts b/src/preview.ts index 292cc63..5d252d2 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -41,11 +41,17 @@ const NAMED_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quo const MAX_IMAGES = 10; +const MAX_DIRECT_VIDEO_BYTES = 100 * 1024 * 1024; + +const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v|ogv)$/i; + type Tags = Record; type Image = { url: string; width?: number; height?: number }; -type Preview = { tags: Tags; images: Image[]; cover?: Image }; +type Video = { url: string; type: string; width?: number; height?: number }; + +type Preview = { tags: Tags; images: Image[]; cover?: Image; video?: Video }; type OEmbed = { title?: string; @@ -119,6 +125,14 @@ function toImage(value: string | undefined, base?: string | URL, width?: string return { url: url.href, width: toSize(width), height: toSize(height) }; } +function toVideo(value: string | undefined, base: string | URL, type?: string, width?: string | number, height?: string | number): Video | null { + const url = value ? parsePublicUrl(value, base) : null; + if (!url || url.pathname === '/') return null; + const mime = type?.split(';')[0].trim().toLowerCase(); + if (mime ? !mime.startsWith('video/') : !VIDEO_EXTENSION.test(url.pathname)) return null; + return { url: url.href, type: mime ?? 'video/mp4', 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; @@ -138,6 +152,7 @@ async function readPage(page: Response, base: string | URL): Promise<{ preview: let title = ''; let oembed: string | undefined; let misskeyNote = false; + const clip: { url?: string; secure?: string; type?: string; width?: string; height?: string } = {}; await new HTMLRewriter() .on('meta', { @@ -147,7 +162,12 @@ async function readPage(page: Response, base: string | URL): Promise<{ preview: 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 }); + if (key === 'og:video' || key === 'og:video:url') clip.url ??= content; + else if (key === 'og:video:secure_url') clip.secure ??= content; + else if (key === 'og:video:type') clip.type ??= content; + else if (key === 'og:video:width') clip.width ??= content; + else if (key === 'og:video:height') clip.height ??= content; + else 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'; @@ -177,7 +197,8 @@ async function readPage(page: Response, base: string | URL): Promise<{ preview: 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 }; + const video = toVideo(clip.secure ?? clip.url, base, clip.type, clip.width, clip.height); + return { preview: { tags, images, video: video ?? undefined }, oembed, misskeyNote }; } async function noteImages(url: URL): Promise { @@ -224,12 +245,26 @@ function imageType(page: Response): string | null { return type && type !== 'image/svg+xml' ? type : null; } +function videoType(page: Response): string | null { + return /^\s*(video\/[\w.+-]+)/i.exec(page.headers.get('Content-Type') ?? '')?.[1].toLowerCase() ?? null; +} + +function videoSize(page: Response): number { + return toSize(page.headers.get('Content-Length') ?? undefined) ?? 0; +} + 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 clipType = page && videoType(page); + if (page && clipType) { + await page.body?.cancel(); + const video = toVideo(base.toString(), base, clipType); + return video && videoSize(page) <= MAX_DIRECT_VIDEO_BYTES ? { tags: {}, images: [], video } : null; + } const type = page && imageType(page); if (page && type) { await page.body?.cancel(); @@ -273,7 +308,7 @@ async function previewTumblr(url: URL, post: string): Promise { return preview; } -function toMatrixPreview({ tags, images, cover }: Preview, serverName: string): Record { +function toMatrixPreview({ tags, images, cover, video }: Preview, serverName: string): Record { const toMxc = (image: Image) => `mxc://${serverName}/${toMatrixID(image.url, 'og_')}`; const preview: Record = { ...tags }; @@ -283,6 +318,12 @@ function toMatrixPreview({ tags, images, cover }: Preview, serverName: string): if (main.width) preview['og:image:width'] = main.width; if (main.height) preview['og:image:height'] = main.height; } + if (video) { + preview['og:video'] = toMxc({ url: video.url }); + preview['og:video:type'] = video.type; + if (video.width) preview['og:video:width'] = video.width; + if (video.height) preview['og:video:height'] = video.height; + } if (images.length > 1) { preview['com.sable.images'] = images.map((image) => ({ url: toMxc(image), width: image.width, height: image.height })); } diff --git a/test/preview.spec.ts b/test/preview.spec.ts index b89d6b9..ddfbdd1 100644 --- a/test/preview.spec.ts +++ b/test/preview.spec.ts @@ -101,6 +101,56 @@ describe('preview_url', () => { expect(body['og:title']).toBeUndefined(); }); + it('previews a direct video link as the video itself', async () => { + servePage('', 'video/mp4'); + + const body = (await (await preview('https://cdn.example/clip.mp4')).json()) as Record; + + expect(body['og:video']).toBe(`mxc://gifs.example/${toMatrixID('https://cdn.example/clip.mp4', 'og_')}`); + expect(body['og:video:type']).toBe('video/mp4'); + expect(body['og:image']).toBeUndefined(); + }); + + it('refuses a direct video over the size cap', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(101 * 1024 * 1024) } }), + ); + + expect((await preview('https://cdn.example/big.mp4')).status).toBe(404); + }); + + it('proxies the video a page declares and keeps its poster', async () => { + servePage(` + + + + + + + + `); + + const body = (await (await preview('https://site.example/watch')).json()) as Record; + + expect(body['og:video']).toBe(`mxc://gifs.example/${toMatrixID('https://cdn.example/clip.mp4', 'og_')}`); + expect(body['og:video:width']).toBe(1280); + expect(body['og:video:height']).toBe(720); + expect(body['og:image']).toBeDefined(); + expect(body['og:title']).toBe('Clip'); + }); + + it('ignores an embed player that is not a video file', async () => { + servePage(` + + + + `); + + const body = (await (await preview('https://site.example/watch')).json()) as Record; + + expect(body['og:video']).toBeUndefined(); + }); + it('answers not found when the page cannot be fetched', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('down')); From d7acc1a3e85a8a77fd5a7a76f1a237279061c33c Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 2 Oct 2026 20:28:58 +0200 Subject: [PATCH 3/3] feat(preview): pass the theme colour, card size and oembed author through --- src/preview.ts | 11 ++++++++++- test/preview.spec.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/preview.ts b/src/preview.ts index 5d252d2..408c607 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -41,6 +41,8 @@ const NAMED_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quo const MAX_IMAGES = 10; +const THEME_COLOR = /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i; + const MAX_DIRECT_VIDEO_BYTES = 100 * 1024 * 1024; const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v|ogv)$/i; @@ -56,6 +58,7 @@ type Preview = { tags: Tags; images: Image[]; cover?: Image; video?: Video }; type OEmbed = { title?: string; author_name?: string; + author_url?: string; provider_name?: string; thumbnail_url?: string; thumbnail_width?: number; @@ -174,7 +177,7 @@ async function readPage(page: Response, base: string | URL): Promise<{ preview: 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); + else if (key.startsWith('twitter:') || key === 'description' || key === 'theme-color') setTag(fallback, key, content); }, }) .on('link[rel="alternate"][type="application/json+oembed"]', { @@ -194,6 +197,11 @@ async function readPage(page: Response, base: string | URL): Promise<{ preview: setTag(tags, 'og:title', fallback['twitter:title'] ?? decodeEntities(title).trim()); setTag(tags, 'og:description', fallback['twitter:description'] ?? fallback['description']); + const color = fallback['theme-color']; + if (color && THEME_COLOR.test(color)) tags['com.sable.theme_color'] = color; + const card = fallback['twitter:card']; + if (card === 'summary' || card === 'summary_large_image') tags['com.sable.card'] = card; + 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)]); @@ -217,6 +225,7 @@ function mergeOEmbed(preview: Preview, oembed: OEmbed, authoritative: boolean): const { tags } = preview; if (authoritative && oembed.title) tags['og:description'] = oembed.title; setTag(tags, 'og:title', oembed.title ?? oembed.author_name); + setTag(tags, 'com.sable.author_name', 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)]); } diff --git a/test/preview.spec.ts b/test/preview.spec.ts index ddfbdd1..b15645c 100644 --- a/test/preview.spec.ts +++ b/test/preview.spec.ts @@ -158,6 +158,45 @@ describe('preview_url', () => { }); }); +describe('preview_url presentation hints', () => { + it('passes the theme colour and card size through', async () => { + servePage(` + + + + `); + + const body = (await (await preview('https://site.example/post')).json()) as Record; + + expect(body['com.sable.theme_color']).toBe('#ff4500'); + expect(body['com.sable.card']).toBe('summary_large_image'); + }); + + it('drops a theme colour that is not a hex colour and an unknown card', async () => { + servePage(` + + + + `); + + const body = (await (await preview('https://site.example/post')).json()) as Record; + + expect(body['com.sable.theme_color']).toBeUndefined(); + expect(body['com.sable.card']).toBeUndefined(); + }); + + it('keeps the oembed author', async () => { + serveRoutes({ + 'https://www.reddit.com/oembed': () => json({ title: 'T', author_name: 'someone', provider_name: 'Reddit' }), + 'https://www.reddit.com/r/': () => html(''), + }); + + const body = (await (await preview('https://www.reddit.com/r/x/comments/1/t/')).json()) as Record; + + expect(body['com.sable.author_name']).toBe('someone'); + }); +}); + describe('preview_url with several images', () => { it('lists every og:image with the sizes that follow it', async () => { servePage(` @@ -260,6 +299,7 @@ describe('preview_url fallbacks', () => { 'og:title': 'caption', 'og:description': 'caption', 'og:site_name': 'TikTok', + 'com.sable.author_name': 'Scout', }); }); });