source: Klonkt/src/services/EmbedResolver.js@ 6bc2e31b

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

Een embed-pijplijn: AP eerst volgens de FEP, dan provider, dan oEmbed

Robins besluit: shaer-ibs (oEmbed) en shaer-277 (fediverse-embeds) worden een
ding. Een lezer ziet straks dezelfde kaart, of het nu een geciteerde
fediverse-post is of een ingesloten video. Het verschil zit niet in wat je ziet
maar in wat er FEDEREERT, en dus in de volgorde van resolutie:

  1. ActivityPub-object -> het FEP-pad. Een quote van een fediverse-object draagt echte semantiek (FEP-044f quote + FEP-e232 Link-tag, de geciteerde auteur wordt geadresseerd, permissions gelden). Nooit via oEmbed, want daar zit niets van dat alles in.
  2. Bekende provider -> de bestaande speler (YouTube/Spotify/Bandcamp/...).
  3. oEmbed-discovery -> de generieke weg, en de voorkeur voor alles buiten de fediverse.
  4. Anders -> een kale link blijft een kale link.

De io is geinjecteerd, zodat de volgorde te testen is zonder netwerk. liveIO
bindt 'm aan de echte fetchers: alles via safeFetch (weigert private ranges,
begrenst redirects) en met een body-cap, zodat een vijandige URL in een post de
server niet aan het rondsnuffelen krijgt op interne hosts.

New file:
src/services/EmbedResolver.js

  • resolveEmbed met de vier stappen, alle vier naar dezelfde kaartvorm
  • findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject
  • liveIO: SSRF-veilige binding met body-cap

test/embed-resolver.test.js

  • 10 tests: volgorde (AP wint van provider en oEmbed, provider wint van oEmbed), oEmbed-discovery, degradatie naar link, en dat liveIO grote bodies weigert en niet gooit op een geweigerde fetch

remarks: 200 tests groen. Dit is de kern; nog aan te sluiten (fase 2, zie de
bead): de compose-kant (URL in een post -> kaart), het emitten van de quote
richting de fediverse met notificatie aan de auteur, en de render die de
bestaande quote-kaart hergebruikt voor alle vier de soorten.

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

  • Property mode set to 100644
File size: 7.6 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. 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
39/** Is this JSON an ActivityPub object we can quote? */
40export function looksLikeAPObject(doc) {
41 if (!doc || typeof doc !== 'object') return false;
42 const t = Array.isArray(doc.type) ? doc.type[0] : doc.type;
43 if (typeof t !== 'string') return false;
44 // Quotable content, not an actor and not an activity.
45 return ['Note', 'Article', 'Page', 'Video', 'Audio', 'Image', 'Question', 'Event'].includes(t)
46 && typeof doc.id === 'string';
47}
48
49/** An oEmbed payload → the shared card shape. */
50export function fromOEmbed(url, o) {
51 if (!o || typeof o !== 'object') return null;
52 const media = [];
53 if (o.thumbnail_url) media.push({ url: String(o.thumbnail_url), type: 'image/*' });
54 return {
55 kind: 'oembed',
56 url: typeof o.url === 'string' && /^https?:/i.test(o.url) ? o.url : url,
57 title: o.title ? String(o.title) : null,
58 author: (o.author_name || o.provider_name) ? {
59 name: o.author_name ? String(o.author_name) : String(o.provider_name),
60 handle: o.provider_name ? String(o.provider_name) : null,
61 icon: null,
62 } : null,
63 // `html` is the provider's own iframe. Kept separate from the card body so
64 // a caller can decide to frame it or to fall back to the thumbnail; it is
65 // never merged into sanitized note content.
66 html: typeof o.html === 'string' ? o.html : null,
67 provider: o.provider_name ? String(o.provider_name) : null,
68 media,
69 };
70}
71
72/** An AP object → the same shape a resolved quote already uses. */
73export function fromAPObject(url, doc, author) {
74 const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
75 : (doc.attributedTo && typeof doc.attributedTo.id === 'string' ? doc.attributedTo.id : null);
76 return {
77 kind: 'ap',
78 url: (typeof doc.url === 'string' && doc.url) || doc.id || url,
79 id: doc.id,
80 attributedTo: attributed,
81 title: doc.name ? String(doc.name) : null,
82 content: typeof doc.content === 'string' ? doc.content : '',
83 published: doc.published || null,
84 author: author || null,
85 media: [],
86 };
87}
88
89/**
90 * Resolve one URL to the shared card shape.
91 *
92 * @param {string} url
93 * @param {object} io
94 * - getAP(url) → the AP JSON (Accept: application/activity+json) or null
95 * - getPage(url) → the HTML body or null
96 * - getJSON(url) → arbitrary JSON (the oEmbed endpoint) or null
97 * - actorOf(uri) → { name, handle, icon } for the AP author, or null
98 * - provider(url) → the known-provider hit (AudioEmbedService.detectProvider)
99 */
100export async function resolveEmbed(url, io = {}) {
101 if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
102
103 // 1. ActivityPub first: it is the only path that carries quote semantics.
104 if (io.getAP) {
105 const doc = await io.getAP(url).catch(() => null);
106 if (looksLikeAPObject(doc)) {
107 const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
108 : (doc.attributedTo && doc.attributedTo.id);
109 const author = (attributed && io.actorOf) ? await io.actorOf(attributed).catch(() => null) : null;
110 return fromAPObject(url, doc, author);
111 }
112 }
113
114 // 2. A provider we already play ourselves.
115 if (io.provider) {
116 const p = io.provider(url);
117 if (p) return { kind: 'provider', url, provider: p.provider, id: p.id || null, media: [] };
118 }
119
120 // 3. oEmbed: the generic path for everything else.
121 if (io.getPage && io.getJSON) {
122 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;
128 }
129 }
130
131 // 4. Nothing recognised it: a link stays a link.
132 return { kind: 'link', url, media: [] };
133}
134
135// ── The wired-up variant ──────────────────────────────────────────
136// The io above is injected so the ordering is testable without a network.
137// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
138// goes through safeFetch (which refuses private ranges and caps redirects),
139// so a hostile URL in a post cannot make the server probe an internal host.
140
141const MAX_BODY = 512_000; // an oEmbed page/endpoint is small; refuse the rest
142
143async function safeText(safeFetch, url, accept) {
144 try {
145 const r = await safeFetch(url, { headers: { Accept: accept } });
146 if (!r.ok) return null;
147 if (Number(r.headers.get('content-length') || 0) > MAX_BODY) return null;
148 const body = await r.text();
149 return body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body;
150 } catch { return null; }
151}
152
153/**
154 * Bind the resolver to the live fetchers.
155 * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
156 */
157export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
158 return {
159 provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
160 getAP: async (u) => {
161 const body = await safeText(safeFetch, u, 'application/activity+json, application/ld+json');
162 if (!body) return null;
163 try { return JSON.parse(body); } catch { return null; } // an HTML page is simply not AP
164 },
165 getPage: (u) => safeText(safeFetch, u, 'text/html'),
166 getJSON: async (u) => {
167 const body = await safeText(safeFetch, u, 'application/json');
168 if (!body) return null;
169 try { return JSON.parse(body); } catch { return null; }
170 },
171 actorOf: async (uri) => {
172 if (!fetchActor || !actorInfo) return null;
173 const doc = await fetchActor(uri).catch(() => null);
174 if (!doc) return null;
175 const ai = actorInfo(doc, uri);
176 return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
177 },
178 };
179}
180
181export default { resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Note: See TracBrowser for help on using the repository browser.