Changeset bea59a1 in Klonkt


Ignore:
Timestamp:
06/30/2026 05:57:53 AM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
4d63c36
Parents:
69b509d
Message:

feat(federation): keep the Cirkel in sync the AP way — inbound Update + outbox backfill

Two AP-native mechanisms so the Cirkel/timeline stays fresh without a custom poll loop:

  • Inbound Update(Note/Article): a remote edit now refreshes the cached ap_timeline row (and a cached fediverse reply), scoped to the signing actor so B can't edit A's note. Live edit-sync via push — selfHeal was a version-bump workaround for not having this handler.
  • backfillFromOutbox(slug, actorUri): pulls an actor's standard AP outbox and merges their recent top-level posts into the timeline. Fired fire-and-forget when you feature an account (setAutoBoost on) or follow+feature in one step. Covers what push cannot: history from before you followed and deliveries missed while down. PULL ONLY — sends nothing to the fediverse (consistent with the no-auto-fediverse rule).
  • src/services/ActivityPubService.js — Update handler in handleInbox; apGetJson + backfillFromOutbox; wired into setAutoBoost + followActor; exported

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r69b509d rbea59a1  
    819819    return 202;
    820820  }
     821  // A remote post we cached was edited upstream → refresh our cached copy. This is the
     822  // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
     823  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
     824  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
     825  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     826    const o = act.object;
     827    if (o.id && claimedActor) {
     828      const html = HtmlSanitizerService.sanitize(o.content || '');
     829      const media = mediaFromNote(o);
     830      try {
     831        const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ? AND author_uri = ?')
     832          .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.id, claimedActor);
     833        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
     834      } catch { /* ignore */ }
     835      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
     836      try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
     837    }
     838    return 202;
     839  }
    821840  if (type === 'Like' || type === 'Announce') {
    822841    const tgt = act.object;
     
    13711390export function setAutoBoost(slug, actorUri, on) {
    13721391  try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
     1392  // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
     1393  // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
     1394  if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
    13731395  return { ok: true };
    13741396}
     
    14831505  }
    14841506  return JSON.stringify(atts);
     1507}
     1508// A generic SSRF-safe AP GET (collections / pages).
     1509async function apGetJson(url) {
     1510  try {
     1511    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
     1512    if (!r.ok) return null;
     1513    const len = Number(r.headers.get('content-length') || 0);
     1514    if (len > 3_000_000) return null;
     1515    return await r.json();
     1516  } catch { return null; }
     1517}
     1518// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
     1519// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
     1520// history-from-before-you-followed or a delivery that was missed while you were down;
     1521// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
     1522export async function backfillFromOutbox(slug, actorUri, limit = 20) {
     1523  try {
     1524    if (!slug || !actorUri) return 0;
     1525    const actor = await fetchActor(actorUri);
     1526    if (!actor || !actor.outbox) return 0;
     1527    let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
     1528    let items = (page && (page.orderedItems || page.items)) || [];
     1529    if (!items.length && page && page.first) {
     1530      page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
     1531      items = (page && (page.orderedItems || page.items)) || [];
     1532    }
     1533    if (!Array.isArray(items) || !items.length) return 0;
     1534    const ai = actorInfo(actor, actorUri);
     1535    let added = 0;
     1536    for (const it of items.slice(0, limit)) {
     1537      // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
     1538      const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
     1539      if (!o || !o.id) continue;
     1540      if (o.type && o.type !== 'Note' && o.type !== 'Article') continue; // skip boosts/other
     1541      if (o.inReplyTo) continue;                                          // top-level only
     1542      const auth = actorUriOf(o.attributedTo);
     1543      if (auth && auth !== actorUri) continue;                            // their OWN posts only
     1544      const html = HtmlSanitizerService.sanitize(o.content || '');
     1545      try {
     1546        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, o.summary || null);
     1547        if (r && r.changes > 0) added++;
     1548      } catch { /* ignore */ }
     1549    }
     1550    if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
     1551    return added;
     1552  } catch { return 0; }
    14851553}
    14861554let _selfHealing = false;
     
    15391607  catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
    15401608  console.log('[AP] follow', site.slug, '→', actor.id);
     1609  // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
     1610  if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
    15411611  return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
    15421612}
     
    17081778  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    17091779  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    1710   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
     1780  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction,
    17111781  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    17121782  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note: See TracChangeset for help on using the changeset viewer.