source: Klonkt/src/services/EmbedResolver.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: 9.5 KB
RevLine 
[6bc2e31b]1// One pipeline for everything you can drop a URL of into a post, and one visual
2// result. What differs is not what a reader sees but what FEDERATES.
3//
4// Resolution order (Robins besluit, shaer-277):
5// 1. ActivityPub object → the FEP path. A quote of a fediverse object carries
6// real semantics: FEP-044f `quote` + an FEP-e232 Link tag, the quoted
7// author gets addressed, and the permission model applies. Never resolved
8// over oEmbed, because oEmbed has none of that.
9// 2. Known provider → the existing player (YouTube/Spotify/Bandcamp/…).
10// 3. oEmbed discovery → the generic path, and the preferred implementation
11// for everything outside the fediverse.
12// 4. Otherwise → a plain link.
13//
14// Everything returns the SAME normalised shape, so one renderer draws them all
15// (the quote card). Pure except for the two injected fetchers, so the ordering
16// logic is unit-testable without a network.
17
18const OEMBED_LINK = /<link\b[^>]*>/gi;
19
20/** Pull the oEmbed endpoint out of a page's <link rel="alternate"> tags. */
21export function findOEmbedEndpoint(html) {
22 if (!html || typeof html !== 'string') return null;
23 for (const tag of html.match(OEMBED_LINK) || []) {
24 const type = (tag.match(/\btype\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
25 if (!/application\/(json|xml)\+oembed/i.test(type)) continue;
26 const rel = (tag.match(/\brel\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
27 if (rel && !/alternate/i.test(rel)) continue;
28 const href = (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1];
29 // JSON only: we do not parse the XML flavour.
30 if (href && /json/i.test(type)) return decodeEntities(href);
31 }
32 return null;
33}
34
35function decodeEntities(s) {
36 return String(s).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
37}
38
[0101d0a]39/**
40 * OpenGraph, the one that actually carries link previews on the open web.
41 * oEmbed is the richer protocol but most sites simply do not implement it;
42 * og:image / og:title is what Mastodon and everyone else reads, so it is the
43 * fallback that makes thumbnails appear at all. Same page fetch as the oEmbed
44 * discovery, so it costs nothing extra.
45 */
46export function findOpenGraph(html) {
47 if (!html || typeof html !== 'string') return null;
48 const meta = {};
49 for (const tag of html.match(/<meta\b[^>]*>/gi) || []) {
50 const key = (tag.match(/\b(?:property|name)\s*=\s*["']([^"']+)["']/i) || [])[1];
51 if (!key) continue;
52 const k = key.toLowerCase();
53 if (!/^(og:image|og:title|og:site_name|og:description|twitter:image|twitter:title)$/.test(k)) continue;
54 const val = (tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1];
55 if (val && !meta[k]) meta[k] = decodeEntities(val);
56 }
57 const image = meta['og:image'] || meta['twitter:image'];
58 const title = meta['og:title'] || meta['twitter:title'];
59 if (!image && !title) return null;
60 return { image: image && /^https?:\/\//i.test(image) ? image : null, title: title || null, site: meta['og:site_name'] || null };
61}
62
[6bc2e31b]63/** Is this JSON an ActivityPub object we can quote? */
64export function looksLikeAPObject(doc) {
65 if (!doc || typeof doc !== 'object') return false;
66 const t = Array.isArray(doc.type) ? doc.type[0] : doc.type;
67 if (typeof t !== 'string') return false;
68 // Quotable content, not an actor and not an activity.
69 return ['Note', 'Article', 'Page', 'Video', 'Audio', 'Image', 'Question', 'Event'].includes(t)
70 && typeof doc.id === 'string';
71}
72
73/** An oEmbed payload → the shared card shape. */
74export function fromOEmbed(url, o) {
75 if (!o || typeof o !== 'object') return null;
76 const media = [];
77 if (o.thumbnail_url) media.push({ url: String(o.thumbnail_url), type: 'image/*' });
78 return {
79 kind: 'oembed',
80 url: typeof o.url === 'string' && /^https?:/i.test(o.url) ? o.url : url,
81 title: o.title ? String(o.title) : null,
82 author: (o.author_name || o.provider_name) ? {
83 name: o.author_name ? String(o.author_name) : String(o.provider_name),
84 handle: o.provider_name ? String(o.provider_name) : null,
85 icon: null,
86 } : null,
87 // `html` is the provider's own iframe. Kept separate from the card body so
88 // a caller can decide to frame it or to fall back to the thumbnail; it is
89 // never merged into sanitized note content.
90 html: typeof o.html === 'string' ? o.html : null,
91 provider: o.provider_name ? String(o.provider_name) : null,
92 media,
93 };
94}
95
96/** An AP object → the same shape a resolved quote already uses. */
97export function fromAPObject(url, doc, author) {
98 const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
99 : (doc.attributedTo && typeof doc.attributedTo.id === 'string' ? doc.attributedTo.id : null);
100 return {
101 kind: 'ap',
102 url: (typeof doc.url === 'string' && doc.url) || doc.id || url,
103 id: doc.id,
104 attributedTo: attributed,
105 title: doc.name ? String(doc.name) : null,
106 content: typeof doc.content === 'string' ? doc.content : '',
107 published: doc.published || null,
108 author: author || null,
109 media: [],
110 };
111}
112
113/**
114 * Resolve one URL to the shared card shape.
115 *
116 * @param {string} url
117 * @param {object} io
118 * - getAP(url) → the AP JSON (Accept: application/activity+json) or null
119 * - getPage(url) → the HTML body or null
120 * - getJSON(url) → arbitrary JSON (the oEmbed endpoint) or null
121 * - actorOf(uri) → { name, handle, icon } for the AP author, or null
122 * - provider(url) → the known-provider hit (AudioEmbedService.detectProvider)
123 */
124export async function resolveEmbed(url, io = {}) {
125 if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
126
127 // 1. ActivityPub first: it is the only path that carries quote semantics.
128 if (io.getAP) {
129 const doc = await io.getAP(url).catch(() => null);
130 if (looksLikeAPObject(doc)) {
131 const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
132 : (doc.attributedTo && doc.attributedTo.id);
133 const author = (attributed && io.actorOf) ? await io.actorOf(attributed).catch(() => null) : null;
134 return fromAPObject(url, doc, author);
135 }
136 }
137
138 // 2. A provider we already play ourselves.
139 if (io.provider) {
140 const p = io.provider(url);
141 if (p) return { kind: 'provider', url, provider: p.provider, id: p.id || null, media: [] };
142 }
143
[0101d0a]144 // 3. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer
145 // protocol, OpenGraph is the one most of the web actually ships.
146 if (io.getPage) {
[6bc2e31b]147 const page = await io.getPage(url).catch(() => null);
[0101d0a]148 if (page) {
149 const endpoint = findOEmbedEndpoint(page);
150 if (endpoint && io.getJSON) {
151 const o = await io.getJSON(endpoint).catch(() => null);
152 const card = fromOEmbed(url, o);
153 if (card) return card;
154 }
155 const og = findOpenGraph(page);
156 if (og) {
157 return {
158 kind: 'opengraph',
159 url,
160 title: og.title,
161 author: og.site ? { name: og.site, handle: null, icon: null } : null,
162 provider: og.site,
163 html: null,
164 media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
165 };
166 }
[6bc2e31b]167 }
168 }
169
170 // 4. Nothing recognised it: a link stays a link.
171 return { kind: 'link', url, media: [] };
172}
173
174// ── The wired-up variant ──────────────────────────────────────────
175// The io above is injected so the ordering is testable without a network.
176// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
177// goes through safeFetch (which refuses private ranges and caps redirects),
178// so a hostile URL in a post cannot make the server probe an internal host.
179
180const MAX_BODY = 512_000; // an oEmbed page/endpoint is small; refuse the rest
[0101d0a]181const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
[6bc2e31b]182
[0101d0a]183async function safeText(safeFetch, url, accept, extra = {}) {
[6bc2e31b]184 try {
[0101d0a]185 const r = await safeFetch(url, { headers: { Accept: accept, ...extra } });
[6bc2e31b]186 if (!r.ok) return null;
187 if (Number(r.headers.get('content-length') || 0) > MAX_BODY) return null;
188 const body = await r.text();
189 return body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body;
190 } catch { return null; }
191}
192
193/**
194 * Bind the resolver to the live fetchers.
195 * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
196 */
197export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
198 return {
199 provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
200 getAP: async (u) => {
201 const body = await safeText(safeFetch, u, 'application/activity+json, application/ld+json');
202 if (!body) return null;
203 try { return JSON.parse(body); } catch { return null; } // an HTML page is simply not AP
204 },
[0101d0a]205 // Plenty of sites only hand out their OpenGraph tags to something that
206 // looks like a browser, so the page fetch identifies itself.
207 getPage: (u) => safeText(safeFetch, u, 'text/html', { 'User-Agent': UA }),
[6bc2e31b]208 getJSON: async (u) => {
209 const body = await safeText(safeFetch, u, 'application/json');
210 if (!body) return null;
211 try { return JSON.parse(body); } catch { return null; }
212 },
213 actorOf: async (uri) => {
214 if (!fetchActor || !actorInfo) return null;
215 const doc = await fetchActor(uri).catch(() => null);
216 if (!doc) return null;
217 const ai = actorInfo(doc, uri);
218 return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
219 },
220 };
221}
222
[0101d0a]223export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Note: See TracBrowser for help on using the repository browser.