Changeset 03583f6 in Klonkt
- Timestamp:
- 08/08/2026 12:26:52 AM (5 weeks ago)
- 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)
- Files:
-
- 1 added
- 2 edited
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
test/thread.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r2d13bf3 r03583f6 704 704 705 705 // ── 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. 717 router.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 706 735 router.get('/ap/notes/:id/replies', (req, res) => { 707 736 const base = baseUrl(req); -
src/services/ActivityPubService.js
r2d13bf3 r03583f6 3347 3347 } 3348 3348 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. 3372 const THREAD_VIEW_LIMIT = 30; 3373 const THREAD_VIEW_TTL_MS = 120_000; 3374 const THREAD_VIEW_CACHE_MAX = 200; 3375 const 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. */ 3378 function 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 */ 3395 export 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 3349 3476 export async function resolveRemoteNote(url, opts = {}) { 3350 3477 if (!/^https?:\/\//i.test(String(url || ''))) return null; … … 5549 5676 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, 5550 5677 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker, 5551 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,5678 getReplyUris, getThread, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio, 5552 5679 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections, 5553 5680 noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
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)