Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rf85b2c3 ra29c8c8  
    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. */
     632function 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
    629644export function getMessages(slug, limit, offset) {
    630645  const off = Math.max(0, offset || 0);
     
    638653    for (const m of listOutbox(slug).slice(0, need)) {
    639654      items.push({
    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,
     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),
    642664      });
    643665    }
    644666  } 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 */ }
    645681  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
    646682  const out = [];
     
    656692    out.push(it);
    657693  }
    658   return out.slice(off, off + lim);
     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.
     705const 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 */
     721export 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 */
     745export 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;
    659802}
    660803
     
    10431186  const siteIcon = (site && site.profile_photo) || null;
    10441187
     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
    10451200  const nodes = [];
    10461201  for (const r of rows) {
    10471202    if (r.kind !== 'reply') continue;
     1203    const ik = mijnReactie(r.object_uri);
    10481204    nodes.push({
    10491205      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
     
    10521208      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
    10531209      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
    1054       acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
     1210      acted_boost: ik.boosted, acted_like: ik.liked,
    10551211      children: [],
    10561212    });
     
    11411297}
    11421298
    1143 export async function fetchActor(url) {
     1299export 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;
    11441319  try {
    11451320    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
    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; }
     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;
    11511334}
    11521335
     
    12251408}
    12261409
     1410/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
     1411 *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
     1412let _signSlug;
     1413function 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
    12271420// Best-effort verification of an incoming signed request. Returns the sender's
    12281421// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     
    12301423// federating servers with drifting clocks; an operator can widen it via env.
    12311424const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    1232 export async function verifyRequest(req) {
     1425export async function verifyRequest(req, asSlug = null) {
    12331426  const sigH = req.headers['signature'];
    12341427  if (!sigH) return null;
    12351428  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
    12361429  if (!p.keyId || !p.signature) return null;
    1237   const actor = await fetchActor(p.keyId.split('#')[0]);
     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() });
    12381435  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
    12391436  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
    12571437  const hs = (p.headers || '(request-target) host date').split(/\s+/);
    12581438  // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
     
    14951675}
    14961676
     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. */
     1706function 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 */
     1736const SEEN_NOTES_DAYS = 30;
     1737let _seenSinceSnoei = 0;
     1738function 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}
     1752const 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.
     1759const _derefMiss = new Map();
     1760const DEREF_MISS_MS = 30 * 60 * 1000;
     1761function 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}
     1767function 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
     1775async 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
    14971830// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    14981831export async function handleInbox(req, slugParam, preVerified = null) {
     
    15081841  // keeps everything below identical, including the actor-versus-signer check,
    15091842  // which is exactly the check that must not be skipped for being local.
    1510   const verified = preVerified || await verifyRequest(req).catch(() => null);
     1843  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
    15111844
    15121845  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
     
    15181851  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    15191852  if (GATED.includes(type)) {
    1520     if (!verified || !claimedActor || verified.id !== claimedActor) {
    1521       console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
     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);
    15221873      return 401;
    15231874    }
     
    18092160      }
    18102161    }
     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    }
    18112172    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
    18122173    // above): store a mention notification for each of our actors named in the Mention tags.
    18132174    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
    18142175    // 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    }
    18152189    if (actorUri && !isLocalActor && o.id) {
    18162190      const slugs = localMentionSlugs(o.tag, base);
     
    24972871        const kind = type === 'Announce' ? 'boost' : 'like';
    24982872        await sendInteraction(site, kind, objUri, authorUri);
    2499         setMyReaction(site.slug, targetUri, kind, true);
    2500         if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
     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.
    25012886        return { status: 202, url: objUri };
    25022887      }
     
    25472932          const objUri = (note && note.object_uri) || innerTarget;
    25482933          await sendInteraction(site, kind, objUri, note && note.actor_uri);
    2549           setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
    2550           if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
     2934          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
     2935          catch { /* non-fatal */ }
    25512936          return { status: 202, url: objUri };
    25522937        }
     
    29133298}
    29143299export function listOutbox(siteSlug) {
    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')
     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')
    29163305    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    29173306}
     
    30513440  return { ins: _insTl, list: _listTl, del: _delTl };
    30523441}
    3053 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
     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 */
     3455export 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}
    30543465
    30553466/**
     
    30873498}
    30883499
     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 */
     3516export 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 */
     3532export 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.
     3543const FEED_WAIT_MAX = 4;
     3544const _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 */
     3555export 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
    30893582export function getDirectMessages(slug, limit) {
    30903583  try {
     
    32433736    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
    32443737      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
    3245              t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
     3738             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
     3739             (rb.target_uri IS NOT NULL) AS boosted
    32463740      FROM ap_timeline t
    32473741      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
    3248       WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
     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)
    32493747      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
    32503748      LIMIT ? OFFSET ?`);
     
    32553753  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 []; }
    32563754}
    3257 // Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
     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.
    32583765let _markBoost, _unmarkBoost, _boostedCount;
    32593766export function markBoosted(slug, noteId) {
     
    32703777  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    32713778}
     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.
     3803const 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 */
     3822export 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 */
     3917export 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 */
     3931export 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 */
     3946export 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
     3965export 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
    32723982export function getTimelineReaction(slug, noteId) {
    32733983  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 }; }
     
    33034013}
    33044014export function boostedCount(slug) {
    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; }
     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; }
    33064023}
    33074024
     
    40184735    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
    40194736  }
    4020   console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
     4737  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
    40214738  return { ok: true, target: target.id, inboxes: inboxes.length };
    40224739}
     
    41984915      const wardDoc = await fetchActor(wardUri).catch(() => null);
    41994916      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
    4200       Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
     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      });
    42014931      const L = pushLang(gslug);
    42024932      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` });
     
    43015031  try {
    43025032    const rows = db.prepare(`
    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,
     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,
    43045034             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    43055035             p.slug AS post_slug, p.title AS post_title
     
    43105040    for (const r of rows) out.push({
    43115041      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,
    43125045      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    43135046      // When the post was written, for display. created_at (when it reached us)
     
    45435276  getOutboxRow: (id) => iStmts().getO.get(id),
    45445277  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),
    45455282});
    45465283/**
     
    45795316  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
    45805317  // 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.
    45815321  onEvent: (slug, ev) => {
    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 });
     5322    const p = guardianEventPush(slug, ev);
     5323    if (p) pushEvent(slug, p);
    45965324  },
    45975325});
     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 */
     5335export 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.
     5366const GATE_LABEL = {
     5367  'shaer:externalEmbeds': 'guardian.prop_embeds',
     5368  'shaer:externalPlayback': 'guardian.prop_play',
     5369};
     5370const GATE_OUTCOME = {
     5371  accepted: 'guardian.prop_st_accepted',
     5372  rejected: 'guardian.prop_st_rejected',
     5373  expired: 'guardian.prop_st_expired',
     5374};
    45985375
    45995376// The notification duty of FEP-633c 3.6.2, wired once for every place a
     
    46255402  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    46265403  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    4627   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
     5404  feedCursor, feedChangesSince, waitForFeedChange,
     5405  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    46285406  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    46295407  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     
    46315409  gateOutgoingFollow, performApprovedFollow,
    46325410  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    4633   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     5411  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    46345412  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    46355413  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Note: See TracChangeset for help on using the changeset viewer.