Changeset 4d64a4b in Klonkt


Ignore:
Timestamp:
08/24/2026 04:28:37 PM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
984e0c6
Parents:
911d5cd
Message:

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.

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r911d5cd r4d64a4b  
    8686  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
    8787};
     88// Stap 7 (shaer-drc): de volgwinkel woont in ap-following.js. fwStmts komt
     89// mee terug voor de Accept-tak van de inbox en de verhuizing (FEP-7628);
     90// wireFollowing krijgt onderaan zijn zes werktuigen.
     91import {
     92  fwStmts, wireFollowing,
     93  webfingerResolve, listFollowing, setAutoBoost,
     94  followActor, resolveRemoteActor, unfollowActor,
     95} from './ap-following.js';
     96export {
     97  webfingerResolve, listFollowing, setAutoBoost,
     98  followActor, resolveRemoteActor, unfollowActor,
     99};
    88100// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    89101export { AP_CONTEXT, actorId, noteId, guessMediaType };
     
    37513763}
    37523764
    3753 // ── Fediverse CLIENT: follow accounts + home timeline ─────────────
    3754 // Resolve an @user@domain handle to its actor URL via WebFinger.
    3755 export async function webfingerResolve(handle) {
    3756   const h = String(handle || '').trim().replace(/^@/, '');
    3757   const parts = h.split('@');
    3758   if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
    3759   const acct = `${parts[0]}@${parts[1]}`;
    3760   try {
    3761     const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
    3762       { headers: { Accept: 'application/jrd+json, application/json' } });
    3763     if (!r.ok) return null;
    3764     const jrd = await r.json();
    3765     const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
    3766     return safeUrl(link ? link.href : '') || null;
    3767   } catch { return null; }
    3768 }
    3769 
    3770 let _insFw, _delFw, _listFw, _accFw, _accFwByActor, _oneFw, _setAB;
    3771 function fwStmts() {
    3772   if (!_insFw) {
    3773     _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)');
    3774     _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
    3775     _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
    3776     _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
    3777     // Terugval als de Accept ons follow-id niet teruggeeft (zie de Accept-tak
    3778     // in handleInbox): dan is het paar dat we WEL zeker weten (deze site, deze
    3779     // actor) genoeg, mits de rij nog op pending staat.
    3780     _accFwByActor = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE slug = ? AND actor_uri = ? AND status = 'pending'");
    3781     _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
    3782     _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
    3783   }
    3784   return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, accByActor: _accFwByActor, one: _oneFw, setAB: _setAB };
    3785 }
    3786 export function listFollowing(slug) { return fwStmts().list.all(slug); }
    3787 
    3788 // Toggle auto-boost ("feature") on an account we already follow.
    3789 export function setAutoBoost(slug, actorUri, on) {
    3790   try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
    3791   // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
    3792   // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
    3793   if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
    3794   return { ok: true };
    3795 }
    37963765
    37973766
     
    38363805}
    38373806
    3838 // Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
    3839 // /ap/users/<slug> (content negotiation; Location may be relative). Used by
    3840 // followActor for bare-domain follows.
    3841 // NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
    3842 // REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
    3843 // never throws anything into the fediverse automatically" (would surprise-Follow
    3844 // for some operators at scale). The dead circle_links table stays as harmless dead
    3845 // data; an operator restores an old cirkel by re-following in /following (their click).
    3846 async function resolveApActor(siteUrl) {
    3847   try {
    3848     const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
    3849     if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
    3850     if (r.ok) return siteUrl;
    3851   } catch { /* unreachable */ }
    3852   return null;
    3853 }
    38543807
    38553808// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
     
    44194372
    44204373// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
    4421 export async function followActor(site, handle, autoBoost = false, { approved = false } = {}) {
    4422   const _mv = movedRefusal(site, 'follow'); if (_mv) return _mv;
    4423   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    4424   if (!base || !site || !site.slug) return { error: 'config' };
    4425   // DE POORT STAAT HIER, en niet alleen in de C2S-outbox (shaer-p729, Barts
    4426   // melding 8-8: de volgverzoeken van Esmee kwamen nooit bij haar guardians
    4427   // aan). Hij stond in `case 'Follow'` van de outbox -- dus alleen als je via
    4428   // Shaer volgt. Volgde het kind vanuit Klonkts eigen webinterface, dan werd er
    4429   // geen verzoek aangemaakt, ging er niets naar de guardians, en was er dus ook
    4430   // niets om te beantwoorden. Precies dezelfde deur-naast-de-poort als bij de
    4431   // antwoordpoort vanmiddag (shaer-r4c).
    4432   //
    4433   // Merk op wat het NIET was: niet dat een guardian elders het niet kon
    4434   // beantwoorden. Die weg werkt en levert een Offer af bij de externe guardian.
    4435   // Er kwam alleen nooit iets aan om af te leveren.
    4436   //
    4437   // `approved` is de enige doorlaat, voor performApprovedFollow: zonder dat zou
    4438   // een goedgekeurd verzoek opnieuw op de poort stuiten en voor eeuwig wachten.
    4439 
    4440   // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
    4441   // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
    4442   // resolves to its AP actor, so you can follow a site by just its domain.
    4443   const s = String(handle || '').trim();
    4444   let actorUrl;
    4445   if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
    4446   else if (s.includes('@')) actorUrl = await webfingerResolve(s);
    4447   else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
    4448   else actorUrl = null;
    4449   if (!actorUrl) return { error: 'not_found' };
    4450   // NA het oplossen, want een kind volgt net zo goed met @naam@server of een
    4451   // kaal domein. Zou de poort alleen naar de ruwe invoer kijken, dan is elke
    4452   // handle een sluiproute -- en dat is precies de fout die we hier repareren,
    4453   // een maat kleiner.
    4454   if (!approved) {
    4455     const held = await gateOutgoingFollow(site, actorUrl);
    4456     if (held) return { held: true, id: held.id, status: held.status || 'pending' };
    4457   }
    4458   // SIGNED, as this actor: an authorized-fetch instance refuses an anonymous
    4459   // GET of the actor doc, which made following from a boost silently fail
    4460   // (Robins melding, 31-7). Signed, the other side sees who asks.
    4461   const actor = await signedGetJson(site.slug, actorUrl);
    4462   if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
    4463   const ai = actorInfo(actor, actor.id);
    4464   const me = actorId(base, site.slug);
    4465   const keys = getOrCreateKeys(site.slug);
    4466   const followId = `${me}#follow-${Date.now()}-${rid()}`;
    4467   fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
    4468   const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
    4469   // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
    4470   // timeout, transient 5xx) is retried with backoff instead of staying stuck on
    4471   // 'pending' forever — the Accept can only come back once the Follow lands.
    4472   await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
    4473   console.log('[AP] follow', site.slug, '→', actor.id);
    4474   // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
    4475   if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
    4476   // A ward's guardians are TOLD about a new follow (Robins verzoek, 31-7):
    4477   // a follow brings new content into the child's feed, and the village
    4478   // should know the door opened. A direct note per guardian, best-effort;
    4479   // FEP-633c 5.3 gates inbound follows, the outbound notice is Shaer policy
    4480   // for now (bead: spec-vraag).
    4481   try {
    4482     const guardians = Guardianship.listGuardians(site.slug);
    4483     if (guardians.length) {
    4484       const meRef = actorId(base, site.slug);
    4485       const esc = (t) => String(t).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
    4486       const label = esc(ai.name || ai.handle || actor.id);
    4487       for (const g of guardians) {
    4488         const note = {
    4489           id: `${meRef}/follow-notice/${Date.now().toString(36)}${rid()}`,
    4490           type: 'Note', attributedTo: meRef, to: [g.other_uri],
    4491           tag: [{ type: 'Mention', href: g.other_uri }],
    4492           content: `<p>👀 ${esc(site.title || site.slug)} is now following ${label}.</p>`,
    4493         };
    4494         deliverToActor(site, g.other_uri, { id: `${note.id}#create`, type: 'Create', actor: meRef, to: [g.other_uri], object: note })
    4495           .catch(() => { /* retried by the queue */ });
    4496       }
    4497       console.log('[AP] follow notice →', guardians.length, 'guardian(s) of', site.slug);
    4498     }
    4499   } catch { /* geen guardians is geen fout */ }
    4500   return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
    4501 }
    4502 
    4503 // Resolve a profile URL or @handle to a followable remote actor (for the
    4504 // authorize_interaction "Follow" flow). Returns display fields + inbox, or null
    4505 // when it isn't a reachable actor (e.g. the input was a post, not a profile).
    4506 export async function resolveRemoteActor(input) {
    4507   const s = String(input || '').trim();
    4508   const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
    4509   if (!actorUrl) return null;
    4510   const actor = await fetchActor(actorUrl).catch(() => null);
    4511   if (!actor || !actor.id || !actor.inbox) return null;
    4512   const ai = actorInfo(actor, actor.id);
    4513   return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
    4514 }
    4515 
    4516 export async function unfollowActor(site, actorUri) {
    4517   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    4518   const me = actorId(base, site.slug);
    4519   const keys = getOrCreateKeys(site.slug);
    4520   const row = fwStmts().one.get(site.slug, actorUri);
    4521   // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
    4522   // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
    4523   // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
    4524   // rather than send an unmatchable one. Deliver durably via the retry queue.
    4525   if (row && row.inbox && row.follow_id) {
    4526     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 } };
    4527     deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
    4528   } else if (row && row.inbox) {
    4529     console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
    4530   }
    4531   fwStmts().del.run(site.slug, actorUri);
    4532   return { ok: true };
    4533 }
    45344374
    45354375/**
     
    55085348// Het reactiecluster zijn ene werktuig (stap 6): de verhuisgrendel (FEP-7628).
    55095349wireReactions({ movedLock });
     5350// De volgwinkel zijn zes werktuigen (stap 7): de verhuisweigering, de
     5351// §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers.
     5352wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor });
    55105353
    55115354export default {
Note: See TracChangeset for help on using the changeset viewer.