source: Klonkt/src/services/EmbedResolver.js@ 3f32994

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

Thumbnails ontbraken: providerlijst eruit, en grote pagina-s worden gelezen i.p.v. geweigerd

Twee bugs die elkaar maskeerden, allebei van mij.

DE PROVIDERLIJST WAS HET PROBLEEM, NIET DE OPLOSSING. Een YouTube-link matchte de
hardcoded lijst, kortsloot voor oEmbed, en kwam eruit als een kaart zonder titel
en zonder thumbnail. Er werd dus niets opgeslagen. YouTube levert gewoon oEmbed
en og:image, net als de rest, dus de generieke weg doet het beter dan het
speciale geval. De lijst is weg: geen whitelist meer om te onderhouden, en of een
embed getoond mag worden is een guardian-besluit, geen kwestie van welke host het
is.

DE BODY-CAP SLOEG DE KOP ERAF. Een pagina werd geweigerd als hij groot was.
YouTube propt ~665kB inline script voor zijn og:image en sluit <head> pas op
673kB, dus we knipten net voor de tags af en hielden niets over. Nu lezen we een
pagina vanaf het BEGIN en stoppen zodra we de tags hebben (of </head>), met 1MB
als achtervang. Een normale pagina kost daardoor nog steeds een paar tientallen
kB. Het scannen kijkt alleen naar het nieuwe stuk plus wat overlap, anders wordt
een pagina van 1MB kwadratisch werk.

Echt getest, niet aangenomen: youtube.com en youtu.be leveren nu titel +
thumbnail (oembed, ~2s), linuxguides ook, boiert.eu via opengraph in 144ms.

Changed files:
src/services/EmbedResolver.js

  • providerstap verwijderd uit de keten (AudioEmbedService blijft voor de web-spelers)
  • safeHead: streamt de kop, stopt bij og:image of </head>, cap 1MB
  • safeJsonText: JSON moet heel zijn, dus daar blijft weigeren juist

src/services/ActivityPubService.js

  • self-heal 16 -> 17, en nu ook rijen die eerder niets opleverden opnieuw proberen

test/embed-resolver.test.js

  • regressietest: een videohost is geen speciaal geval en krijgt een echte kaart
  • regressietest: een grote pagina wordt afgekapt, niet geweigerd

remarks: 214 tests groen.

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

  • Property mode set to 100644
File size: 12.0 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.
[c52dc82]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.
[6bc2e31b]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
[c52dc82]138 // 2. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer
[0101d0a]139 // protocol, OpenGraph is the one most of the web actually ships.
[c52dc82]140 //
141 // There is deliberately NO list of known providers here. A hardcoded list
142 // is a whitelist you have to keep maintaining, and it was actively harmful:
143 // a YouTube link matched the list, short-circuited before oEmbed, and came
144 // out as a card with no title and no thumbnail, so nothing was stored at
145 // all. YouTube serves both oEmbed and og:image like everyone else, so the
146 // generic path handles it better than the special case did.
[0101d0a]147 if (io.getPage) {
[6bc2e31b]148 const page = await io.getPage(url).catch(() => null);
[0101d0a]149 if (page) {
150 const endpoint = findOEmbedEndpoint(page);
151 if (endpoint && io.getJSON) {
152 const o = await io.getJSON(endpoint).catch(() => null);
153 const card = fromOEmbed(url, o);
154 if (card) return card;
155 }
156 const og = findOpenGraph(page);
157 if (og) {
158 return {
159 kind: 'opengraph',
160 url,
161 title: og.title,
162 author: og.site ? { name: og.site, handle: null, icon: null } : null,
163 provider: og.site,
164 html: null,
165 media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
166 };
167 }
[6bc2e31b]168 }
169 }
170
171 // 4. Nothing recognised it: a link stays a link.
172 return { kind: 'link', url, media: [] };
173}
174
175// ── The wired-up variant ──────────────────────────────────────────
176// The io above is injected so the ordering is testable without a network.
177// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
178// goes through safeFetch (which refuses private ranges and caps redirects),
179// so a hostile URL in a post cannot make the server probe an internal host.
180
[c52dc82]181const MAX_JSON = 512_000; // an oEmbed/AP payload must parse whole, so cap and refuse
182// Of a web page we only ever need the <head>. The cap has to clear the worst
183// real case rather than the tidy one: YouTube ships ~665kB of inline script
184// before its og:image and closes <head> at ~673kB, and at 512kB we cut the page
185// off just short of the tags and produced nothing. We stop as soon as the tags
186// are in hand, so a normal page still costs a few dozen kB.
187const MAX_HEAD = 1_048_576;
[0101d0a]188const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
[6bc2e31b]189
[c52dc82]190/** A whole small document, refused when it is too big to be one. */
191async function safeJsonText(safeFetch, url, accept) {
[6bc2e31b]192 try {
[c52dc82]193 const r = await safeFetch(url, { headers: { Accept: accept } });
[6bc2e31b]194 if (!r.ok) return null;
[c52dc82]195 if (Number(r.headers.get('content-length') || 0) > MAX_JSON) return null;
[6bc2e31b]196 const body = await r.text();
[c52dc82]197 return body.length > MAX_JSON ? null : body; // truncated JSON is useless
198 } catch { return null; }
199}
200
201/**
202 * The START of a web page, streamed and cut off at MAX_HEAD.
203 *
204 * Refusing a page for being large was wrong: YouTube's watch page is megabytes,
205 * so it was rejected outright and never produced a thumbnail, even though its
206 * og:image sits in the first few kilobytes like everyone else's. We only ever
207 * read the <head>, so read that much and stop pulling. The cap still protects
208 * us from someone streaming us an endless body.
209 */
210async function safeHead(safeFetch, url, extra = {}) {
211 try {
212 const r = await safeFetch(url, { headers: { Accept: 'text/html,application/xhtml+xml', ...extra } });
213 if (!r.ok) return null;
214 if (!r.body || typeof r.body.getReader !== 'function') {
215 const body = await r.text(); // no stream (or a test double)
216 return body.length > MAX_HEAD ? body.slice(0, MAX_HEAD) : body;
217 }
218 const reader = r.body.getReader();
219 const dec = new TextDecoder('utf-8');
220 const parts = [];
221 let len = 0;
222 let tail = ''; // carry a little context so a tag split across chunks still matches
223 let done_ = false;
224 while (!done_) {
225 const { done, value } = await reader.read();
226 if (done) break;
227 const chunk = dec.decode(value, { stream: true });
228 parts.push(chunk);
229 len += chunk.length;
230 // Scan only the new chunk (plus overlap), not the whole buffer: testing
231 // the full string every read turns a 1MB page into quadratic work.
232 const window = tail + chunk;
233 if (len >= MAX_HEAD || /<\/head>/i.test(window) || /og:image/i.test(window)) done_ = true;
234 tail = chunk.slice(-512);
235 }
236 try { await reader.cancel(); } catch { /* already closed */ }
237 return parts.join('').slice(0, MAX_HEAD);
[6bc2e31b]238 } catch { return null; }
239}
240
241/**
242 * Bind the resolver to the live fetchers.
243 * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
244 */
245export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
246 return {
247 provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
248 getAP: async (u) => {
[c52dc82]249 const body = await safeJsonText(safeFetch, u, 'application/activity+json, application/ld+json');
[6bc2e31b]250 if (!body) return null;
251 try { return JSON.parse(body); } catch { return null; } // an HTML page is simply not AP
252 },
[0101d0a]253 // Plenty of sites only hand out their OpenGraph tags to something that
254 // looks like a browser, so the page fetch identifies itself.
[c52dc82]255 getPage: (u) => safeHead(safeFetch, u, { 'User-Agent': UA }),
[6bc2e31b]256 getJSON: async (u) => {
[c52dc82]257 const body = await safeJsonText(safeFetch, u, 'application/json');
[6bc2e31b]258 if (!body) return null;
259 try { return JSON.parse(body); } catch { return null; }
260 },
261 actorOf: async (uri) => {
262 if (!fetchActor || !actorInfo) return null;
263 const doc = await fetchActor(uri).catch(() => null);
264 if (!doc) return null;
265 const ai = actorInfo(doc, uri);
266 return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
267 },
268 };
269}
270
[0101d0a]271export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Note: See TracBrowser for help on using the repository browser.