source: Klonkt/test/embed-resolver.test.js@ 0101d0a

main
Last change on this file since 0101d0a was 0101d0a, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Embeds toonden niets: OpenGraph erbij en een backfill

Twee oorzaken, allebei mijn eigen gat.

OPENGRAPH ONTBRAK. Ik had oEmbed gebouwd als de generieke weg, maar het grootste
deel van het web levert dat helemaal niet. Link-previews met een thumbnail
draaien in de praktijk op OpenGraph, en dat is ook wat Mastodon leest. Gecheckt
op een URL uit onze eigen feed: og:image en og:title wel, oEmbed niet. Zonder die
stap loste vrijwel elke link op naar kind=link en werd er dus niets opgeslagen,
en bleef de kaart leeg. OpenGraph zit nu in dezelfde keten, na oEmbed en voor de
kale link, en gebruikt dezelfde pagina-fetch: het kost geen extra request. De
fetch stuurt nu ook een User-Agent mee, want een deel van het web geeft z-n
og-tags alleen aan iets dat op een browser lijkt.

GEEN BACKFILL. embed_json werd alleen gevuld bij nieuwe binnenkomende posts, dus
alle 359 bestaande rijen bleven leeg: precies wat je ziet als je de app opent.
Self-heal v16 haalt het alsnog op, alleen voor rijen zonder quote en zonder
embed, dus een pagina-fetch per kandidaat en nooit opnieuw.

Changed files:
src/services/EmbedResolver.js

  • findOpenGraph (og:image/title/site_name, met twitter:-fallback)
  • resolveEmbed: OpenGraph na oEmbed, een pagina-fetch voor beide
  • liveIO: User-Agent op de pagina-fetch

src/services/ActivityPubService.js

  • SELFHEAL_VERSION 15 -> 16: embed_json backfillen

test/embed-resolver.test.js

  • 4 tests: og lezen + entities, twitter-fallback en non-http image geweigerd, oEmbed wint nog steeds van OpenGraph, en een og-only pagina levert een kaart

remarks: 213 tests groen. End-to-end gedraaid op een echte feed-URL: titel,
provider en thumbnail komen eruit.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 7.8 KB
Line 
1// One embed pipeline (shaer-277): AP first (FEP semantics), then a known
2// provider, then oEmbed, then a plain link. All four end in the same shape.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5import {
6 resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed,
7} from '../src/services/EmbedResolver.js';
8
9const AP_NOTE = { id: 'https://s/objects/1', type: 'Note', content: '<p>hi</p>', attributedTo: 'https://s/users/alice', url: 'https://s/@alice/1' };
10const OEMBED_PAGE = '<html><head><link rel="alternate" type="application/json+oembed" href="https://v.example/oembed?url=x&amp;f=json"><title>t</title></head></html>';
11const OEMBED_JSON = { type: 'video', title: 'A talk', author_name: 'Ada', provider_name: 'Vid', html: '<iframe src="https://v.example/e/1"></iframe>', thumbnail_url: 'https://v.example/t.jpg' };
12
13const io = (over = {}) => ({
14 getAP: async () => null,
15 getPage: async () => null,
16 getJSON: async () => null,
17 actorOf: async () => ({ name: 'Alice', handle: '@alice@s', icon: null }),
18 provider: () => null,
19 ...over,
20});
21
22test('findOEmbedEndpoint reads the json+oembed link and decodes entities', () => {
23 assert.equal(findOEmbedEndpoint(OEMBED_PAGE), 'https://v.example/oembed?url=x&f=json');
24 assert.equal(findOEmbedEndpoint('<html><head></head></html>'), null);
25 // the XML flavour is not parsed
26 assert.equal(findOEmbedEndpoint('<link rel="alternate" type="text/xml+oembed" href="https://x/o">'), null);
27});
28
29test('looksLikeAPObject accepts quotable content, rejects actors and activities', () => {
30 assert.ok(looksLikeAPObject(AP_NOTE));
31 assert.ok(looksLikeAPObject({ id: 'https://s/v/1', type: 'Video' }));
32 assert.ok(!looksLikeAPObject({ id: 'https://s/users/a', type: 'Person' }));
33 assert.ok(!looksLikeAPObject({ id: 'https://s/a/1', type: 'Create' }));
34 assert.ok(!looksLikeAPObject({ type: 'Note' })); // no id
35 assert.ok(!looksLikeAPObject(null));
36});
37
38test('an ActivityPub object resolves over AP, never over oEmbed', async () => {
39 let pageFetched = false;
40 const r = await resolveEmbed('https://s/@alice/1', io({
41 getAP: async () => AP_NOTE,
42 getPage: async () => { pageFetched = true; return OEMBED_PAGE; },
43 getJSON: async () => OEMBED_JSON,
44 provider: () => ({ provider: 'youtube', id: 'x' }), // must not win either
45 }));
46 assert.equal(r.kind, 'ap');
47 assert.equal(r.id, 'https://s/objects/1');
48 assert.equal(r.attributedTo, 'https://s/users/alice');
49 assert.equal(r.author.handle, '@alice@s');
50 assert.equal(r.url, 'https://s/@alice/1');
51 assert.ok(!pageFetched, 'AP wins before any oEmbed discovery happens');
52});
53
54test('a known provider beats oEmbed but loses to AP', async () => {
55 const r = await resolveEmbed('https://youtu.be/abcdefghijk', io({
56 provider: () => ({ provider: 'youtube', id: 'abcdefghijk' }),
57 getPage: async () => OEMBED_PAGE,
58 getJSON: async () => OEMBED_JSON,
59 }));
60 assert.equal(r.kind, 'provider');
61 assert.equal(r.provider, 'youtube');
62});
63
64test('anything else goes through oEmbed discovery', async () => {
65 const r = await resolveEmbed('https://v.example/watch/1', io({
66 getPage: async () => OEMBED_PAGE,
67 getJSON: async () => OEMBED_JSON,
68 }));
69 assert.equal(r.kind, 'oembed');
70 assert.equal(r.title, 'A talk');
71 assert.equal(r.author.name, 'Ada');
72 assert.equal(r.provider, 'Vid');
73 assert.equal(r.media[0].url, 'https://v.example/t.jpg');
74 assert.ok(r.html.startsWith('<iframe'));
75});
76
77test('no oEmbed link, or a dead endpoint, still yields a usable link card', async () => {
78 const noLink = await resolveEmbed('https://plain.example/p', io({ getPage: async () => '<html></html>' }));
79 assert.equal(noLink.kind, 'link');
80 const deadEndpoint = await resolveEmbed('https://v.example/p', io({
81 getPage: async () => OEMBED_PAGE, getJSON: async () => null,
82 }));
83 assert.equal(deadEndpoint.kind, 'link');
84});
85
86test('a failing AP fetch does not abort the pipeline', async () => {
87 const r = await resolveEmbed('https://v.example/p', io({
88 getAP: async () => { throw new Error('boom'); },
89 getPage: async () => OEMBED_PAGE,
90 getJSON: async () => OEMBED_JSON,
91 }));
92 assert.equal(r.kind, 'oembed');
93});
94
95test('non-http input is refused', async () => {
96 assert.equal(await resolveEmbed('javascript:alert(1)', io()), null);
97 assert.equal(await resolveEmbed('', io()), null);
98});
99
100test('fromOEmbed keeps only an http(s) canonical url', () => {
101 const c = fromOEmbed('https://a/b', { url: 'javascript:alert(1)', title: 'x' });
102 assert.equal(c.url, 'https://a/b');
103});
104
105// The live binding: the ordering above stays, but every fetch is capped and
106// goes through safeFetch, so a URL in a post cannot make us probe internals.
107test('liveIO caps oversized bodies and never throws on a bad fetch', async () => {
108 const { liveIO } = await import('../src/services/EmbedResolver.js');
109 const calls = [];
110 const fakeFetch = async (u, o) => {
111 calls.push([u, o.headers.Accept]);
112 if (u.includes('huge')) return { ok: true, headers: { get: () => String(9_000_000) }, text: async () => 'x' };
113 if (u.includes('boom')) throw new Error('refused');
114 return { ok: true, headers: { get: () => '10' }, text: async () => '{"type":"Note","id":"https://s/1"}' };
115 };
116 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null });
117 assert.equal(await io.getPage('https://x/huge'), null, 'oversized body refused');
118 assert.equal(await io.getAP('https://x/boom'), null, 'a refused fetch is not an error');
119 assert.deepEqual(await io.getAP('https://x/ok'), { type: 'Note', id: 'https://s/1' });
120 assert.ok(calls.some((c) => c[1].includes('activity+json')), 'AP asks for activity+json');
121});
122
123// OpenGraph: the one that actually carries link previews on the open web.
124// oEmbed is richer, but most sites simply do not ship it, which is why cards
125// stayed empty until this fallback existed.
126const OG_PAGE = '<html><head><meta property="og:title" content="Linux f&amp;uuml;r Einsteiger">'
127 + '<meta property="og:site_name" content="Linux Guides">'
128 + '<meta property="og:image" content="https://lg.example/tux.png"></head></html>';
129
130test('findOpenGraph reads og:image/title/site and decodes entities', async () => {
131 const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
132 const og = findOpenGraph(OG_PAGE);
133 assert.equal(og.image, 'https://lg.example/tux.png');
134 assert.equal(og.site, 'Linux Guides');
135 assert.ok(og.title.startsWith('Linux f'));
136 assert.equal(findOpenGraph('<html><head><title>x</title></head></html>'), null);
137 assert.equal(findOpenGraph(null), null);
138});
139
140test('findOpenGraph falls back to twitter:image and refuses a non-http image', async () => {
141 const { findOpenGraph } = await import('../src/services/EmbedResolver.js');
142 const tw = findOpenGraph('<meta name="twitter:image" content="https://x/y.png"><meta name="twitter:title" content="T">');
143 assert.equal(tw.image, 'https://x/y.png');
144 const bad = findOpenGraph('<meta property="og:image" content="javascript:alert(1)"><meta property="og:title" content="T">');
145 assert.equal(bad.image, null, 'a non-http image is dropped, the title survives');
146 assert.equal(bad.title, 'T');
147});
148
149test('oEmbed still wins over OpenGraph when a page offers both', async () => {
150 const both = OEMBED_PAGE.replace('</head>', '<meta property="og:title" content="OG"></head>');
151 const r = await resolveEmbed('https://v.example/1', io({
152 getPage: async () => both, getJSON: async () => OEMBED_JSON,
153 }));
154 assert.equal(r.kind, 'oembed');
155 assert.equal(r.title, 'A talk');
156});
157
158test('a page with only OpenGraph yields a thumbnail card', async () => {
159 const r = await resolveEmbed('https://lg.example/artikel', io({ getPage: async () => OG_PAGE }));
160 assert.equal(r.kind, 'opengraph');
161 assert.equal(r.media[0].url, 'https://lg.example/tux.png');
162 assert.equal(r.provider, 'Linux Guides');
163 assert.equal(r.url, 'https://lg.example/artikel');
164});
Note: See TracBrowser for help on using the repository browser.