Changeset 04d5aeb in Klonkt for src


Ignore:
Timestamp:
08/02/2026 09:42:34 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
5327324
Parents:
f3a58a4
Message:

Replies op friends-only posts: de deur die nooit open kon

De hangende reply-klacht (Robins schermafdruk, 2-8: 502
cannot_resolve_inReplyTo) bleek een keten van drie schakels. Een
friends-post slaat fan_only = 1 op. De /ap/notes-route verborg elke
fan_only post voor IEDEREEN, zonder ooit naar de Signature-header te
kijken. En resolveRemoteNote haalde zelfs de eigen notes over publiek
HTTPS op. De gesigneerde resolutie die het reply-pad sinds 30-7 doet
klopte dus aan bij een deur die niet open kon: elke reply op een
friends-only post (Shaers standaard!) stierf voor de aflevering.
Publieke posts deden het wel, vandaar dat het grillig leek.

Twee reparaties. EEN: een note die hier woont verlaat het pand niet
meer. resolveRemoteNote bouwt hem uit de database (localNoteObject,
localActorObject, ook in de thread-klim), waarbij de eigen host in
ASCII vergeleken wordt: xn--zz9h.example IS het hart-domein, Barts
WebFinger-les van vanochtend, nu ook op het reply-pad en in
postIdFromNoteUrl. Niet-publieke notes alleen voor de eigen C2S-caller
(forSlug); een hairpin-fetch die op een thuisserver achter een tunnel
faalt is er niet meer.

TWEE: authorized fetch op GET /ap/notes/:id. Een geverifieerde follower
verdient de friends-only Note (noteAudience/mayReadNote); een vreemde
krijgt exact dezelfde 404 als vroeger, een geblokkeerde actor ook (de
staande regel: gesigneerde fetch van een geblokkeerde verdient de lege
verzameling, domein-blocks incluis) en direct wordt nooit over GET
geserveerd.

Changed files:
src/services/ActivityPubService.js

  • asciiOrigin/isOwnUrl: hostvergelijking via WHATWG URL, geen bytes
  • postIdFromNoteUrl: ASCII-origins in plaats van startsWith
  • localNoteObject/localActorObject: eigen notes en actors uit de DB
  • resolveRemoteNote: kortsluiting op alle drie de fetch-punten
  • noteAudience/mayReadNote: de leespoort, ook in de default-export

src/routes/activitypub.js

  • /ap/notes/:id: fan_only niet meer in de SELECT maar achter de poort; verifyRequest beslist, try eromheen (Express 4 vangt een async rejection niet: een fout werd een eeuwig hangende request, precies zo gevonden tijdens het bouwen)

New file:
test/reply-friends-only.test.js

  • reply op eigen friends-post resolvet lokaal (geen server achter het testdomein: HTTP zou 502 geven) en threadt onder de post
  • unicode- en punycode-spelling zijn een host
  • onbestaande note blijft luid 502
  • mayReadNote-matrix: follower/vreemde/blocked/domein-block/direct
  • route: vreemde 404, garbage-signature 404, direct 404, publiek 200

remarks: de suite staat op 384. Wat dit NIET oplost: een reply waarvan
de parent op een derde server staat die zelf geen authorized fetch
doet; dat is de andere kant van dezelfde deur en die is van hen.

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

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    rf3a58a4 r04d5aeb  
    585585
    586586// ── Note ──────────────────────────────────────────────────────────
    587 router.get('/ap/notes/:id', (req, res) => {
     587router.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.
    588595  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'"
    590597  ).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  }
    591611  if (!post) {
    592612    // Could be one of OUR outbound replies (ap_outbox), not a post.
  • src/services/ActivityPubService.js

    rf3a58a4 r04d5aeb  
    947947const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
    948948// 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.
     953function asciiOrigin(u) {
     954  try { const x = new URL(String(u)); return `${x.protocol}//${x.host}`.toLowerCase(); } catch { return null; }
     955}
     956function 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}
    949962function postIdFromNoteUrl(url, base) {
    950963  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; }
    952967  const m = s.match(/\/ap\/notes\/([^/?#]+)/);
    953968  return m ? decodeURIComponent(m[1]) : null;
     
    12621277  }
    12631278  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.
     1285export 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).
     1294export 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; }
    12641308}
    12651309
     
    27222766// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
    27232767// 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).
     2776function 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.
     2793function 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
    27242800export async function resolveRemoteNote(url, opts = {}) {
    27252801  if (!/^https?:\/\//i.test(String(url || ''))) return null;
     
    27302806  // the other server sees WHO asks and serves what the friendship earns.
    27312807  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
    27332809  if (!note || !note.id) return null;
    27342810  const att = note.attributedTo;
    27352811  const actorUri = actorUriOf(att);
    27362812  if (!actorUri) return null;
    2737   const actor = await get(actorUri);
     2813  const actor = localActorObject(actorUri) || await get(actorUri);
    27382814  const ai = actorInfo(actor, actorUri);
    27392815  // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
     
    27492825    const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
    27502826    if (!url) break;
    2751     const pn = await get(url);
     2827    const pn = localNoteObject(url, opts.asSlug) || await get(url);
    27522828    if (!pn) break;
    27532829    const pa = actorUriOf(pn.attributedTo);
     
    44324508  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    44334509  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,
    44354511  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    44364512  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.