source: Klonkt/src/services/ap-following.js@ 76bd53c

main
Last change on this file since 76bd53c was 86cfa1a, checked in by Robin <roboburr@…>, 9 hours ago

Een profiel op mastodon.social laadt: ook het profiel wordt ondertekend opgehaald

Plak je ​https://mastodon.social/@hotdogsladies in de zoekbalk, dan kwam er
"niet gevonden"; ​https://arvr.social/@bedrijfzondernaam liet gewoon een profiel
zien. Allebei Mastodon. Het verschil is authorized fetch: mastodon.social geeft
een onbetekende GET van een profiel een 401 {"error":"Request not signed"},
arvr.social serveert hem aan iedereen.

fetchActor kan dat al aan -- eerst onbetekend, en tekenen als dat niet lukt --
maar alleen als de aanroeper zegt als welke site. resolveRemoteActor gaf dat
nooit door, dus tekende nooit. De ironie: de zoekbalk probeert eerst "is het een
post?", en dat verzoek gaat WEL ondertekend en krijgt het profiel ook terug, maar
gooit het weg omdat het geen post is. Daarna haalde de profielstap hetzelfde
adres opnieuw op, onbetekend.

Gemeten op dev voor de wijziging: onbetekend niets, ondertekend als dev het
profiel met inbox. arvr.social in beide gevallen gevonden, en daar gaat ook
geen ondertekend verzoek uit: dat is alleen de terugval.

ALLEEN ALS JE INGELOGD BENT (Robin, 30-9). Tekenen is deze site die met haar
eigen sleutel voor het verzoek instaat, en dat mag alleen namens een ingelogde
beheerder van die site. resolveRemoteActor blijft zonder opts onbetekend; de
enige aanroepers die de site meegeven zijn de zoekbalk (achter mayLookUp) en
/authorize_interaction (requireSiteManager).

De volgknop had dit gat niet: followActor haalt al sinds 31-7 ondertekend op.

Getoetst: het profiel ondertekend op de zoekpagina, in de preview en op de
interactiepagina, en dat niet ingelogd geen enkel verzoek uitgaat, dus ook geen
ondertekend. Tegenproef: onbetekend in de zoekbalk, onbetekend op de
interactiepagina en de inlogcontrole eruit laten elk hun eigen toets omvallen.
Volledige suite 1286 groen.

shaer-utpi

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

  • Property mode set to 100644
File size: 12.3 KB
Line 
1/**
2 * ap-following.js — de volgwinkel (stap 7 van shaer-drc).
3 *
4 * Alles rond ap_following: WebFinger, de statements, de lijst en de
5 * auto-boost-knop, en de drie federatiehandelingen (followActor,
6 * resolveRemoteActor, unfollowActor).
7 *
8 * fwStmts exporteert mee: de Accept-tak van de inbox en de verhuizing
9 * (FEP-7628) schrijven de winkel bij en blijven in de dienst wonen -- zelfde
10 * verhouding als tlStmts bij ap-timeline. De poortwachter (gateOutgoingFollow)
11 * en zijn goedkeuring (performApprovedFollow) blijven daar ook: de eerste komt
12 * hier via injectie binnen, de tweede roept followActor gewoon via de dienst
13 * aan, en zo is er geen kring.
14 */
15import db from '../config/database.js';
16import * as Guardianship from './guardianship/index.js';
17import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
18import { safeFetch, signedGetJson, fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
19
20// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
21let movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
22 deliverToActor;
23export function wireFollowing(deps) {
24 ({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
25 deliverToActor } = deps);
26}
27
28// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
29// Resolve an @user@domain handle to its actor URL via WebFinger.
30export async function webfingerResolve(handle) {
31 const h = String(handle || '').trim().replace(/^@/, '');
32 const parts = h.split('@');
33 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
34 const acct = `${parts[0]}@${parts[1]}`;
35 try {
36 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
37 { headers: { Accept: 'application/jrd+json, application/json' } });
38 if (!r.ok) return null;
39 const jrd = await r.json();
40 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
41 return safeUrl(link ? link.href : '') || null;
42 } catch { return null; }
43}
44
45let _insFw, _delFw, _listFw, _accFw, _accFwByActor, _oneFw, _setAB;
46export function fwStmts() {
47 if (!_insFw) {
48 _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, auto_boost, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
49 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
50 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
51 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
52 // Terugval als de Accept ons follow-id niet teruggeeft (zie de Accept-tak
53 // in handleInbox): dan is het paar dat we WEL zeker weten (deze site, deze
54 // actor) genoeg, mits de rij nog op pending staat.
55 _accFwByActor = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE slug = ? AND actor_uri = ? AND status = 'pending'");
56 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
57 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
58 }
59 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, accByActor: _accFwByActor, one: _oneFw, setAB: _setAB };
60}
61export function listFollowing(slug) { return fwStmts().list.all(slug); }
62
63// Toggle auto-boost ("feature") on an account we already follow.
64export function setAutoBoost(slug, actorUri, on) {
65 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
66 // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
67 // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
68 if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
69 return { ok: true };
70}
71
72// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
73// /ap/users/<slug> (content negotiation; Location may be relative). Used by
74// followActor for bare-domain follows.
75// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
76// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
77// never throws anything into the fediverse automatically" (would surprise-Follow
78// for some operators at scale). The dead circle_links table stays as harmless dead
79// data; an operator restores an old cirkel by re-following in /following (their click).
80async function resolveApActor(siteUrl) {
81 try {
82 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
83 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
84 if (r.ok) return siteUrl;
85 } catch { /* unreachable */ }
86 return null;
87}
88
89export async function followActor(site, handle, autoBoost = false, { approved = false } = {}) {
90 const _mv = movedRefusal(site, 'follow'); if (_mv) return _mv;
91 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
92 if (!base || !site || !site.slug) return { error: 'config' };
93 // DE POORT STAAT HIER, en niet alleen in de C2S-outbox (shaer-p729, Barts
94 // melding 8-8: de volgverzoeken van Esmee kwamen nooit bij haar guardians
95 // aan). Hij stond in `case 'Follow'` van de outbox -- dus alleen als je via
96 // Shaer volgt. Volgde het kind vanuit Klonkts eigen webinterface, dan werd er
97 // geen verzoek aangemaakt, ging er niets naar de guardians, en was er dus ook
98 // niets om te beantwoorden. Precies dezelfde deur-naast-de-poort als bij de
99 // antwoordpoort vanmiddag (shaer-r4c).
100 //
101 // Merk op wat het NIET was: niet dat een guardian elders het niet kon
102 // beantwoorden. Die weg werkt en levert een Offer af bij de externe guardian.
103 // Er kwam alleen nooit iets aan om af te leveren.
104 //
105 // `approved` is de enige doorlaat, voor performApprovedFollow: zonder dat zou
106 // een goedgekeurd verzoek opnieuw op de poort stuiten en voor eeuwig wachten.
107
108 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
109 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
110 // resolves to its AP actor, so you can follow a site by just its domain.
111 const s = String(handle || '').trim();
112 let actorUrl;
113 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
114 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
115 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
116 else actorUrl = null;
117 if (!actorUrl) return { error: 'not_found' };
118 // NA het oplossen, want een kind volgt net zo goed met @naam@server of een
119 // kaal domein. Zou de poort alleen naar de ruwe invoer kijken, dan is elke
120 // handle een sluiproute -- en dat is precies de fout die we hier repareren,
121 // een maat kleiner.
122 if (!approved) {
123 const held = await gateOutgoingFollow(site, actorUrl);
124 if (held) return { held: true, id: held.id, status: held.status || 'pending' };
125 }
126 // SIGNED, as this actor: an authorized-fetch instance refuses an anonymous
127 // GET of the actor doc, which made following from a boost silently fail
128 // (Robins melding, 31-7). Signed, the other side sees who asks.
129 const actor = await signedGetJson(site.slug, actorUrl);
130 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
131 const ai = actorInfo(actor, actor.id);
132 const me = actorId(base, site.slug);
133 const keys = getOrCreateKeys(site.slug);
134 const followId = `${me}#follow-${Date.now()}-${rid()}`;
135 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
136 const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
137 // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
138 // timeout, transient 5xx) is retried with backoff instead of staying stuck on
139 // 'pending' forever — the Accept can only come back once the Follow lands.
140 await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
141 console.log('[AP] follow', site.slug, '→', actor.id);
142 // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
143 if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
144 // A ward's guardians are TOLD about a new follow (Robins verzoek, 31-7):
145 // a follow brings new content into the child's feed, and the village
146 // should know the door opened. A direct note per guardian, best-effort;
147 // FEP-633c 5.3 gates inbound follows, the outbound notice is Shaer policy
148 // for now (bead: spec-vraag).
149 try {
150 const guardians = Guardianship.listGuardians(site.slug);
151 if (guardians.length) {
152 const meRef = actorId(base, site.slug);
153 const esc = (t) => String(t).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
154 const label = esc(ai.name || ai.handle || actor.id);
155 for (const g of guardians) {
156 const note = {
157 id: `${meRef}/follow-notice/${Date.now().toString(36)}${rid()}`,
158 type: 'Note', attributedTo: meRef, to: [g.other_uri],
159 tag: [{ type: 'Mention', href: g.other_uri }],
160 content: `<p>👀 ${esc(site.title || site.slug)} is now following ${label}.</p>`,
161 };
162 deliverToActor(site, g.other_uri, { id: `${note.id}#create`, type: 'Create', actor: meRef, to: [g.other_uri], object: note })
163 .catch(() => { /* retried by the queue */ });
164 }
165 console.log('[AP] follow notice →', guardians.length, 'guardian(s) of', site.slug);
166 }
167 } catch { /* geen guardians is geen fout */ }
168 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
169}
170
171// Resolve a profile URL or @handle to a followable remote actor (for the
172// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
173// when it isn't a reachable actor (e.g. the input was a post, not a profile).
174//
175// ONDERTEKEND ALLEEN ALS DE AANROEPER DE SITE NOEMT (opts.asSlug). Een instance
176// met authorized fetch (mastodon.social) geeft een onbetekende GET een 401, en
177// dan leek een bestaand profiel te ontbreken -- terwijl een ander Mastodon
178// zonder die instelling het wel toonde. fetchActor probeert eerst onbetekend en
179// tekent pas als dat niet lukt.
180//
181// Tekenen is deze site die voor het verzoek instaat, met haar eigen sleutel.
182// Dat mag alleen namens een ingelogde beheerder van die site: de zoekbalk
183// (achter mayLookUp) en /authorize_interaction (requireSiteManager) zijn de
184// enige die asSlug meegeven. Zonder opts blijft dit onbetekend.
185export async function resolveRemoteActor(input, opts = {}) {
186 const s = String(input || '').trim();
187 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
188 if (!actorUrl) return null;
189 const actor = await fetchActor(actorUrl, opts.asSlug ? { asSlug: opts.asSlug } : {}).catch(() => null);
190 if (!actor || !actor.id || !actor.inbox) return null;
191 const ai = actorInfo(actor, actor.id);
192 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
193}
194
195export async function unfollowActor(site, actorUri) {
196 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
197 const me = actorId(base, site.slug);
198 const keys = getOrCreateKeys(site.slug);
199 const row = fwStmts().one.get(site.slug, actorUri);
200 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
201 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
202 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
203 // rather than send an unmatchable one. Deliver durably via the retry queue.
204 if (row && row.inbox && row.follow_id) {
205 const undo = { '@context': AP_CONTEXT, id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id, type: 'Follow', actor: me, object: actorUri } };
206 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
207 } else if (row && row.inbox) {
208 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
209 }
210 fwStmts().del.run(site.slug, actorUri);
211 return { ok: true };
212}
Note: See TracBrowser for help on using the repository browser.