Changeset 04d5aeb in Klonkt for src/routes/activitypub.js


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@…>

File:
1 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.
Note: See TracChangeset for help on using the changeset viewer.