Changeset c52dc82 in Klonkt
- Timestamp:
- 07/28/2026 08:37:08 AM (6 weeks ago)
- Branches:
- main
- Children:
- 3f32994
- Parents:
- 0101d0a
- Files:
-
- 3 edited
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
src/services/EmbedResolver.js (modified) (5 diffs)
-
test/embed-resolver.test.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r0101d0a rc52dc82 2849 2849 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone 2850 2850 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync. 2851 const SELFHEAL_VERSION = 1 6; // v16: resolve external link previews (embed_json) for posts that predate the embed pipeline2851 const SELFHEAL_VERSION = 17; // v17: re-resolve link previews now that OpenGraph works and big pages are read instead of refused 2852 2852 async function fetchNoteAP(url) { 2853 2853 try { … … 3190 3190 // quote (a quote already IS the card) and no embed yet, so this costs 3191 3191 // one page fetch per candidate and never repeats. 3192 if (!r.quote_json && !r.embed_json) {3192 if (!r.quote_json) { 3193 3193 const ej = await resolveExternalEmbed(html || r.content).catch(() => null); 3194 3194 if (ej) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id); } catch { /* ignore */ } } -
src/services/EmbedResolver.js
r0101d0a rc52dc82 7 7 // author gets addressed, and the permission model applies. Never resolved 8 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 implementation11 // for everything outside the fediverse.12 // 4. Otherwise → a plain link.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 13 // 14 14 // Everything returns the SAME normalised shape, so one renderer draws them all … … 136 136 } 137 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 144 // 3. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer 138 // 2. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer 145 139 // protocol, OpenGraph is the one most of the web actually ships. 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. 146 147 if (io.getPage) { 147 148 const page = await io.getPage(url).catch(() => null); … … 178 179 // so a hostile URL in a post cannot make the server probe an internal host. 179 180 180 const MAX_BODY = 512_000; // an oEmbed page/endpoint is small; refuse the rest 181 const 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. 187 const MAX_HEAD = 1_048_576; 181 188 const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)'; 182 189 183 async function safeText(safeFetch, url, accept, extra = {}) { 190 /** A whole small document, refused when it is too big to be one. */ 191 async function safeJsonText(safeFetch, url, accept) { 184 192 try { 185 const r = await safeFetch(url, { headers: { Accept: accept , ...extra} });193 const r = await safeFetch(url, { headers: { Accept: accept } }); 186 194 if (!r.ok) return null; 187 if (Number(r.headers.get('content-length') || 0) > MAX_ BODY) return null;195 if (Number(r.headers.get('content-length') || 0) > MAX_JSON) return null; 188 196 const body = await r.text(); 189 return body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body; 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 */ 210 async 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); 190 238 } catch { return null; } 191 239 } … … 199 247 provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null, 200 248 getAP: async (u) => { 201 const body = await safe Text(safeFetch, u, 'application/activity+json, application/ld+json');249 const body = await safeJsonText(safeFetch, u, 'application/activity+json, application/ld+json'); 202 250 if (!body) return null; 203 251 try { return JSON.parse(body); } catch { return null; } // an HTML page is simply not AP … … 205 253 // Plenty of sites only hand out their OpenGraph tags to something that 206 254 // looks like a browser, so the page fetch identifies itself. 207 getPage: (u) => safe Text(safeFetch, u, 'text/html', { 'User-Agent': UA }),255 getPage: (u) => safeHead(safeFetch, u, { 'User-Agent': UA }), 208 256 getJSON: async (u) => { 209 const body = await safe Text(safeFetch, u, 'application/json');257 const body = await safeJsonText(safeFetch, u, 'application/json'); 210 258 if (!body) return null; 211 259 try { return JSON.parse(body); } catch { return null; } -
test/embed-resolver.test.js
r0101d0a rc52dc82 52 52 }); 53 53 54 test('a known provider beats oEmbed but loses to AP', async () => { 54 // Regression: a hardcoded provider list used to short-circuit here and return a 55 // card with no title and no thumbnail, so a YouTube link ended up storing 56 // nothing at all. There is no provider list any more; every non-fediverse URL 57 // takes the generic path, which is exactly what gives it a thumbnail. 58 test('a video host is not special-cased and still gets a real card', async () => { 55 59 const r = await resolveEmbed('https://youtu.be/abcdefghijk', io({ 56 provider: () => ({ provider: 'youtube', id: 'abcdefghijk' }), 60 provider: () => ({ provider: 'youtube', id: 'abcdefghijk' }), // ignored on purpose 57 61 getPage: async () => OEMBED_PAGE, 58 62 getJSON: async () => OEMBED_JSON, 59 63 })); 60 assert.equal(r.kind, 'provider'); 61 assert.equal(r.provider, 'youtube'); 64 assert.equal(r.kind, 'oembed'); 65 assert.equal(r.title, 'A talk'); 66 assert.ok(r.media[0].url, 'and it has a thumbnail, which the old path never produced'); 62 67 }); 63 68 … … 115 120 }; 116 121 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null }); 117 assert.equal(await io.getPage('https://x/huge'), null, 'oversized body refused'); 122 // A JSON payload must parse whole, so an oversized one is refused outright. 123 assert.equal(await io.getJSON('https://x/huge'), null, 'oversized JSON refused'); 118 124 assert.equal(await io.getAP('https://x/boom'), null, 'a refused fetch is not an error'); 119 125 assert.deepEqual(await io.getAP('https://x/ok'), { type: 'Note', id: 'https://s/1' }); … … 163 169 assert.equal(r.url, 'https://lg.example/artikel'); 164 170 }); 171 172 // Regression, the one that kept YouTube blank: a page is read from the START and 173 // cut off, never refused for being large. Refusing it meant no thumbnail at all 174 // for exactly the sites people share most. 175 test('a huge page is truncated, not rejected', async () => { 176 const { liveIO } = await import('../src/services/EmbedResolver.js'); 177 const big = '<html><head>' + 'x'.repeat(5000) + '<meta property="og:title" content="T">' 178 + '<meta property="og:image" content="https://x/i.png"></head></html>'; 179 const fakeFetch = async () => ({ ok: true, headers: { get: () => String(9_000_000) }, text: async () => big }); 180 const io = liveIO({ safeFetch: fakeFetch, detectProvider: () => null }); 181 const page = await io.getPage('https://x/huge'); 182 assert.ok(page && page.includes('og:image'), 'the head survives the cap'); 183 });
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)