| 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 | */
|
|---|
| 15 | import db from '../config/database.js';
|
|---|
| 16 | import * as Guardianship from './guardianship/index.js';
|
|---|
| 17 | import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
|
|---|
| 18 | import { safeFetch, signedGetJson, fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
|
|---|
| 19 |
|
|---|
| 20 | // De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
|
|---|
| 21 | let movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
|
|---|
| 22 | deliverToActor;
|
|---|
| 23 | export 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.
|
|---|
| 30 | export 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 |
|
|---|
| 45 | let _insFw, _delFw, _listFw, _accFw, _accFwByActor, _oneFw, _setAB;
|
|---|
| 46 | export 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 | }
|
|---|
| 61 | export function listFollowing(slug) { return fwStmts().list.all(slug); }
|
|---|
| 62 |
|
|---|
| 63 | // Toggle auto-boost ("feature") on an account we already follow.
|
|---|
| 64 | export 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).
|
|---|
| 80 | async 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 |
|
|---|
| 89 | export 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) => ({ '<': '<', '>': '>', '&': '&' }[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 | export 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 |
|
|---|
| 184 | export 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 | }
|
|---|