Ignore:
Timestamp:
10/14/2026 04:25:21 PM (6 hours ago)
Author:
Bart <bart@…>
Branches:
main
Parents:
6e9bc70
git-author:
Bart <bart@…> (10/14/2026 04:23:29 PM)
git-committer:
Bart <bart@…> (10/14/2026 04:25:21 PM)
Message:

Eén note ophalen, voor wie een gesprek naar boven volgt

De thread-route (shaer-tqz) geeft wat ONDER een post hangt. Een lezer die een
antwoord kreeg en de ouder niet, kon die ouder bij een server in secure mode
niet halen: daar telt alleen een ondertekend verzoek, en de sleutel woont hier.
R9999 haalt ouders voortaan op zodra een antwoord binnenkomt, en heeft daar
deze route voor nodig -- zonder anonieme terugval, om dezelfde reden als bij de
thread.

GET /ap/users/:slug/object?uri= -- bearer-only en niets bewaren, dezelfde
weigering-of-storing (404/502) en dezelfde poorten als de thread. Een
geblokkeerde auteur is er niet. Voor een ward met een dichte kring is een note
van buiten de kring een 404 'outside the circle', met shaer:hidden erbij in
plaats van verzwegen.

inReplyTo alleen als de bron hem gaf. De thread vult een ontbrekende inReplyTo
aan met de post waaronder het antwoord in de replies-collectie stond, en daar is
dat bewijs. Voor een losse note is er geen bewijs, en een verzonnen ouder is
erger dan geen.

De vorm van één note (readerNote) en de poorten (readerGates) zijn uit de
thread getild, zodat de twee niet uit elkaar kunnen lopen. De thread-route
gedraagt zich hetzelfde.

Tests: getObject (de ouder van de bron, geen verzonnen ouder voor een wortel,
blokkade, weigering) en de route (zonder bearer, andermans bearer, alleen
https, weigering is 404).

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r6e9bc70 r61d52ed  
    24962496
    24972497/**
    2498  * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
    2499  *
    2500  * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
    2501  * kringfilter (de dichte stand van shaer:externalThreads) woont in
    2502  * filterThreadToCircle en de beeld/muziek/emoji-poorten in
    2503  * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
    2504  * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
    2505  * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
    2506  * een blokkade is onzichtbaar, ook als getal.
     2498 * Eén vreemde note in de vorm die de C2S-lezer krijgt: geschoonde HTML, de
     2499 * byline ingesloten in attributedTo, alleen emoji-tags. Gedeeld door de thread
     2500 * (antwoorden) en getObject (één note), zodat die twee niet uit elkaar lopen.
     2501 *
     2502 * `parentFallback` is voor de thread: een antwoord in de replies-collectie van
     2503 * X dat zelf geen inReplyTo noemt, hangt aantoonbaar onder X. Voor één losse
     2504 * note is er zo'n bewijs niet, en dan blijft inReplyTo leeg.
    25072505 */
    2508 export async function getThread(slug, objectUri) {
    2509   const key = `${slug}|${objectUri}`;
    2510   const hit = threadViewCache.get(key);
    2511   if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
    2512 
    2513   // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
    2514   // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
    2515   // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
    2516   // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
    2517   let sourceStatus = 0;
    2518   const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
    2519   const note = await get(objectUri);
    2520   const repliesRef = note && note.replies;
    2521   let coll = null;
    2522   if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
    2523   else if (repliesRef && typeof repliesRef === 'object') {
    2524     coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
    2525       : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
    2526   }
    2527   // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
    2528   // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
    2529   // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
    2530   // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
    2531   // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
    2532   // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
    2533   // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
    2534   let items = [];
    2535   let node = coll;
    2536   if (node && node.first && !collectionItems(node).length) {
    2537     node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
    2538   }
    2539   let pages = 0;
    2540   while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
    2541     items.push(...collectionItems(node));
    2542     if (!node.next) break;
    2543     node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
    2544   }
    2545   items = items.slice(0, THREAD_VIEW_LIMIT);
    2546 
    2547   // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
    2548   // afwachten is een halve minuut kijken naar een spinner.
    2549   const objs = await Promise.all(items.map(async (it) => {
    2550     const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
    2551     return (o && o.id && o.attributedTo) ? o : null;
    2552   }));
    2553 
    2554   const kept = [];
    2555   for (const o of objs) {
    2556     if (!o) continue;
    2557     const actorUri = actorUriOf(o.attributedTo);
    2558     if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
    2559     kept.push({ o, actorUri });
    2560   }
    2561 
    2562   // Bylines: één fetch per unieke auteur, niet één per antwoord.
    2563   const authors = new Map();
    2564   await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
    2565     authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
    2566   }));
    2567 
    2568   const notes = kept.map(({ o, actorUri }) => ({
     2506function readerNote(o, actorUri, authorDoc, parentFallback = null) {
     2507  return {
    25692508    id: o.id,
    25702509    type: 'Note',
    25712510    // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
    25722511    // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
    2573     attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
    2574     inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
     2512    attributedTo: actorObject(actorUri, actorInfo(authorDoc, actorUri)),
     2513    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || parentFallback || undefined,
    25752514    content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
    25762515    url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
    … …  
    25972536      return out.length ? out : undefined;
    25982537    })(),
    2599   })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
     2538  };
     2539}
     2540
     2541/**
     2542 * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
     2543 *
     2544 * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
     2545 * kringfilter (de dichte stand van shaer:externalThreads) woont in
     2546 * filterThreadToCircle en de beeld/muziek/emoji-poorten in
     2547 * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
     2548 * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
     2549 * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
     2550 * een blokkade is onzichtbaar, ook als getal.
     2551 */
     2552export async function getThread(slug, objectUri) {
     2553  const key = `${slug}|${objectUri}`;
     2554  const hit = threadViewCache.get(key);
     2555  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
     2556
     2557  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
     2558  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
     2559  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
     2560  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
     2561  let sourceStatus = 0;
     2562  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
     2563  const note = await get(objectUri);
     2564  const repliesRef = note && note.replies;
     2565  let coll = null;
     2566  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
     2567  else if (repliesRef && typeof repliesRef === 'object') {
     2568    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
     2569      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
     2570  }
     2571  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
     2572  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
     2573  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
     2574  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
     2575  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
     2576  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
     2577  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
     2578  let items = [];
     2579  let node = coll;
     2580  if (node && node.first && !collectionItems(node).length) {
     2581    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
     2582  }
     2583  let pages = 0;
     2584  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
     2585    items.push(...collectionItems(node));
     2586    if (!node.next) break;
     2587    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
     2588  }
     2589  items = items.slice(0, THREAD_VIEW_LIMIT);
     2590
     2591  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
     2592  // afwachten is een halve minuut kijken naar een spinner.
     2593  const objs = await Promise.all(items.map(async (it) => {
     2594    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
     2595    return (o && o.id && o.attributedTo) ? o : null;
     2596  }));
     2597
     2598  const kept = [];
     2599  for (const o of objs) {
     2600    if (!o) continue;
     2601    const actorUri = actorUriOf(o.attributedTo);
     2602    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
     2603    kept.push({ o, actorUri });
     2604  }
     2605
     2606  // Bylines: één fetch per unieke auteur, niet één per antwoord.
     2607  const authors = new Map();
     2608  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
     2609    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
     2610  }));
     2611
     2612  const notes = kept.map(({ o, actorUri }) => readerNote(o, actorUri, authors.get(actorUri), objectUri))
     2613    .sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
    26002614
    26012615  const out = { notes, found: !!note, sourceStatus };
    … …  
    26062620  }
    26072621  return out;
     2622}
     2623
     2624/**
     2625 * Eén note, ondertekend opgehaald, in dezelfde vorm als een thread-antwoord.
     2626 *
     2627 * De thread hierboven geeft alleen wat ONDER een post hangt. Wie een gesprek
     2628 * naar boven wil volgen -- een antwoord kwam binnen, de ouder nooit -- kan die
     2629 * ouder zonder de sleutel die hier woont niet halen bij een server in secure
     2630 * mode. Niets wordt bewaard, net als bij de thread.
     2631 *
     2632 * inReplyTo staat er alleen als de bron hem gaf: een verzonnen ouder is erger
     2633 * dan geen. Een geblokkeerde auteur maakt de note onvindbaar, met dezelfde
     2634 * status als een bron die nee zegt -- een blokkade hoort onzichtbaar te zijn.
     2635 */
     2636export async function getObject(slug, objectUri) {
     2637  let sourceStatus = 0;
     2638  const o = localNoteObject(objectUri, slug)
     2639    || await signedGetJson(slug, objectUri, (st) => { sourceStatus = st; });
     2640  if (!o || !o.id || !o.attributedTo) return { note: null, found: false, sourceStatus };
     2641  const actorUri = actorUriOf(o.attributedTo);
     2642  if (!actorUri || isBlockedAny(actorUri)) return { note: null, found: false, sourceStatus: 404 };
     2643  const author = localActorObject(actorUri) || await signedGetJson(slug, actorUri).catch(() => null);
     2644  return { note: readerNote(o, actorUri, author), found: true, sourceStatus };
    26082645}
    26092646
    … …  
    43464383  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    43474384  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    4348   sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
     4385  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, getObject, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    43494386  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    43504387  noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
Note: See TracChangeset for help on using the changeset viewer.