Changeset 0101d0a in Klonkt
- Timestamp:
- 07/28/2026 08:22:37 AM (6 weeks ago)
- Branches:
- main
- Children:
- c52dc82
- Parents:
- b258a79
- Files:
-
- 3 edited
-
src/services/ActivityPubService.js (modified) (3 diffs)
-
src/services/EmbedResolver.js (modified) (5 diffs)
-
test/embed-resolver.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
rb258a79 r0101d0a 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 5; // v15: re-capture emoji_json/link_json for boosts that arrived before the Announce-path tag fix2851 const SELFHEAL_VERSION = 16; // v16: resolve external link previews (embed_json) for posts that predate the embed pipeline 2852 2852 async function fetchNoteAP(url) { 2853 2853 try { … … 3159 3159 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots 3160 3160 let rows = []; 3161 try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }3161 try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ } 3162 3162 let healed = 0, failed = 0; 3163 3163 for (const r of rows) { … … 3185 3185 const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri); 3186 3186 if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } } 3187 } 3188 // v16: link previews. A post from before the embed pipeline has no 3189 // card at all, which is why nothing showed. Only for rows that have no 3190 // quote (a quote already IS the card) and no embed yet, so this costs 3191 // one page fetch per candidate and never repeats. 3192 if (!r.quote_json && !r.embed_json) { 3193 const ej = await resolveExternalEmbed(html || r.content).catch(() => null); 3194 if (ej) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id); } catch { /* ignore */ } } 3187 3195 } 3188 3196 // v14: same for the booster's display name ("X boosted"). The row stores -
src/services/EmbedResolver.js
rb258a79 r0101d0a 35 35 function decodeEntities(s) { 36 36 return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/'/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 */ 46 export 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 }; 37 61 } 38 62 … … 118 142 } 119 143 120 // 3. oEmbed: the generic path for everything else. 121 if (io.getPage && io.getJSON) { 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) { 122 147 const page = await io.getPage(url).catch(() => null); 123 const endpoint = findOEmbedEndpoint(page); 124 if (endpoint) { 125 const o = await io.getJSON(endpoint).catch(() => null); 126 const card = fromOEmbed(url, o); 127 if (card) return card; 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 } 128 167 } 129 168 } … … 140 179 141 180 const MAX_BODY = 512_000; // an oEmbed page/endpoint is small; refuse the rest 142 143 async function safeText(safeFetch, url, accept) { 181 const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)'; 182 183 async function safeText(safeFetch, url, accept, extra = {}) { 144 184 try { 145 const r = await safeFetch(url, { headers: { Accept: accept } });185 const r = await safeFetch(url, { headers: { Accept: accept, ...extra } }); 146 186 if (!r.ok) return null; 147 187 if (Number(r.headers.get('content-length') || 0) > MAX_BODY) return null; … … 163 203 try { return JSON.parse(body); } catch { return null; } // an HTML page is simply not AP 164 204 }, 165 getPage: (u) => safeText(safeFetch, u, 'text/html'), 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 }), 166 208 getJSON: async (u) => { 167 209 const body = await safeText(safeFetch, u, 'application/json'); … … 179 221 } 180 222 181 export default { resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };223 export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO }; -
test/embed-resolver.test.js
rb258a79 r0101d0a 120 120 assert.ok(calls.some((c) => c[1].includes('activity+json')), 'AP asks for activity+json'); 121 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. 126 const OG_PAGE = '<html><head><meta property="og:title" content="Linux f&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 130 test('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 140 test('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 149 test('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 158 test('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 TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)