Changeset 04d5aeb in Klonkt
- Timestamp:
- 08/02/2026 09:42:34 PM (5 weeks ago)
- Branches:
- main
- Children:
- 5327324
- Parents:
- f3a58a4
- Files:
-
- 1 added
- 2 edited
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
test/reply-friends-only.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
rf3a58a4 r04d5aeb 585 585 586 586 // ── Note ────────────────────────────────────────────────────────── 587 router.get('/ap/notes/:id', (req, res) => { 587 router.get('/ap/notes/:id', async (req, res) => { 588 // No fan_only filter in the SELECT anymore: a friends-only post is not 589 // absent, it is GATED. The old route hid it from EVERYONE, also from the 590 // follower whose friendship earns it — so the signed resolution the reply 591 // path performs knocked on a door that could never open, and every reply 592 // to a friends-only post (Shaer's default!) died in 593 // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a 594 // note's existence stays as private as before. 588 595 const post = db.prepare( 589 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"596 "SELECT * FROM posts WHERE id = ? AND status = 'published'" 590 597 ).get(req.params.id); 598 if (post && AP.noteAudience(post) !== 'public') { 599 // The whole gate in a try: this is the only async route in this file, 600 // and Express 4 does not catch an async rejection — the request would 601 // hang forever instead of failing (which is exactly how the missing 602 // default-export entry manifested while building this). Any error here 603 // reads as "not authorized", never as silence. 604 try { 605 if (AP.noteAudience(post) === 'direct') return res.status(404).end(); 606 const gsite = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id); 607 const actor = await AP.verifyRequest(req).catch(() => null); 608 if (!actor || !AP.mayReadNote(gsite, post, actor.id)) return res.status(404).end(); 609 } catch { return res.status(404).end(); } 610 } 591 611 if (!post) { 592 612 // Could be one of OUR outbound replies (ap_outbox), not a post. -
src/services/ActivityPubService.js
rf3a58a4 r04d5aeb 947 947 const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } }; 948 948 // Extract our local post id from a note URL, but only if it's ours (base match). 949 // One host, two spellings (Barts WebFinger-les, 2-8): a URL the client hands 950 // back may carry the punycoded host (every URL parser silently punycodes) 951 // while PUBLIC_BASE_URL carries the typed one. WHATWG URL does the IDNA, so 952 // compare origins in ASCII and never the bytes the client happened to send. 953 function asciiOrigin(u) { 954 try { const x = new URL(String(u)); return `${x.protocol}//${x.host}`.toLowerCase(); } catch { return null; } 955 } 956 function isOwnUrl(u) { 957 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 958 if (!base) return false; 959 const a = asciiOrigin(u); 960 return !!a && a === asciiOrigin(base); 961 } 949 962 function postIdFromNoteUrl(url, base) { 950 963 const s = String(url || ''); 951 if (base && !s.startsWith(base)) return null; 964 // ASCII origins, not startsWith: xn--zz9h.example IS 🩵.example, and a 965 // byte comparison read our own note as a stranger's. 966 if (base) { const a = asciiOrigin(s); if (!a || a !== asciiOrigin(base)) return null; } 952 967 const m = s.match(/\/ap\/notes\/([^/?#]+)/); 953 968 return m ? decodeURIComponent(m[1]) : null; … … 1262 1277 } 1263 1278 return ok ? actor : null; 1279 } 1280 1281 // ── Authorized fetch for a single Note (2-8) ───────────────────── 1282 // Who may read this post's Note over AP GET? 'public' needs nobody; 1283 // friends-only (fan_only, Shaer's DEFAULT) needs a verified follower; 1284 // 'direct' is addressed to people and is never served over a GET at all. 1285 export function noteAudience(post) { 1286 if (!post) return 'direct'; 1287 if (post.ap_visibility === 'direct') return 'direct'; 1288 if (post.fan_only || post.ap_visibility === 'friends') return 'followers'; 1289 return 'public'; 1290 } 1291 // A follower earns the friends-only Note; a blocked actor gets the same 1292 // nothing as a stranger (the standing rule: a blocked actor's signed fetch 1293 // earns the empty set, gated server-side at serialisation). 1294 export function mayReadNote(site, post, actorUri) { 1295 const aud = noteAudience(post); 1296 if (aud === 'public') return true; 1297 if (aud === 'direct' || !site || !actorUri) return false; 1298 try { 1299 const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri); 1300 if (blocked) return false; 1301 let host = null; try { host = new URL(actorUri).host; } catch { /* geen host, geen domein-block */ } 1302 if (host) { 1303 const dom = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'domain' AND target = ?").get(site.slug, host); 1304 if (dom) return false; 1305 } 1306 return !!db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(site.slug, actorUri); 1307 } catch { return false; } 1264 1308 } 1265 1309 … … 2722 2766 // Resolve a remote post URL (any fediverse/Klonkt post) into a reply target. 2723 2767 // Returns a parent-shaped object usable by deliverReply(), or null. 2768 // The server's own note, built straight from the DB. resolveRemoteNote used 2769 // to fetch EVERYTHING over HTTPS, including notes living right here: a 2770 // hairpin fetch fails on home setups (a Klonkt on a Mac behind a tunnel), the 2771 // /ap/notes route rightly hides friends-only posts, and a punycode-spelled 2772 // own URL read as remote on a byte comparison. For the authenticated C2S 2773 // caller none of those walls apply; the DB is one prepare() away. 2774 // `forSlug` is that caller: only the post's own site gets its non-public 2775 // notes on this shortcut (public ones anyone, same as the route serves). 2776 function localNoteObject(url, forSlug) { 2777 if (!isOwnUrl(url)) return null; 2778 const m = String(url).match(/\/ap\/notes\/([^/?#]+)/); 2779 if (!m) return null; 2780 const id = decodeURIComponent(m[1]); 2781 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 2782 const post = db.prepare("SELECT * FROM posts WHERE id = ? AND status = 'published'").get(id); 2783 if (post) { 2784 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id); 2785 if (!site) return null; 2786 const nonPublic = post.fan_only || post.ap_visibility === 'friends' || post.ap_visibility === 'direct'; 2787 if (nonPublic && (!forSlug || forSlug !== site.slug)) return null; 2788 return buildNote(base, site, post); 2789 } 2790 return getOutboxNote(base, id); // our own outbound replies 2791 } 2792 // The own actor document, same shortcut, same reason. 2793 function localActorObject(uri) { 2794 if (!isOwnUrl(uri)) return null; 2795 const m = String(uri).match(/\/ap\/users\/([^/?#]+)/); 2796 const site = m ? db.prepare('SELECT * FROM sites WHERE slug = ?').get(decodeURIComponent(m[1])) : null; 2797 return site ? buildActor((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site) : null; 2798 } 2799 2724 2800 export async function resolveRemoteNote(url, opts = {}) { 2725 2801 if (!/^https?:\/\//i.test(String(url || ''))) return null; … … 2730 2806 // the other server sees WHO asks and serves what the friendship earns. 2731 2807 const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null)); 2732 const note = await get(url); // AP GET (content-negotiates)2808 const note = localNoteObject(url, opts.asSlug) || await get(url); // own DB first, then AP GET 2733 2809 if (!note || !note.id) return null; 2734 2810 const att = note.attributedTo; 2735 2811 const actorUri = actorUriOf(att); 2736 2812 if (!actorUri) return null; 2737 const actor = await get(actorUri);2813 const actor = localActorObject(actorUri) || await get(actorUri); 2738 2814 const ai = actorInfo(actor, actorUri); 2739 2815 // Is what we're replying to a post (or a comment) on one of OUR posts? If so, … … 2749 2825 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id); 2750 2826 if (!url) break; 2751 const pn = await get(url);2827 const pn = localNoteObject(url, opts.asSlug) || await get(url); 2752 2828 if (!pn) break; 2753 2829 const pa = actorUriOf(pn.attributedTo); … … 4432 4508 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured, 4433 4509 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 4434 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, 4510 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote, 4435 4511 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 4436 4512 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
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)