Changes in src/services/ActivityPubService.js [f85b2c3:a29c8c8] in Klonkt
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (29 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
rf85b2c3 ra29c8c8 627 627 // outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into 628 628 // 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 629 644 export function getMessages(slug, limit, offset) { 630 645 const off = Math.max(0, offset || 0); … … 638 653 for (const m of listOutbox(slug).slice(0, need)) { 639 654 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), 642 664 }); 643 665 } 644 666 } 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 */ } 645 681 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications) 646 682 const out = []; … … 656 692 out.push(it); 657 693 } 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. 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; 659 802 } 660 803 … … 1043 1186 const siteIcon = (site && site.profile_photo) || null; 1044 1187 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 1045 1200 const nodes = []; 1046 1201 for (const r of rows) { 1047 1202 if (r.kind !== 'reply') continue; 1203 const ik = mijnReactie(r.object_uri); 1048 1204 nodes.push({ 1049 1205 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id, … … 1052 1208 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at, 1053 1209 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, 1055 1211 children: [], 1056 1212 }); … … 1141 1297 } 1142 1298 1143 export async function fetchActor(url) { 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; 1144 1319 try { 1145 1320 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; 1151 1334 } 1152 1335 … … 1225 1408 } 1226 1409 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 1227 1420 // Best-effort verification of an incoming signed request. Returns the sender's 1228 1421 // actor doc if the signature checks out, else null. (Not gating yet — MVP.) … … 1230 1423 // federating servers with drifting clocks; an operator can widen it via env. 1231 1424 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000; 1232 export async function verifyRequest(req ) {1425 export async function verifyRequest(req, asSlug = null) { 1233 1426 const sigH = req.headers['signature']; 1234 1427 if (!sigH) return null; 1235 1428 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); 1236 1429 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() }); 1238 1435 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem; 1239 1436 if (!pem) return null; 1240 // Bind the key to the actor it speaks for. Without this we hand back whatever1241 // `id` the fetched document claims, so anyone could host a document carrying a1242 // 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 on1244 // `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: an1247 // "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 above1249 // already requires the embedded publicKey object (an array or a bare URI1250 // 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 key1254 if (key.id && key.id !== p.keyId) return null; // this key, not a neighbour's1255 if (key.owner && key.owner !== actor.id) return null; // and it belongs to this actor1256 } catch { return null; } // unparseable id or keyId1257 1437 const hs = (p.headers || '(request-target) host date').split(/\s+/); 1258 1438 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when … … 1495 1675 } 1496 1676 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 1497 1830 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 1498 1831 export async function handleInbox(req, slugParam, preVerified = null) { … … 1508 1841 // keeps everything below identical, including the actor-versus-signer check, 1509 1842 // 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); 1511 1844 1512 1845 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very … … 1518 1851 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move']; 1519 1852 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); 1522 1873 return 401; 1523 1874 } … … 1809 2160 } 1810 2161 } 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 } 1811 2172 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned 1812 2173 // above): store a mention notification for each of our actors named in the Mention tags. 1813 2174 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is 1814 2175 // 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 } 1815 2189 if (actorUri && !isLocalActor && o.id) { 1816 2190 const slugs = localMentionSlugs(o.tag, base); … … 2497 2871 const kind = type === 'Announce' ? 'boost' : 'like'; 2498 2872 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. 2501 2886 return { status: 202, url: objUri }; 2502 2887 } … … 2547 2932 const objUri = (note && note.object_uri) || innerTarget; 2548 2933 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 */ } 2551 2936 return { status: 202, url: objUri }; 2552 2937 } … … 2913 3298 } 2914 3299 export 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') 2916 3305 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; }); 2917 3306 } … … 3051 3440 return { ins: _insTl, list: _listTl, del: _delTl }; 3052 3441 } 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 */ 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 } 3054 3465 3055 3466 /** … … 3087 3498 } 3088 3499 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 3089 3582 export function getDirectMessages(slug, limit) { 3090 3583 try { … … 3243 3736 if (!_cirkelPosts) _cirkelPosts = db.prepare(` 3244 3737 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 3246 3740 FROM ap_timeline t 3247 3741 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) 3249 3747 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC 3250 3748 LIMIT ? OFFSET ?`); … … 3255 3753 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 []; } 3256 3754 } 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. 3258 3765 let _markBoost, _unmarkBoost, _boostedCount; 3259 3766 export function markBoosted(slug, noteId) { … … 3270 3777 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ } 3271 3778 } 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 3272 3982 export function getTimelineReaction(slug, noteId) { 3273 3983 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 }; } … … 3303 4013 } 3304 4014 export 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; } 3306 4023 } 3307 4024 … … 4018 4735 await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem); 4019 4736 } 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)'); 4021 4738 return { ok: true, target: target.id, inboxes: inboxes.length }; 4022 4739 } … … 4198 4915 const wardDoc = await fetchActor(wardUri).catch(() => null); 4199 4916 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 }); 4201 4931 const L = pushLang(gslug); 4202 4932 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` }); … … 4301 5031 try { 4302 5032 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, 4304 5034 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json, 4305 5035 p.slug AS post_slug, p.title AS post_title … … 4310 5040 for (const r of rows) out.push({ 4311 5041 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, 4312 5045 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at, 4313 5046 // When the post was written, for display. created_at (when it reached us) … … 4543 5276 getOutboxRow: (id) => iStmts().getO.get(id), 4544 5277 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), 4545 5282 }); 4546 5283 /** … … 4579 5316 // Guardian PWA / Berichten push. The kid answers an incoming offer in its 4580 5317 // 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. 4581 5321 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); 4596 5324 }, 4597 5325 }); 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 }; 4598 5375 4599 5376 // The notification duty of FEP-633c 3.6.2, wired once for every place a … … 4625 5402 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured, 4626 5403 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, 4628 5406 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 4629 5407 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll, … … 4631 5409 gateOutgoingFollow, performApprovedFollow, 4632 5410 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, 4634 5412 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, 4635 5413 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)