Changeset 4d64a4b in Klonkt
- Timestamp:
- 08/24/2026 04:28:37 PM (2 weeks ago)
- Branches:
- main
- Children:
- 984e0c6
- Parents:
- 911d5cd
- Location:
- src/services
- Files:
-
- 1 added
- 1 edited
-
ActivityPubService.js (modified) (5 diffs)
-
ap-following.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r911d5cd r4d64a4b 86 86 setReaction, getTimelineReaction, upsertBoostedNote, boostedCount, 87 87 }; 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. 91 import { 92 fwStmts, wireFollowing, 93 webfingerResolve, listFollowing, setAutoBoost, 94 followActor, resolveRemoteActor, unfollowActor, 95 } from './ap-following.js'; 96 export { 97 webfingerResolve, listFollowing, setAutoBoost, 98 followActor, resolveRemoteActor, unfollowActor, 99 }; 88 100 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken. 89 101 export { AP_CONTEXT, actorId, noteId, guessMediaType }; … … 3751 3763 } 3752 3764 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-tak3778 // in handleInbox): dan is het paar dat we WEL zeker weten (deze site, deze3779 // 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 next3792 // 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 }3796 3765 3797 3766 … … 3836 3805 } 3837 3806 3838 // Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to3839 // /ap/users/<slug> (content negotiation; Location may be relative). Used by3840 // followActor for bare-domain follows.3841 // NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was3842 // REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code3843 // never throws anything into the fediverse automatically" (would surprise-Follow3844 // for some operators at scale). The dead circle_links table stays as harmless dead3845 // 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 }3854 3807 3855 3808 // ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ── … … 4419 4372 4420 4373 // 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, Barts4426 // melding 8-8: de volgverzoeken van Esmee kwamen nooit bij haar guardians4427 // aan). Hij stond in `case 'Follow'` van de outbox -- dus alleen als je via4428 // Shaer volgt. Volgde het kind vanuit Klonkts eigen webinterface, dan werd er4429 // geen verzoek aangemaakt, ging er niets naar de guardians, en was er dus ook4430 // niets om te beantwoorden. Precies dezelfde deur-naast-de-poort als bij de4431 // antwoordpoort vanmiddag (shaer-r4c).4432 //4433 // Merk op wat het NIET was: niet dat een guardian elders het niet kon4434 // 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 zou4438 // 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 a4441 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root4442 // 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 een4451 // kaal domein. Zou de poort alleen naar de ruwe invoer kijken, dan is elke4452 // 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 anonymous4459 // GET of the actor doc, which made following from a boost silently fail4460 // (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 on4471 // '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 village4478 // 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 policy4480 // 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) => ({ '<': '<', '>': '>', '&': '&' }[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 the4504 // authorize_interaction "Follow" flow). Returns display fields + inbox, or null4505 // 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 it4522 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow4523 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo4524 // 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 }4534 4374 4535 4375 /** … … 5508 5348 // Het reactiecluster zijn ene werktuig (stap 6): de verhuisgrendel (FEP-7628). 5509 5349 wireReactions({ 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. 5352 wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor }); 5510 5353 5511 5354 export default {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)