Changeset 73a10c2 in Klonkt


Ignore:
Timestamp:
07/30/2026 08:40:28 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
aa189d1
Parents:
094f7d0
Message:

Vrienden krijgen de geschiedenis mee, en een block blijft een dichte deur

Robins besluit (30-7): wie later vriend wordt, moet de oudere friends-only
posts alsnog kunnen zien. Dat kon niet: friends-only was een moment-opname
(bezorgd aan de volgers van dat moment) en de outbox verborg fan-only juist,
dus de backfill die bij een nieuw volgen draait had niets op te halen.

Drie stukken, samen de lus:

  1. De outbox antwoordt naar PUBLIEK: outboxAudience beslist per lezer. De eigenaar (bearer) en een geverifieerde geaccepteerde volger of guardian krijgen de friends-only geschiedenis mee; anoniem en een vreemde met alleen een handtekening krijgen de publieke set, precies als voorheen. Bijvangst: de auteur ziet nu ook zijn EIGEN friends-only posts in de app.
  2. De backfill identificeert zich: signedGetJson tekent de GET als de volgende site ((request-target) host date, wat verifyRequest checkt), dus de servende kant herkent de vriend. Een server die de handtekening negeert gedraagt zich exact als vroeger.
  3. Het moment dat de vriendschap ontstaat is het moment dat de geschiedenis meekomt: op de Accept van onze Follow draait de backfill.

En Robins scherpe toevoeging: een geverifieerde caller die deze instance
BLOKKEERT krijgt een LEGE collectie, niet eens de publieke set. Een block is
een dichte deur, en een gesigneerde fetch is aankloppen met je naam erop. De
block wint ook van een verweesd volger-rijtje.

Changed files:
src/services/ActivityPubService.js

  • signedGetJson; backfill gesigneerd; backfill op Accept(Follow); outboxAudience als pure, testbare beslissing

src/routes/activitypub.js

  • de outbox-route beslist via outboxAudience; blocked = lege set

New file:
test/friends-history.test.js

  • anoniem blijft publiek; identificatie is geen vriendschap; volger en eigenaar lezen de geschiedenis; block wint van alles, ook van een stale volger-rij

remarks: 341 tests groen, server start.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r094f7d0 r73a10c2  
    8282  const site = publicSite(req.params.slug);
    8383  if (!site) return res.status(404).end();
    84   // Authorized fetch (FEP-633c §5.3 note): a committed guardian doing a SIGNED
    85   // GET may read the ward's fan-only history too, without appearing as a
    86   // follower. Unsigned / non-guardian callers get the public collection only.
    87   let asGuardian = false;
    88   if (req.headers['signature']) {
     84  // Authorized fetch (30-7): who is asking decides what they see.
     85  //  - the owner's own app (bearer) and a verified accepted follower or
     86  //    guardian get the friends-only history too, so a NEW friend's backfill
     87  //    brings the past along (Robins besluit: vrienden krijgen de
     88  //    geschiedenis mee);
     89  //  - a verified caller this instance BLOCKS gets an EMPTY collection, not
     90  //    even the public set: a block is a closed door, and a signed fetch is
     91  //    the caller knocking with their name on it;
     92  //  - everyone else gets the public collection, exactly as before.
     93  const bearer = OAuth.verifyBearer(req.headers.authorization);
     94  let verifiedActor = null;
     95  if (!bearer && req.headers['signature']) {
    8996    const verified = await AP.verifyRequest(req).catch(() => null);
    90     asGuardian = !!(verified && AP.isWardGuardian(req.params.slug, verified.id));
    91   }
    92   const fanClause = asGuardian ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
     97    verifiedActor = verified && verified.id;
     98  }
     99  const audience = AP.outboxAudience(req.params.slug, {
     100    bearerSlug: bearer ? bearer.site.slug : null,
     101    verifiedActor,
     102  });
     103  if (audience === 'blocked') {
     104    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, []), 'private, no-store');
     105  }
     106  const fanClause = audience === 'friend' ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
    93107  const posts = db.prepare(
    94108    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
     
    96110     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
    97111  ).all(site.id);
    98   AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), asGuardian ? 'private, no-store' : undefined);
     112  AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), audience === 'friend' ? 'private, no-store' : undefined);
    99113});
    100114
  • src/services/ActivityPubService.js

    r094f7d0 r73a10c2  
    18541854    if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
    18551855    console.log('[AP] follow accepted', actorUri);
     1856    // The moment a friendship exists is the moment the history comes along
     1857    // (Robins besluit, 30-7): delivery cannot reach into the past, so the
     1858    // fresh follower pulls the outbox, signed, and the other side now serves
     1859    // the friends-only posts too.
     1860    if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
    18561861    return 202;
    18571862  }
     
    32383243
    32393244// A generic SSRF-safe AP GET (collections / pages).
     3245/**
     3246 * A signed GET as one of our local actors (friends-history, 30-7): the remote
     3247 * server can then recognise the caller and serve what THAT caller may see,
     3248 * exactly like the guardian's authorized fetch. The signature covers
     3249 * (request-target) host date, the set verifyRequest checks.
     3250 */
     3251async function signedGetJson(slug, url) {
     3252  try {
     3253    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3254    if (!base || !slug) return apGetJson(url);
     3255    const me = actorId(base, slug);
     3256    const keys = getOrCreateKeys(slug);
     3257    const u = new URL(url);
     3258    const date = new Date().toUTCString();
     3259    const target = `${u.pathname}${u.search || ''}`;
     3260    const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
     3261    const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
     3262    const sig = `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`;
     3263    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json', Date: date, Signature: sig } });
     3264    if (!r.ok) return null;
     3265    const len = Number(r.headers.get('content-length') || 0);
     3266    if (len > 3_000_000) return null;
     3267    return await r.json();
     3268  } catch { return null; }
     3269}
     3270
    32403271async function apGetJson(url) {
    32413272  try {
     
    32563287    const actor = await fetchActor(actorUri);
    32573288    if (!actor || !actor.outbox) return 0;
    3258     let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
     3289    // Signed as the follower (30-7): the serving side recognises an accepted
     3290    // friend and hands the friends-only history along; an anonymous GET only
     3291    // ever sees the public set. A server that ignores the signature behaves
     3292    // exactly as before.
     3293    let page = await signedGetJson(slug, typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
    32593294    let items = (page && (page.orderedItems || page.items)) || [];
    32603295    if (!items.length && page && page.first) {
    3261       page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
     3296      page = await signedGetJson(slug, typeof page.first === 'string' ? page.first : page.first.id);
    32623297      items = (page && (page.orderedItems || page.items)) || [];
    32633298    }
     
    35483583
    35493584// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
     3585/**
     3586 * Who is reading this outbox, and what may they see (30-7)?
     3587 *  - 'blocked': a verified caller this instance blocks. They get an EMPTY
     3588 *    collection, not even the public set (Robins eis): a block is a closed
     3589 *    door, and a signed fetch is the caller knocking with their name on it.
     3590 *  - 'friend': the owner (bearer) or a verified accepted follower or
     3591 *    guardian: the fan-only history rides along.
     3592 *  - 'public': everyone else: the public set.
     3593 */
     3594export function outboxAudience(slug, { bearerSlug = null, verifiedActor = null } = {}) {
     3595  if (bearerSlug && bearerSlug === slug) return 'friend';
     3596  if (!verifiedActor) return 'public';
     3597  if (isBlockedAny(verifiedActor)) return 'blocked';
     3598  try {
     3599    if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
     3600  } catch { /* table absent on fresh init */ }
     3601  if (isWardGuardian(slug, verifiedActor)) return 'friend';
     3602  return 'public';
     3603}
     3604
    35503605// guardian of the local ward `wardSlug` — so a signed GET from it may read the
    35513606// ward's non-public history without the guardian appearing as a follower.
     
    40444099  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    40454100  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    4046   acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
     4101  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
    40474102  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    40484103  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Note: See TracChangeset for help on using the changeset viewer.