Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,24 @@ const NAMED_ENTITIES: Record<string, string> = { 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;

type Tags = Record<string, string>;

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;
author_name?: string;
author_url?: string;
provider_name?: string;
thumbnail_url?: string;
thumbnail_width?: number;
Expand Down Expand Up @@ -119,6 +128,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;
Expand All @@ -138,6 +155,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', {
Expand All @@ -147,14 +165,19 @@ 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';
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"]', {
Expand All @@ -174,10 +197,16 @@ 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)]);
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<Image[]> {
Expand All @@ -196,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)]);
}
Expand All @@ -219,12 +249,37 @@ async function previewTweet(id: string): Promise<Preview | null> {
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;
}

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<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 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();
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;
Expand Down Expand Up @@ -262,7 +317,7 @@ async function previewTumblr(url: URL, post: string): Promise<Preview | null> {
return preview;
}

function toMatrixPreview({ tags, images, cover }: Preview, serverName: string): Record<string, unknown> {
function toMatrixPreview({ tags, images, cover, video }: Preview, serverName: string): Record<string, unknown> {
const toMxc = (image: Image) => `mxc://${serverName}/${toMatrixID(image.url, 'og_')}`;
const preview: Record<string, unknown> = { ...tags };

Expand All @@ -272,6 +327,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 }));
}
Expand Down
102 changes: 102 additions & 0 deletions test/preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,114 @@ 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<string, unknown>;

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('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<string, unknown>;

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(`<html><head>
<meta property="og:title" content="Clip">
<meta property="og:image" content="https://cdn.example/poster.jpg">
<meta property="og:video" content="http://cdn.example/clip.mp4">
<meta property="og:video:secure_url" content="https://cdn.example/clip.mp4">
<meta property="og:video:type" content="video/mp4">
<meta property="og:video:width" content="1280">
<meta property="og:video:height" content="720">
</head></html>`);

const body = (await (await preview('https://site.example/watch')).json()) as Record<string, unknown>;

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(`<html><head>
<meta property="og:title" content="Watch">
<meta property="og:video" content="https://site.example/embed/1">
<meta property="og:video:type" content="text/html">
</head></html>`);

const body = (await (await preview('https://site.example/watch')).json()) as Record<string, unknown>;

expect(body['og:video']).toBeUndefined();
});

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 presentation hints', () => {
it('passes the theme colour and card size through', async () => {
servePage(`<html><head>
<meta property="og:title" content="A post">
<meta name="theme-color" content="#ff4500">
<meta name="twitter:card" content="summary_large_image">
</head></html>`);

const body = (await (await preview('https://site.example/post')).json()) as Record<string, unknown>;

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(`<html><head>
<meta property="og:title" content="A post">
<meta name="theme-color" content="red; background:url(x)">
<meta name="twitter:card" content="player">
</head></html>`);

const body = (await (await preview('https://site.example/post')).json()) as Record<string, unknown>;

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('<html><head></head></html>'),
});

const body = (await (await preview('https://www.reddit.com/r/x/comments/1/t/')).json()) as Record<string, unknown>;

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(`<meta property="og:title" content="Gallery">
Expand Down Expand Up @@ -198,6 +299,7 @@ describe('preview_url fallbacks', () => {
'og:title': 'caption',
'og:description': 'caption',
'og:site_name': 'TikTok',
'com.sable.author_name': 'Scout',
});
});
});
Expand Down
Loading