source: Klonkt/src/services/EmbedResolver.js@ db81e56

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

Publiek oEmbed-register erbij: YouTube verstopt zijn tags voor een server

Waarom het op mijn machine werkte en op de VPS niet: YouTube serveert een
datacenter-IP een uitgeklede pagina. Titel leeg, nul meta property-tags, geen
oEmbed-link. Hun oEmbed-API antwoordt daar wel gewoon (status 200, echte titel).
Discovery via de pagina faalt dus precies waar het het meest uitmaakt.

Het publieke providerregister van oembed.com dicht dat gat, en dat is iets anders
dan de whitelist die we net weggehaald hebben: die lijst onderhouden wij niet, we
lezen er een die gepubliceerd wordt. Valt oembed.com weg, dan vallen we terug op
paginadiscovery en werkt alles gewoon minder slim.

Het register komt VOOR de paginafetch: het is een match in het geheugen plus een
klein API-verzoek, en scheelt dus ook de download van een pagina van een MB.
Eenmaal opgehaald blijft de lijst een dag staan; een stale lijst is beter dan
geen lijst.

Changed files:
src/services/EmbedResolver.js

  • matchProviderEndpoint (wildcard-schemes, host-fallback, {format} invullen)
  • oembedRequestUrl; registry-stap voor de pagina; liveIO cachet de lijst 24u

src/services/ActivityPubService.js

  • SELFHEAL_VERSION 19 -> 20

test/embed-resolver.test.js

  • 4 tests: schemes en host-fallback, url-opbouw, register wint van de pagina (en bespaart de download), en een onbereikbaar register valt netjes terug

remarks: 219 tests groen.

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

  • Property mode set to 100644
File size: 15.4 KB
Line 
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. oEmbed, else OpenGraph → one page fetch, and no list of providers to
10// maintain. Whether an embed may be shown at all is a guardian decision
11// (the gate), not a question of which host it came from.
12// 3. 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
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
63/**
64 * Match a URL against the public oEmbed provider registry (oembed.com).
65 *
66 * Discovery through the page is the pure way, but it only works when the page
67 * hands you its <link rel=oembed>, and big platforms do not always do that: from
68 * a datacentre IP, YouTube serves a stripped page with no oEmbed link and no
69 * OpenGraph at all, while its oEmbed API answers perfectly. The registry closes
70 * that gap without us keeping a list of hosts: it is published and maintained by
71 * oembed.com, we only read it.
72 *
73 * Pure, so the pattern matching is testable without a network.
74 */
75export function matchProviderEndpoint(url, providers) {
76 if (!Array.isArray(providers)) return null;
77 let host = '';
78 try { host = new URL(url).host.replace(/^www\./, ''); } catch { return null; }
79 const toRe = (scheme) => new RegExp('^' + String(scheme)
80 .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
81 .replace(/\*/g, '.*') + '$', 'i');
82 for (const p of providers) {
83 for (const ep of (p.endpoints || [])) {
84 const target = typeof ep.url === 'string' ? ep.url.replace('{format}', 'json') : null;
85 if (!target) continue;
86 for (const scheme of (ep.schemes || [])) {
87 if (toRe(scheme).test(url)) return target;
88 }
89 // No schemes listed: fall back to the provider's own host.
90 if (!ep.schemes || !ep.schemes.length) {
91 let phost = '';
92 try { phost = new URL(p.provider_url).host.replace(/^www\./, ''); } catch { /* skip */ }
93 if (phost && (host === phost || host.endsWith('.' + phost))) return target;
94 }
95 }
96 }
97 return null;
98}
99
100/** Add the url + json format to an oEmbed endpoint. */
101export function oembedRequestUrl(endpoint, url) {
102 const sep = endpoint.includes('?') ? '&' : '?';
103 return `${endpoint}${sep}format=json&url=${encodeURIComponent(url)}`;
104}
105
106/** Is this JSON an ActivityPub object we can quote? */
107export function looksLikeAPObject(doc) {
108 if (!doc || typeof doc !== 'object') return false;
109 const t = Array.isArray(doc.type) ? doc.type[0] : doc.type;
110 if (typeof t !== 'string') return false;
111 // Quotable content, not an actor and not an activity.
112 return ['Note', 'Article', 'Page', 'Video', 'Audio', 'Image', 'Question', 'Event'].includes(t)
113 && typeof doc.id === 'string';
114}
115
116/** An oEmbed payload → the shared card shape. */
117export function fromOEmbed(url, o) {
118 if (!o || typeof o !== 'object') return null;
119 const media = [];
120 if (o.thumbnail_url) media.push({ url: String(o.thumbnail_url), type: 'image/*' });
121 return {
122 kind: 'oembed',
123 url: typeof o.url === 'string' && /^https?:/i.test(o.url) ? o.url : url,
124 title: o.title ? String(o.title) : null,
125 author: (o.author_name || o.provider_name) ? {
126 name: o.author_name ? String(o.author_name) : String(o.provider_name),
127 handle: o.provider_name ? String(o.provider_name) : null,
128 icon: null,
129 } : null,
130 // `html` is the provider's own iframe. Kept separate from the card body so
131 // a caller can decide to frame it or to fall back to the thumbnail; it is
132 // never merged into sanitized note content.
133 html: typeof o.html === 'string' ? o.html : null,
134 provider: o.provider_name ? String(o.provider_name) : null,
135 media,
136 };
137}
138
139/** An AP object → the same shape a resolved quote already uses. */
140export function fromAPObject(url, doc, author) {
141 const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
142 : (doc.attributedTo && typeof doc.attributedTo.id === 'string' ? doc.attributedTo.id : null);
143 return {
144 kind: 'ap',
145 url: (typeof doc.url === 'string' && doc.url) || doc.id || url,
146 id: doc.id,
147 attributedTo: attributed,
148 title: doc.name ? String(doc.name) : null,
149 content: typeof doc.content === 'string' ? doc.content : '',
150 published: doc.published || null,
151 author: author || null,
152 media: [],
153 };
154}
155
156/**
157 * Resolve one URL to the shared card shape.
158 *
159 * @param {string} url
160 * @param {object} io
161 * - getAP(url) → the AP JSON (Accept: application/activity+json) or null
162 * - getPage(url) → the HTML body or null
163 * - getJSON(url) → arbitrary JSON (the oEmbed endpoint) or null
164 * - actorOf(uri) → { name, handle, icon } for the AP author, or null
165 * - provider(url) → the known-provider hit (AudioEmbedService.detectProvider)
166 */
167export async function resolveEmbed(url, io = {}) {
168 if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
169
170 // 1. ActivityPub first: it is the only path that carries quote semantics.
171 if (io.getAP) {
172 const doc = await io.getAP(url).catch(() => null);
173 if (looksLikeAPObject(doc)) {
174 const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
175 : (doc.attributedTo && doc.attributedTo.id);
176 const author = (attributed && io.actorOf) ? await io.actorOf(attributed).catch(() => null) : null;
177 return fromAPObject(url, doc, author);
178 }
179 }
180
181 // 2. The oEmbed registry: a cheap in-memory match, then one small API call.
182 // Tried before the page because it is far cheaper AND because the big
183 // platforms are exactly the ones that hide their tags from a server.
184 if (io.registry && io.getJSON) {
185 const providers = await io.registry().catch(() => null);
186 const endpoint = matchProviderEndpoint(url, providers);
187 if (endpoint) {
188 const o = await io.getJSON(oembedRequestUrl(endpoint, url)).catch(() => null);
189 const card = fromOEmbed(url, o);
190 if (card) return card;
191 }
192 }
193
194 // 3. oEmbed via the page, then OpenGraph. One page fetch serves both: oEmbed
195 // is the richer protocol, OpenGraph is the one most of the web ships.
196 //
197 // There is deliberately NO list of known providers here. A hardcoded list
198 // is a whitelist you have to keep maintaining, and it was actively harmful:
199 // a YouTube link matched the list, short-circuited before oEmbed, and came
200 // out as a card with no title and no thumbnail, so nothing was stored at
201 // all. YouTube serves both oEmbed and og:image like everyone else, so the
202 // generic path handles it better than the special case did.
203 if (io.getPage) {
204 const page = await io.getPage(url).catch(() => null);
205 if (page) {
206 const endpoint = findOEmbedEndpoint(page);
207 if (endpoint && io.getJSON) {
208 const o = await io.getJSON(endpoint).catch(() => null);
209 const card = fromOEmbed(url, o);
210 if (card) return card;
211 }
212 const og = findOpenGraph(page);
213 if (og) {
214 return {
215 kind: 'opengraph',
216 url,
217 title: og.title,
218 author: og.site ? { name: og.site, handle: null, icon: null } : null,
219 provider: og.site,
220 html: null,
221 media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
222 };
223 }
224 }
225 }
226
227 // 4. Nothing recognised it: a link stays a link.
228 return { kind: 'link', url, media: [] };
229}
230
231// ── The wired-up variant ──────────────────────────────────────────
232// The io above is injected so the ordering is testable without a network.
233// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
234// goes through safeFetch (which refuses private ranges and caps redirects),
235// so a hostile URL in a post cannot make the server probe an internal host.
236
237const MAX_JSON = 512_000; // an oEmbed/AP payload must parse whole, so cap and refuse
238// Of a web page we only ever need the <head>. The cap has to clear the worst
239// real case rather than the tidy one: YouTube ships ~665kB of inline script
240// before its og:image and closes <head> at ~673kB, and at 512kB we cut the page
241// off just short of the tags and produced nothing. We stop as soon as the tags
242// are in hand, so a normal page still costs a few dozen kB.
243const MAX_HEAD = 1_048_576;
244const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
245
246/** A whole small document, refused when it is too big to be one. */
247async function safeJsonText(safeFetch, url, accept) {
248 try {
249 const r = await safeFetch(url, { headers: { Accept: accept } });
250 if (!r.ok) return null;
251 if (Number(r.headers.get('content-length') || 0) > MAX_JSON) return null;
252 const body = await r.text();
253 return body.length > MAX_JSON ? null : body; // truncated JSON is useless
254 } catch { return null; }
255}
256
257/**
258 * The START of a web page, streamed and cut off at MAX_HEAD.
259 *
260 * Refusing a page for being large was wrong: YouTube's watch page is megabytes,
261 * so it was rejected outright and never produced a thumbnail, even though its
262 * og:image sits in the first few kilobytes like everyone else's. We only ever
263 * read the <head>, so read that much and stop pulling. The cap still protects
264 * us from someone streaming us an endless body.
265 */
266async function safeHead(safeFetch, url, extra = {}) {
267 try {
268 const r = await safeFetch(url, { headers: { Accept: 'text/html,application/xhtml+xml', ...extra } });
269 if (!r.ok) return null;
270 if (!r.body || typeof r.body.getReader !== 'function') {
271 const body = await r.text(); // no stream (or a test double)
272 return body.length > MAX_HEAD ? body.slice(0, MAX_HEAD) : body;
273 }
274 const reader = r.body.getReader();
275 const dec = new TextDecoder('utf-8');
276 const parts = [];
277 let len = 0;
278 let tail = ''; // carry a little context so a tag split across chunks still matches
279 let done_ = false;
280 while (!done_) {
281 const { done, value } = await reader.read();
282 if (done) break;
283 const chunk = dec.decode(value, { stream: true });
284 parts.push(chunk);
285 len += chunk.length;
286 // Scan only the new chunk (plus overlap), not the whole buffer: testing
287 // the full string every read turns a 1MB page into quadratic work.
288 const window = tail + chunk;
289 // Stop on the real <meta property="og:image">, not on the bare string.
290 // Big sites carry "og:image" inside inline JSON long before the actual
291 // tag, and stopping there cut the page off just short of the meta block:
292 // the same near-miss as the old size cap, with a different cause.
293 if (len >= MAX_HEAD || /<\/head>/i.test(window) || /<meta[^>]{0,300}og:image/i.test(window)) done_ = true;
294 tail = chunk.slice(-512);
295 }
296 try { await reader.cancel(); } catch { /* already closed */ }
297 return parts.join('').slice(0, MAX_HEAD);
298 } catch { return null; }
299}
300
301/**
302 * Bind the resolver to the live fetchers.
303 * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
304 */
305// The provider registry, fetched once and kept for a day. It is a public list
306// maintained by oembed.com, not by us; if it is unreachable we simply fall back
307// to page discovery, so nothing breaks, it just gets less clever.
308const REGISTRY_URL = 'https://oembed.com/providers.json';
309const REGISTRY_TTL = 24 * 60 * 60 * 1000;
310let _registry = null;
311let _registryAt = 0;
312
313export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
314 return {
315 registry: async () => {
316 if (_registry && Date.now() - _registryAt < REGISTRY_TTL) return _registry;
317 const body = await safeJsonText(safeFetch, REGISTRY_URL, 'application/json');
318 if (!body) return _registry; // keep a stale list over none
319 try { _registry = JSON.parse(body); _registryAt = Date.now(); } catch { /* keep the old one */ }
320 return _registry;
321 },
322 provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
323 getAP: async (u) => {
324 const body = await safeJsonText(safeFetch, u, 'application/activity+json, application/ld+json');
325 if (!body) return null;
326 try { return JSON.parse(body); } catch { return null; } // an HTML page is simply not AP
327 },
328 // Plenty of sites only hand out their OpenGraph tags to something that
329 // looks like a browser, so the page fetch identifies itself.
330 getPage: (u) => safeHead(safeFetch, u, { 'User-Agent': UA }),
331 getJSON: async (u) => {
332 const body = await safeJsonText(safeFetch, u, 'application/json');
333 if (!body) return null;
334 try { return JSON.parse(body); } catch { return null; }
335 },
336 actorOf: async (uri) => {
337 if (!fetchActor || !actorInfo) return null;
338 const doc = await fetchActor(uri).catch(() => null);
339 if (!doc) return null;
340 const ai = actorInfo(doc, uri);
341 return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
342 },
343 };
344}
345
346export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, matchProviderEndpoint, oembedRequestUrl, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Note: See TracBrowser for help on using the repository browser.