Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    ra29c8c8 rf85b2c3  
    627627// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
    628628// one grouped item (actors list + count) so activity doesn't drown out conversations.
    629 /** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
    630  *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
    631  *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
    632 function outboxMediaJson(attachments) {
    633   if (!attachments) return null;
    634   try {
    635     const list = JSON.parse(attachments);
    636     if (!Array.isArray(list) || !list.length) return null;
    637     const media = list
    638       .filter((a) => a && a.url)
    639       .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
    640     return media.length ? JSON.stringify(media) : null;
    641   } catch { return null; }
    642 }
    643 
    644629export function getMessages(slug, limit, offset) {
    645630  const off = Math.max(0, offset || 0);
     
    653638    for (const m of listOutbox(slug).slice(0, need)) {
    654639      items.push({
    655         type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
    656         in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
    657         editable: m.editable, language: m.language, created_at: m.created_at,
    658         // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
    659         // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
    660         // media uit media_json met een `type`, terwijl ap_outbox ze als
    661         // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
    662         // een foto die JIJ meestuurde als kale tekst binnen.
    663         media_json: outboxMediaJson(m.attachments),
     640        type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
     641        content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
    664642      });
    665643    }
    666644  } catch { /* ignore */ }
    667   // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
    668   // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
    669   // zei alleen een slug, dus vullen we ze in één query aan.
    670   try {
    671     const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
    672     if (missing.length) {
    673       const rows = db.prepare(
    674         `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
    675            AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
    676       ).all(...missing, slug);
    677       const byslug = new Map(rows.map((r) => [r.slug, r.title]));
    678       for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
    679     }
    680   } catch { /* zonder titel valt de draad terug op de slug */ }
    681645  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
    682646  const out = [];
     
    692656    out.push(it);
    693657  }
    694   // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
    695   // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
    696   // telt een draad als één item voor de paginering, wat klopt: je scrolt door
    697   // gesprekken, niet door losse zinnen.
    698   return groupConversations(out).slice(off, off + lim);
    699 }
    700 
    701 // De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
    702 // aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
    703 // de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
    704 // moest dus tussen chips heen en weer, terwijl het één draad is.
    705 const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
    706 
    707 /** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
    708  *
    709  *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
    710  *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
    711  *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
    712  *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
    713  *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
    714  *     loopt per tegenpartij.
    715  *
    716  *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
    717  *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
    718  *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
    719  *  door.
    720  */
    721 export function threadKey(it) {
    722   if (!it || !CONV_TYPES.has(it.type)) return null;
    723   if (it.post_slug) return `post:${it.post_slug}`;
    724   let who = it.handle || it.to_handle || '';
    725   // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
    726   // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
    727   // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
    728   // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
    729   if (!who && it.to_actors) {
    730     try {
    731       const first = JSON.parse(it.to_actors)[0];
    732       if (first) who = deriveHandle(first);
    733     } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
    734   }
    735   const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
    736   return norm ? `actor:${norm}` : null;
    737 }
    738 
    739 /** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
    740  *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
    741  *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
    742  *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
    743  *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
    744  */
    745 export function groupConversations(items) {
    746   const threads = new Map();
    747   const out = [];
    748   for (const it of items || []) {
    749     const key = threadKey(it);
    750     if (!key) { out.push(it); continue; }
    751     let t = threads.get(key);
    752     if (!t) {
    753       // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
    754       // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
    755       t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
    756       threads.set(key, t);
    757       out.push(t);
    758     }
    759     t.messages.push(it);
    760     // De context bij de draad: gaat het over een post, dan hoort de link
    761     // erbij, anders is een los antwoord in een lijst niet te plaatsen.
    762     // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
    763     // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
    764     // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
    765     // dan kan het ontvangen antwoord eronder de echte titel niet meer
    766     // invullen. De terugval op de slug hoort in de weergave, niet in de data.
    767     if (it.post_slug) {
    768       if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
    769       else if (!t.post.title && it.post_title) t.post.title = it.post_title;
    770     }
    771   }
    772   for (const t of threads.values()) {
    773     t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
    774     t.count = t.messages.length;
    775     // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
    776     const seen = new Set();
    777     for (const m of t.messages) {
    778       if (m.type === 'sent') continue;
    779       const h = m.handle || m.name;
    780       if (!h || seen.has(h)) continue;
    781       seen.add(h);
    782       t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
    783     }
    784     // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
    785     // onbeantwoord bericht leest.
    786     t.mine = t.messages.some((m) => m.type === 'sent');
    787     // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
    788     // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
    789     // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
    790     // een direct bericht aan de tegenpartij.
    791     const inkomend = t.messages.filter((m) => m.type !== 'sent');
    792     const laatste = inkomend[inkomend.length - 1];
    793     t.replyTo = {
    794       interactionId: (laatste && laatste.interactionId) || null,
    795       postSlug: (t.post && t.post.slug) || null,
    796       actorUri: (laatste && (laatste.actorUri || laatste.url))
    797         || (t.messages.find((m) => m.to_actor) || {}).to_actor
    798         || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
    799     };
    800   }
    801   return out;
     658  return out.slice(off, off + lim);
    802659}
    803660
     
    11861043  const siteIcon = (site && site.profile_photo) || null;
    11871044
    1188   // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
    1189   // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
    1190   // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
    1191   // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
    1192   // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
    1193   const mijnSleutel = new Map();
    1194   for (const r of rows) {
    1195     if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
    1196   }
    1197   const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
    1198   const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
    1199 
    12001045  const nodes = [];
    12011046  for (const r of rows) {
    12021047    if (r.kind !== 'reply') continue;
    1203     const ik = mijnReactie(r.object_uri);
    12041048    nodes.push({
    12051049      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
     
    12081052      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
    12091053      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
    1210       acted_boost: ik.boosted, acted_like: ik.liked,
     1054      acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
    12111055      children: [],
    12121056    });
     
    12971141}
    12981142
    1299 export async function fetchActor(url, opts = {}) {
    1300   // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
    1301   // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
    1302   // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
    1303   // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
    1304   // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
    1305   // accounts eindeloos geweigerd, en precies die vier geven 401 op een
    1306   // onbetekende GET (shaer-afq).
    1307   //
    1308   // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
    1309   // actor-document op, en dat serveert Klonkt publiek.
    1310   //
    1311   // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
    1312   // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
    1313   // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
    1314   // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
    1315   // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
    1316   // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
    1317   // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
    1318   let doc = null;
     1143export async function fetchActor(url) {
    13191144  try {
    13201145    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
    1321     if (r.ok) {
    1322       const len = Number(r.headers.get('content-length') || 0);
    1323       if (len > 2_000_000) return null; // refuse oversized actor docs
    1324       doc = await r.json();
    1325     }
    1326   } catch { /* val door naar de ondertekende poging */ }
    1327   // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
    1328   // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
    1329   // telt als mislukt.
    1330   if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
    1331   if (!opts.asSlug) return doc;
    1332   const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
    1333   return (signed && signed.id) ? signed : doc;
     1146    if (!r.ok) return null;
     1147    const len = Number(r.headers.get('content-length') || 0);
     1148    if (len > 2_000_000) return null; // refuse oversized actor docs
     1149    return await r.json();
     1150  } catch { return null; }
    13341151}
    13351152
     
    14081225}
    14091226
    1410 /** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
    1411  *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
    1412 let _signSlug;
    1413 function anySigningSlug() {
    1414   if (_signSlug !== undefined) return _signSlug;
    1415   try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
    1416   catch { _signSlug = null; }
    1417   return _signSlug;
    1418 }
    1419 
    14201227// Best-effort verification of an incoming signed request. Returns the sender's
    14211228// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     
    14231230// federating servers with drifting clocks; an operator can widen it via env.
    14241231const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    1425 export async function verifyRequest(req, asSlug = null) {
     1232export async function verifyRequest(req) {
    14261233  const sigH = req.headers['signature'];
    14271234  if (!sigH) return null;
    14281235  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
    14291236  if (!p.keyId || !p.signature) return null;
    1430   // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
    1431   // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
    1432   // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
    1433   // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
    1434   const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
     1237  const actor = await fetchActor(p.keyId.split('#')[0]);
    14351238  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
    14361239  if (!pem) return null;
     1240  // Bind the key to the actor it speaks for. Without this we hand back whatever
     1241  // `id` the fetched document claims, so anyone could host a document carrying a
     1242  // VICTIM's id next to their OWN public key, sign with their own private half,
     1243  // and be believed: the victim's server is never contacted. The caller decides on
     1244  // `verified.id`, so the identity has to come from where the key was FETCHED,
     1245  // never from what the document says about itself.
     1246  // Adds conditions only, and there is no exemption list on purpose: an
     1247  // "unless it's a known peer" escape hatch is exactly the door this closes.
     1248  // Note this does not narrow what we accept in practice, since the line above
     1249  // already requires the embedded publicKey object (an array or a bare URI
     1250  // reference never worked here).
     1251  const key = actor.publicKey;
     1252  try {
     1253    if (new URL(p.keyId).host !== new URL(actor.id).host) return null;   // same origin as the key
     1254    if (key.id && key.id !== p.keyId) return null;                       // this key, not a neighbour's
     1255    if (key.owner && key.owner !== actor.id) return null;                // and it belongs to this actor
     1256  } catch { return null; }                                               // unparseable id or keyId
    14371257  const hs = (p.headers || '(request-target) host date').split(/\s+/);
    14381258  // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
     
    16751495}
    16761496
    1677 /**
    1678  * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
    1679  *
    1680  * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
    1681  * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
    1682  * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
    1683  * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
    1684  * binnen, zonder dat iemand een fout zag.
    1685  *
    1686  * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
    1687  * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
    1688  * bezorgde inhoud NIET en halen het object op bij de bron.
    1689  *
    1690  * Vier voorwaarden, en geen ervan is optioneel:
    1691  *
    1692  *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
    1693  *     dereferencen -- het object is weg -- dus die blijft geweigerd.
    1694  *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
    1695  *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
    1696  *     attributedTo alles kan beweren.
    1697  *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
    1698  *     levert een doorsturer een echt id met verdraaide inhoud.
    1699  *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
    1700  *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
    1701  */
    1702 /** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
    1703  *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
    1704  *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
    1705  *  doorstuurt; iets anders is dat niet. */
    1706 function knownNoteUri(uri) {
    1707   if (!uri || typeof uri !== 'string') return false;
    1708   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    1709   try {
    1710     if (base && uri.startsWith(`${base}/ap/notes/`)) {
    1711       const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
    1712       if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
    1713       if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
    1714     }
    1715     if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
    1716     if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
    1717     // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
    1718     if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
    1719   } catch { /* bij twijfel niet ophalen */ }
    1720   return false;
    1721 }
    1722 
    1723 /**
    1724  * Onthoud dat we dit bericht al eens bezorgd kregen.
    1725  *
    1726  * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
    1727  * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
    1728  * stelt voordat er iets bij de bron wordt opgehaald.
    1729  *
    1730  * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
    1731  * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
    1732  * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
    1733  * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
    1734  * schrijvers die je zelf volgt.
    1735  */
    1736 const SEEN_NOTES_DAYS = 30;
    1737 let _seenSinceSnoei = 0;
    1738 function rememberNoteUri(uri) {
    1739   if (!uri || typeof uri !== 'string') return;
    1740   try {
    1741     db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
    1742     // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
    1743     // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
    1744     // ouder is dan een maand beantwoordt geen enkele vraag meer.
    1745     if (++_seenSinceSnoei >= 500) {
    1746       _seenSinceSnoei = 0;
    1747       const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
    1748       if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
    1749     }
    1750   } catch { /* niet fataal */ }
    1751 }
    1752 const isFollowedActor = (uri) => {
    1753   try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
    1754 };
    1755 
    1756 // Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
    1757 // dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
    1758 // vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
    1759 const _derefMiss = new Map();
    1760 const DEREF_MISS_MS = 30 * 60 * 1000;
    1761 function derefRecentlyFailed(uri) {
    1762   const t = _derefMiss.get(uri);
    1763   if (t === undefined) return false;
    1764   if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
    1765   return true;
    1766 }
    1767 function noteDerefFailure(uri) {
    1768   if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
    1769     const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
    1770     for (const [k] of oud) _derefMiss.delete(k);
    1771   }
    1772   _derefMiss.set(uri, Date.now());
    1773 }
    1774 
    1775 async function dereferenceForwarded(act, claimedActor, type, slugParam) {
    1776   // Every exit states its reason. Five of the six used to return silently, so a
    1777   // rejection count could not be told apart from a narrowing that closed too far
    1778   // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
    1779   // the signer-mismatch rate (tens per hour), so this is not a noisy log.
    1780   const skipped = (reason, detail) => {
    1781     console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
    1782     return null;
    1783   };
    1784   if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
    1785   const o = act && act.object;
    1786   const objId = typeof o === 'string' ? o : (o && o.id);
    1787   if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
    1788   try {
    1789     if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
    1790   } catch { return skipped('unparsable id', objId); }
    1791   // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
    1792   // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
    1793   // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
    1794   // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
    1795   // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
    1796   // niets aan legitiem verkeer waarvan we de ouder kennen.
    1797   const parent = typeof o === 'object' && o
    1798     ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
    1799     : null;
    1800   if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
    1801   if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
    1802   // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
    1803   // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
    1804   // reden als bij fetchActor sinds efe5633.
    1805   let fetched = await apGetJson(objId).catch(() => null);
    1806   if (!fetched || fetched.id !== objId) {
    1807     // The signer used to be slugParam, which is null on the shared inbox — and
    1808     // that is where forwarded traffic lands, because we advertise a sharedInbox.
    1809     // signedGetJson falls back to an unsigned GET for a null slug, so a source in
    1810     // secure mode could never be dereferenced at all. Same fix verifyRequest got
    1811     // in shaer-afq: any local actor is a valid signer.
    1812     const asSlug = slugParam || anySigningSlug();
    1813     if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
    1814   }
    1815   const attributed = fetched && (typeof fetched.attributedTo === 'string'
    1816     ? fetched.attributedTo
    1817     : (fetched.attributedTo && fetched.attributedTo.id));
    1818   if (!fetched || fetched.id !== objId) {
    1819     noteDerefFailure(objId);
    1820     return skipped('fetch failed', objId);
    1821   }
    1822   if (attributed !== claimedActor) {
    1823     // Not a transport hiccup: the source itself says someone else wrote this.
    1824     noteDerefFailure(objId);
    1825     return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
    1826   }
    1827   return fetched;
    1828 }
    1829 
    18301497// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    18311498export async function handleInbox(req, slugParam, preVerified = null) {
     
    18411508  // keeps everything below identical, including the actor-versus-signer check,
    18421509  // which is exactly the check that must not be skipped for being local.
    1843   const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
     1510  const verified = preVerified || await verifyRequest(req).catch(() => null);
    18441511
    18451512  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
     
    18511518  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    18521519  if (GATED.includes(type)) {
    1853     // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
    1854     // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
    1855     // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
    1856     let forwarded = null;
    1857     if (verified && claimedActor && verified.id !== claimedActor) {
    1858       forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
    1859       if (forwarded) {
    1860         act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
    1861         console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
    1862       }
    1863     }
    1864     if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
    1865       // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
    1866       // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
    1867       // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
    1868       // of geldig ondertekend door iemand anders.
    1869       const reden = verified ? '(signer mismatch)'
    1870         : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
    1871         : '(no signature)';
    1872       console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
     1520    if (!verified || !claimedActor || verified.id !== claimedActor) {
     1521      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
    18731522      return 401;
    18741523    }
     
    21601809      }
    21611810    }
    2162     // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
    2163     // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
    2164     // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
    2165     // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
    2166     // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
    2167     // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
    2168     // moet iemand zijn die je zelf bent gaan volgen.
    2169     if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
    2170       rememberNoteUri(o.id);
    2171     }
    21721811    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
    21731812    // above): store a mention notification for each of our actors named in the Mention tags.
    21741813    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
    21751814    // someone else's actor, not ours.
    2176     // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
    2177     // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
    2178     // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
    2179     // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
    2180     // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
    2181     if (actorUri && !isLocalActor) {
    2182       const mark = Guardianship.help.parseMarker(o);
    2183       if (mark) {
    2184         const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
    2185         Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
    2186         console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
    2187       }
    2188     }
    21891815    if (actorUri && !isLocalActor && o.id) {
    21901816      const slugs = localMentionSlugs(o.tag, base);
     
    28712497        const kind = type === 'Announce' ? 'boost' : 'like';
    28722498        await sendInteraction(site, kind, objUri, authorUri);
    2873         // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
    2874         // De note gaat mee zodat een boost de post je tijdlijn in trekt.
    2875         try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
    2876         catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
    2877         // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
    2878         // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
    2879         // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
    2880         // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
    2881         // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
    2882         // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
    2883         // Anders dan bij een boost geen upsert: een like hoort een post niet
    2884         // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
    2885         // terecht een no-op.
     2499        setMyReaction(site.slug, targetUri, kind, true);
     2500        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
    28862501        return { status: 202, url: objUri };
    28872502      }
     
    29322547          const objUri = (note && note.object_uri) || innerTarget;
    29332548          await sendInteraction(site, kind, objUri, note && note.actor_uri);
    2934           try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
    2935           catch { /* non-fatal */ }
     2549          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
     2550          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
    29362551          return { status: 202, url: objUri };
    29372552        }
     
    32982913}
    32992914export function listOutbox(siteSlug) {
    3300   // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
    3301   // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
    3302   // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
    3303   // elkaar in "Verzonden" en "Gesprekken".
    3304   return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
     2915  return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
    33052916    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    33062917}
     
    34403051  return { ins: _insTl, list: _listTl, del: _delTl };
    34413052}
    3442 /**
    3443  * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
    3444  *
    3445  * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
    3446  * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
    3447  * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
    3448  * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
    3449  * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
    3450  *
    3451  * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
    3452  * heten en hangt het van de driver af welke wint. Eén extra query per pagina
    3453  * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
    3454  */
    3455 export function getTimeline(slug, limit, offset) {
    3456   const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
    3457   const reacties = getReactionsFor(slug, rows.map((r) => r.id));
    3458   for (const r of rows) {
    3459     const x = reacties.get(r.id);
    3460     r.liked = !!(x && x.liked);
    3461     r.boosted = !!(x && x.boosted);
    3462   }
    3463   return rows;
    3464 }
     3053export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
    34653054
    34663055/**
     
    34983087}
    34993088
    3500 /**
    3501  * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
    3502  * (shaer-n05).
    3503  *
    3504  * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
    3505  * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
    3506  * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
    3507  * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
    3508  *
    3509  * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
    3510  * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
    3511  * andere server niet te vertrouwen is.
    3512  *
    3513  * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
    3514  * mee; de vorm mag veranderen zonder dat dat iets breekt.
    3515  */
    3516 export function feedCursor(slug) {
    3517   try {
    3518     const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
    3519     return String((r && r.n) || 0);
    3520   } catch { return '0'; }
    3521 }
    3522 
    3523 /**
    3524  * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
    3525  * bewerkt, of weg.
    3526  *
    3527  * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
    3528  * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
    3529  * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
    3530  * plek waar ze samen te lezen zijn.
    3531  */
    3532 export function feedChangesSince(slug, rev, limit = 200) {
    3533   try {
    3534     return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
    3535                         WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
    3536       .all(slug, parseInt(rev, 10) || 0, limit);
    3537   } catch { return []; }
    3538 }
    3539 
    3540 // Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
    3541 // een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
    3542 // krijgen gewoon meteen antwoord in plaats van een fout.
    3543 const FEED_WAIT_MAX = 4;
    3544 const _wachters = new Map();
    3545 
    3546 /**
    3547  * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
    3548  *
    3549  * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
    3550  * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
    3551  * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
    3552  * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
    3553  * vertraging.
    3554  */
    3555 export async function waitForFeedChange(slug, opts = {}) {
    3556   const tickMs = Math.max(50, opts.tickMs || 1000);
    3557   const waitMs = Math.max(0, opts.waitMs || 0);
    3558   const since = String(opts.since || '');
    3559   let cursor = feedCursor(slug);
    3560   // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
    3561   if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
    3562 
    3563   const bezet = _wachters.get(slug) || 0;
    3564   if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
    3565   _wachters.set(slug, bezet + 1);
    3566   try {
    3567     const einde = Date.now() + waitMs;
    3568     while (Date.now() < einde) {
    3569       if (opts.signal && opts.signal.aborted) break;   // client hing op
    3570       const rest = Math.min(tickMs, einde - Date.now());
    3571       await new Promise((r) => setTimeout(r, rest));
    3572       cursor = feedCursor(slug);
    3573       if (cursor !== since) return { cursor, changed: true, waited: true };
    3574     }
    3575     return { cursor, changed: false, waited: true };
    3576   } finally {
    3577     const n = (_wachters.get(slug) || 1) - 1;
    3578     if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
    3579   }
    3580 }
    3581 
    35823089export function getDirectMessages(slug, limit) {
    35833090  try {
     
    37363243    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
    37373244      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
    3738              t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
    3739              (rb.target_uri IS NOT NULL) AS boosted
     3245             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
    37403246      FROM ap_timeline t
    37413247      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
    3742       -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
    3743       -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
    3744       -- deze join kan geen rijen verdubbelen.
    3745       LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
    3746       WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
     3248      WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
    37473249      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
    37483250      LIMIT ? OFFSET ?`);
     
    37533255  try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
    37543256}
    3755 // AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
    3756 // staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
    3757 // setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
    3758 // helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
    3759 //
    3760 // ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
    3761 // Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
    3762 // kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
    3763 // zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
    3764 // terugdraaien een code-revert blijft in plaats van dataherstel.
     3257// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
    37653258let _markBoost, _unmarkBoost, _boostedCount;
    37663259export function markBoosted(slug, noteId) {
     
    37773270  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    37783271}
    3779 /**
    3780  * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
    3781  * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
    3782  * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
    3783  * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
    3784  * dat leverde de shaer:liked-bug op (04aca12).
    3785  *
    3786  * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
    3787  * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
    3788  * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
    3789  * boost nergens -- ook niet in de Cirkel.
    3790  *
    3791  * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
    3792  * worden: de tussentabel op de URI die de client stuurde, de vlag op de
    3793  * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
    3794  * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
    3795  * werk voor fase 2, mét datamigratie.
    3796  */
    3797 // Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
    3798 // selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
    3799 // stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
    3800 // terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
    3801 // v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
    3802 // laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
    3803 const REACTIONS_MIGRATION_VERSION = 2;
    3804 
    3805 /**
    3806  * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
    3807  *
    3808  * Twee stappen, en ze zijn allebei nodig:
    3809  *
    3810  *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
    3811  *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
    3812  *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
    3813  *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
    3814  *     wanneer je reageerde, bij aanvullen niet.
    3815  *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
    3816  *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
    3817  *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
    3818  *     fediverse in als gevolg.
    3819  *
    3820  * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
    3821  */
    3822 export function migrateReactions(opts = {}) {
    3823   const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
    3824   try {
    3825     if (!opts.force) {
    3826       const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
    3827       const cur = r ? (parseInt(r.value, 10) || 0) : 0;
    3828       if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
    3829     }
    3830   } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
    3831 
    3832   // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
    3833   const wees = `
    3834     FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
    3835      WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
    3836   const scheef = (kind, kolom) => `
    3837     FROM ap_timeline t
    3838      WHERE t.${kolom} = 1
    3839        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
    3840                         WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
    3841   // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
    3842   //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
    3843   //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
    3844   //    dus die uitsluiting verliest per constructie niets.
    3845   const acted = (kind, kolom) => `
    3846     FROM ap_interactions i
    3847      JOIN posts p ON p.id = i.post_id
    3848      JOIN sites s ON s.id = p.site_id
    3849      WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
    3850        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
    3851                         WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
    3852 
    3853   if (opts.dryRun) {
    3854     const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
    3855     uit.hersleuteld = tel(wees);
    3856     uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
    3857     uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
    3858     return uit;
    3859   }
    3860 
    3861   try {
    3862     db.transaction(() => {
    3863       // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
    3864       //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
    3865       //    oplevert en nooit een verdwenen reactie.
    3866       uit.hersleuteld = db.prepare(`
    3867         INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
    3868         SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
    3869       db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
    3870 
    3871       // 2. Aanvullen vanuit de kolommen.
    3872       for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
    3873         uit.aangevuld += db.prepare(`
    3874           INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
    3875           SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
    3876       }
    3877 
    3878       // 3. En vanuit acted_* op de reacties onder je eigen posts.
    3879       for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
    3880         uit.reacties += db.prepare(`
    3881           INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
    3882           SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
    3883       }
    3884     })();
    3885     if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
    3886       console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
    3887     }
    3888     if (!opts.force) {
    3889       db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
    3890         .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
    3891     }
    3892   } catch (e) {
    3893     // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
    3894     // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
    3895     console.warn('[AP] reaction migration failed:', e.message);
    3896   }
    3897   return uit;
    3898 }
    3899 
    3900 /**
    3901  * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
    3902  *
    3903  * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
    3904  * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
    3905  * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
    3906  * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
    3907  * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
    3908  *
    3909  * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
    3910  * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
    3911  * highlight. Vandaar hier, en niet later.
    3912  *
    3913  * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
    3914  * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
    3915  * een reactie op iets buiten je tijdlijn moet gewoon werken.
    3916  */
    3917 export function canonicalReactionUri(slug, uri) {
    3918   if (!slug || !uri) return uri;
    3919   try {
    3920     if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
    3921     const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
    3922     return (row && row.id) || uri;
    3923   } catch { return uri; }
    3924 }
    3925 
    3926 /**
    3927  * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
    3928  * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
    3929  * dezelfde vraag beantwoordden uit twee verschillende bronnen.
    3930  */
    3931 export function getReaction(slug, uri) {
    3932   try {
    3933     const key = canonicalReactionUri(slug, uri);
    3934     const rows = (slug && key)
    3935       ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
    3936       : [];
    3937     return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
    3938   } catch { return { liked: false, boosted: false }; }
    3939 }
    3940 
    3941 /**
    3942  * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
    3943  * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
    3944  * een consistentiebug geruild voor een traagheidsbug.
    3945  */
    3946 export function getReactionsFor(slug, uris) {
    3947   const out = new Map();
    3948   const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
    3949   if (!slug || !list.length) return out;
    3950   try {
    3951     const rows = db.prepare(
    3952       `SELECT target_uri, kind FROM ap_my_reactions
    3953         WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
    3954     ).all(slug, ...list);
    3955     for (const r of rows) {
    3956       const cur = out.get(r.target_uri) || { liked: false, boosted: false };
    3957       if (r.kind === 'like') cur.liked = true;
    3958       if (r.kind === 'boost') cur.boosted = true;
    3959       out.set(r.target_uri, cur);
    3960     }
    3961   } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
    3962   return out;
    3963 }
    3964 
    3965 export function setReaction(slug, uri, kind, on, opts = {}) {
    3966   if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
    3967   // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
    3968   // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
    3969   // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
    3970   // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
    3971   // maakte dezelfde like onvindbaar vanaf de andere pagina.
    3972   const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
    3973   setMyReaction(slug, flagUri, kind, !!on);
    3974   if (kind === 'boost') {
    3975     if (!on) unmarkBoosted(slug, flagUri);
    3976     else if (opts.note) upsertBoostedNote(slug, opts.note);
    3977     else markBoosted(slug, flagUri);
    3978   } else if (on) markLiked(slug, flagUri);
    3979   else unmarkLiked(slug, flagUri);
    3980 }
    3981 
    39823272export function getTimelineReaction(slug, noteId) {
    39833273  try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
     
    40133303}
    40143304export function boostedCount(slug) {
    4015   // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
    4016   // bevatten van iets dat er (nog) niet in staat.
    4017   try {
    4018     if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
    4019       JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
    4020       WHERE r.site_slug = ? AND r.kind = 'boost'`);
    4021     return _boostedCount.get(slug).n;
    4022   } catch { return 0; }
     3305  try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
    40233306}
    40243307
     
    47354018    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
    47364019  }
    4737   console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
     4020  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
    47384021  return { ok: true, target: target.id, inboxes: inboxes.length };
    47394022}
     
    49154198      const wardDoc = await fetchActor(wardUri).catch(() => null);
    49164199      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
    4917       // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
    4918       // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
    4919       // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
    4920       // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
    4921       // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
    4922       const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
    4923       const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
    4924       const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
    4925       Guardianship.follows.recordReview(gslug, {
    4926         id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
    4927         follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
    4928         direction: uitgaand ? 'outgoing' : 'incoming',
    4929         target: doel || null, targetHandle: dai ? dai.handle : null,
    4930       });
     4200      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
    49314201      const L = pushLang(gslug);
    49324202      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
     
    50314301  try {
    50324302    const rows = db.prepare(`
    5033       SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
     4303      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
    50344304             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    50354305             p.slug AS post_slug, p.title AS post_title
     
    50404310    for (const r of rows) out.push({
    50414311      type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
    5042       // Waar een antwoord uit de draad heen moet: het id is de parent voor
    5043       // deliverReply, de uri het adres voor een direct bericht.
    5044       interactionId: r.interaction_id, actorUri: r.actor_uri,
    50454312      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    50464313      // When the post was written, for display. created_at (when it reached us)
     
    52764543  getOutboxRow: (id) => iStmts().getO.get(id),
    52774544  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
    5278   // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
    5279   // een antwoord uit Berichten door precies één poort gaat.
    5280   sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
    5281   htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
    52824545});
    52834546/**
     
    53164579  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
    53174580  // own Berichten; an existing guardian and a commit land in the PWA.
    5318   //
    5319   // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
    5320   // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
    53214581  onEvent: (slug, ev) => {
    5322     const p = guardianEventPush(slug, ev);
    5323     if (p) pushEvent(slug, p);
     4582    const L = pushLang(slug);
     4583    const texts = {
     4584      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
     4585      offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
     4586      committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
     4587      // §3.2: a guardian ended the relation. The ward hears that someone who
     4588      // was looking after them has gone; a co-guardian hears they are one fewer.
     4589      guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
     4590      coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
     4591    }[ev.kind];
     4592    if (!texts) return;
     4593    const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
     4594    const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
     4595    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
    53244596  },
    53254597});
    5326 
    5327 /**
    5328  * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
    5329  *
    5330  * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
    5331  * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
    5332  * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
    5333  * erbij te halen.
    5334  */
    5335 export function guardianEventPush(slug, ev) {
    5336   const L = pushLang(slug);
    5337   const texts = {
    5338     offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
    5339     offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
    5340     committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
    5341     // §3.2: a guardian ended the relation. The ward hears that someone who
    5342     // was looking after them has gone; a co-guardian hears they are one fewer.
    5343     guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
    5344     coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
    5345     // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
    5346     // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
    5347     // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
    5348     // geen drempel.
    5349     gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
    5350     gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
    5351   }[ev.kind];
    5352   if (!texts) return null;
    5353   const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
    5354   // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
    5355   // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
    5356   const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
    5357   const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
    5358   const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
    5359   const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
    5360   return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
    5361 }
    5362 
    5363 // Van een gated feature naar het woord dat het Guardian-paneel er al voor
    5364 // gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
    5365 // de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
    5366 const GATE_LABEL = {
    5367   'shaer:externalEmbeds': 'guardian.prop_embeds',
    5368   'shaer:externalPlayback': 'guardian.prop_play',
    5369 };
    5370 const GATE_OUTCOME = {
    5371   accepted: 'guardian.prop_st_accepted',
    5372   rejected: 'guardian.prop_st_rejected',
    5373   expired: 'guardian.prop_st_expired',
    5374 };
    53754598
    53764599// The notification duty of FEP-633c 3.6.2, wired once for every place a
     
    54024625  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    54034626  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    5404   feedCursor, feedChangesSince, waitForFeedChange,
    5405   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
     4627  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    54064628  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    54074629  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     
    54094631  gateOutgoingFollow, performApprovedFollow,
    54104632  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    5411   autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     4633  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    54124634  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    54134635  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Note: See TracChangeset for help on using the changeset viewer.