Changeset 73a10c2 in Klonkt
- Timestamp:
- 07/30/2026 08:40:28 AM (6 weeks ago)
- Branches:
- main
- Children:
- aa189d1
- Parents:
- 094f7d0
- Files:
-
- 1 added
- 2 edited
-
src/routes/activitypub.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (5 diffs)
-
test/friends-history.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r094f7d0 r73a10c2 82 82 const site = publicSite(req.params.slug); 83 83 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']) { 89 96 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)"; 93 107 const posts = db.prepare( 94 108 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at … … 96 110 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20` 97 111 ).all(site.id); 98 AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), a sGuardian? 'private, no-store' : undefined);112 AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), audience === 'friend' ? 'private, no-store' : undefined); 99 113 }); 100 114 -
src/services/ActivityPubService.js
r094f7d0 r73a10c2 1854 1854 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } } 1855 1855 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 */ }); 1856 1861 return 202; 1857 1862 } … … 3238 3243 3239 3244 // 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 */ 3251 async 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 3240 3271 async function apGetJson(url) { 3241 3272 try { … … 3256 3287 const actor = await fetchActor(actorUri); 3257 3288 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); 3259 3294 let items = (page && (page.orderedItems || page.items)) || []; 3260 3295 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); 3262 3297 items = (page && (page.orderedItems || page.items)) || []; 3263 3298 } … … 3548 3583 3549 3584 // 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 */ 3594 export 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 3550 3605 // guardian of the local ward `wardSlug` — so a signed GET from it may read the 3551 3606 // ward's non-public history without the guardian appearing as a follower. … … 4044 4099 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 4045 4100 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, 4047 4102 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, 4048 4103 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
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)