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

main
Last change on this file since db81e56 was 4d64a4b, checked in by Robin <roboburr@…>, 2 weeks ago

Opsplitsing stap 7 (shaer-drc): de volgwinkel naar ap-following.js

Alles rond ap_following verhuist als drie blokken -- 172 regels,
byte-voor-byte: WebFinger, de statements met de lijst en de
auto-boost-knop, resolveApActor, en de drie federatiehandelingen
(followActor, resolveRemoteActor, unfollowActor).

fwStmts exporteert mee voor de schrijvers die in de dienst blijven (de
Accept-tak van de inbox en de verhuizing, FEP-7628) -- zelfde verhouding
als tlStmts bij ap-timeline. De poortwachter en zijn goedkeuring blijven
ook daar: gateOutgoingFollow komt via wireFollowing binnen (met
movedRefusal, actorInfo, rid, backfillFromOutbox en deliverToActor), en
performApprovedFollow roept followActor via de dienst aan, dus er is
geen kring.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 5383 regels.

  • Property mode set to 100644
File size: 11.6 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).
174export async function resolveRemoteActor(input) {
175 const s = String(input || '').trim();
176 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
177 if (!actorUrl) return null;
178 const actor = await fetchActor(actorUrl).catch(() => null);
179 if (!actor || !actor.id || !actor.inbox) return null;
180 const ai = actorInfo(actor, actor.id);
181 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
182}
183
184export async function unfollowActor(site, actorUri) {
185 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
186 const me = actorId(base, site.slug);
187 const keys = getOrCreateKeys(site.slug);
188 const row = fwStmts().one.get(site.slug, actorUri);
189 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
190 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
191 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
192 // rather than send an unmatchable one. Deliver durably via the retry queue.
193 if (row && row.inbox && row.follow_id) {
194 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 } };
195 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
196 } else if (row && row.inbox) {
197 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
198 }
199 fwStmts().del.run(site.slug, actorUri);
200 return { ok: true };
201}
Note: See TracBrowser for help on using the repository browser.