Changeset 03583f6 in Klonkt


Ignore:
Timestamp:
08/08/2026 12:26:52 AM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
457e87b
Parents:
2d13bf3
git-author:
Robin <roboburr@…> (08/08/2026 12:16:05 AM)
git-committer:
Robin <roboburr@…> (08/08/2026 12:26:52 AM)
Message:

De thread onder een vreemde post: ophalen via je eigen server, nooit bewaren

shaer-tqz, Barts besluit van 7-8. GET /ap/users/:slug/thread?object=<uri>,
bearer-only: de eigen app vraagt, Klonkt doet de ondertekende GET die de
app niet kan (de sleutel woont hier), loopt EEN pagina van de AS2
replies-collectie af (cap 30), normaliseert en geeft terug. Cache twee
minuten in het geheugen, weg bij een herstart. Geen tabel, geen migratie:
Klonkt is hier een tolk, geen archief. Dat is de afwijking van wat
Mastodon doet (dat bewaart /context in zijn database) en dat is bewust --
bij een instance van een persoon slaat die opslag alleen surfgedrag op.

NIET hetzelfde als maybeCrawlThread: die kruipt de thread onder je EIGEN
posts af en bewaart in ap_interactions -- dat zijn de jouwe, die horen te
blijven. De naambotsing die dat bijna opleverde (THREAD_TTL_MS) is met
THREAD_VIEW_* opgelost en het onderscheid staat nu in het commentaar.

Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
antwoorden uit de kring die de guardians al kennen (gevolgd of volgend),
en shaer:hidden telt wat erbuiten viel -- geteld, nooit stil weggelaten.
Geblokkeerden zijn een andere categorie: onzichtbaar, ook als getal.

Vier tests: de gewone lezer (schonen, sorteren, blokkade onzichtbaar), de
ward met de telling, de onbereikbare bron die dat zegt in plaats van een
lege thread te veinzen, en de cache die de tweede lezing nul fetches kost.

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

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r2d13bf3 r03583f6  
    704704
    705705// ── Replies collection ── lets remote servers fetch a post's whole thread.
     706// ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
     707//
     708// Bearer-only: dit is de eigen app van deze account die vraagt, nooit een
     709// vreemde. Klonkt doet de ondertekende GET die de app zelf niet kan (de
     710// sleutel staat hier), loopt één pagina van de replies-collectie af en geeft
     711// genormaliseerde notes terug. Er wordt NIETS opgeslagen; zie getThread.
     712//
     713// Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
     714// antwoorden uit de kring die de guardians al kennen, en shaer:hidden telt wat
     715// er buiten viel. De telling staat er zodat de UI eerlijk kan zijn -- OF hij
     716// getoond wordt is onderdeel van datzelfde besluit.
     717router.get('/ap/users/:slug/thread', async (req, res) => {
     718  const auth = OAuth.verifyBearer(req.headers.authorization);
     719  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     720  const objectUri = String(req.query.object || '');
     721  if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'object must be an https URI' });
     722  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
     723  const uit = await AP.getThread(auth.site.slug, objectUri, { isWard });
     724  if (!uit.found) return res.status(404).json({ error: 'note not reachable' });
     725  AP.sendAP(res, {
     726    '@context': AP.AP_CONTEXT,
     727    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/thread?object=${encodeURIComponent(objectUri)}`,
     728    type: 'OrderedCollection',
     729    totalItems: uit.notes.length,
     730    orderedItems: uit.notes,
     731    'shaer:hidden': uit.hidden || undefined,
     732  }, 'private, no-store');
     733});
     734
    706735router.get('/ap/notes/:id/replies', (req, res) => {
    707736  const base = baseUrl(req);
  • src/services/ActivityPubService.js

    r2d13bf3 r03583f6  
    33473347}
    33483348
     3349// ── De thread onder een post (shaer-tqz) ───────────────────────────
     3350//
     3351// Klonkt is hier een TOLK, geen archief (Barts besluit, 7-8): de antwoorden
     3352// worden opgehaald op het moment dat iemand kijkt en daarna weer vergeten.
     3353// Geen tabel, geen migratie -- wie replies bewaart van elke post die iemand
     3354// tegenkomt, laat de omvang van zijn database bepalen door surfgedrag. Dat is
     3355// de AFWIJKING, niet de norm: Mastodon serveert /context uit zijn eigen
     3356// database, en dat verdient zich daar terug omdat honderden mensen de cache
     3357// delen. Een Klonkt-instance is de server van één persoon.
     3358//
     3359// Waarom dit niet in de app kan: de replies-collectie van een vreemde server
     3360// eist in secure mode een ONDERTEKEND verzoek, en de sleutel staat hier en kan
     3361// hier niet weg. Voor de opgaande inReplyTo-keten komt de app weg met een
     3362// ongetekende GET (mist er een, jammer); voor een thread van dertig is "de
     3363// helft doet het niet" geen resultaat.
     3364//
     3365// Je krijgt hier NOOIT de hele thread: een replies-collectie bevat alleen wat
     3366// die ene server gezien heeft. De UI hoort "wat de bron weet" te tonen en geen
     3367// volledigheid te suggereren.
     3368// NIET hetzelfde als maybeCrawlThread verderop: die kruipt de thread onder je
     3369// EIGEN posts af en bewaart de antwoorden in ap_interactions (dat zijn de
     3370// jouwe, die horen te blijven). Dit hier is voor een post van een ANDER die je
     3371// tegenkomt, en bewaart niets.
     3372const THREAD_VIEW_LIMIT = 30;
     3373const THREAD_VIEW_TTL_MS = 120_000;
     3374const THREAD_VIEW_CACHE_MAX = 200;
     3375const threadViewCache = new Map();   // `${slug}|${uri}` -> { at, out } -- geheugen, weg bij herstart
     3376
     3377/** Eén pagina items uit een AS2-collectie, welke spelling hij ook koos. */
     3378function collectionItems(coll) {
     3379  if (!coll || typeof coll !== 'object') return [];
     3380  const arr = coll.orderedItems || coll.items;
     3381  return Array.isArray(arr) ? arr : [];
     3382}
     3383
     3384/**
     3385 * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
     3386 *
     3387 * `circle` is de wachtwoord-vraag van shaer-vw4 in zijn veiligste stand: is de
     3388 * lezer een ward, dan komen alleen antwoorden door van accounts die de
     3389 * guardians al kennen (gevolgd of volgend). Wat er buiten valt wordt GETELD en
     3390 * als aantal teruggegeven, nooit stil weggelaten -- maar het besluit of dat
     3391 * aantal getoond wordt, en of dit de blijvende regel is, ligt bij Bart en
     3392 * Robin (shaer-vw4). Geblokkeerde actors zijn een andere categorie: die
     3393 * verdwijnen zonder telling, een blokkade is onzichtbaar.
     3394 */
     3395export async function getThread(slug, objectUri, { isWard = false } = {}) {
     3396  const key = `${slug}|${objectUri}`;
     3397  const hit = threadViewCache.get(key);
     3398  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
     3399
     3400  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u);
     3401  const note = await get(objectUri);
     3402  const repliesRef = note && note.replies;
     3403  let coll = null;
     3404  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
     3405  else if (repliesRef && typeof repliesRef === 'object') {
     3406    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
     3407      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
     3408  }
     3409  // ÉÉN pagina, met opzet: de eerste. Wie meer wil moet eerst kunnen zeggen
     3410  // waarom dertig directe antwoorden niet genoeg context is voor een gesprek.
     3411  let items = collectionItems(coll);
     3412  if (!items.length && coll && coll.first) {
     3413    const first = typeof coll.first === 'string' ? await signedGetJson(slug, coll.first) : coll.first;
     3414    items = collectionItems(first);
     3415  }
     3416  items = items.slice(0, THREAD_VIEW_LIMIT);
     3417
     3418  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
     3419  // afwachten is een halve minuut kijken naar een spinner.
     3420  const objs = await Promise.all(items.map(async (it) => {
     3421    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
     3422    return (o && o.id && o.attributedTo) ? o : null;
     3423  }));
     3424
     3425  const circle = isWard ? (() => {
     3426    const s = new Set();
     3427    try { for (const r of db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted'").all(slug)) s.add(r.actor_uri); } catch { /* geen tabel */ }
     3428    try { for (const r of db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ?').all(slug)) s.add(r.actor_uri); } catch { /* geen tabel */ }
     3429    return s;
     3430  })() : null;
     3431
     3432  let hidden = 0;
     3433  const kept = [];
     3434  for (const o of objs) {
     3435    if (!o) continue;
     3436    const actorUri = actorUriOf(o.attributedTo);
     3437    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
     3438    if (circle && !circle.has(actorUri)) { hidden += 1; continue; }
     3439    kept.push({ o, actorUri });
     3440  }
     3441
     3442  // Bylines: één fetch per unieke auteur, niet één per antwoord.
     3443  const authors = new Map();
     3444  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
     3445    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
     3446  }));
     3447
     3448  const notes = kept.map(({ o, actorUri }) => ({
     3449    id: o.id,
     3450    type: 'Note',
     3451    attributedTo: actorUri,
     3452    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
     3453    content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
     3454    url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
     3455    published: typeof o.published === 'string' ? o.published : undefined,
     3456    sensitive: !!o.sensitive,
     3457    summary: typeof o.summary === 'string' ? o.summary.slice(0, 500) : undefined,
     3458    attachment: (() => {
     3459      const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
     3460      const out = arr.map((a) => ({ type: 'Document', mediaType: (a && a.mediaType) || undefined, url: safeUrl(a && a.url), name: (a && typeof a.name === 'string') ? a.name.slice(0, 1500) : undefined }))
     3461        .filter((a) => a.url);
     3462      return out.length ? out.slice(0, 8) : undefined;
     3463    })(),
     3464    'shaer:author': actorInfo(authors.get(actorUri), actorUri),
     3465  })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
     3466
     3467  const out = { notes, hidden, found: !!note };
     3468  threadViewCache.set(key, { at: Date.now(), out });
     3469  if (threadViewCache.size > THREAD_VIEW_CACHE_MAX) {
     3470    const oldest = [...threadViewCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
     3471    if (oldest) threadViewCache.delete(oldest[0]);
     3472  }
     3473  return out;
     3474}
     3475
    33493476export async function resolveRemoteNote(url, opts = {}) {
    33503477  if (!/^https?:\/\//i.test(String(url || ''))) return null;
     
    55495676  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    55505677  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    5551   getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
     5678  getReplyUris, getThread, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    55525679  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    55535680  noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
Note: See TracChangeset for help on using the changeset viewer.