- Timestamp:
- 08/24/2026 04:53:38 PM (2 weeks ago)
- Branches:
- main
- Children:
- 1976a10
- Parents:
- 984e0c6
- Location:
- src/services
- Files:
-
- 1 added
- 1 edited
-
ActivityPubService.js (modified) (4 diffs)
-
ap-inbox.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r984e0c6 rdb81e56 111 111 voteOnPoll, voteOnRemotePoll, 112 112 }; 113 // Stap 9 (shaer-drc): de inbox woont in ap-inbox.js. De schakelkast krijgt 114 // onderaan zijn tweeendertig werktuigen via wireInbox. 115 import { handleInbox, wireInbox } from './ap-inbox.js'; 116 export { handleInbox }; 113 117 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken. 114 118 export { AP_CONTEXT, actorId, noteId, guessMediaType }; … … 234 238 } 235 239 236 237 /**238 * Welke objectsoorten deze inbox in de tijdlijn opneemt.239 *240 * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-241 * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in242 * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te243 * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.244 */245 const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);246 240 247 241 /** … … 1807 1801 return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` }; 1808 1802 } catch { return null; } 1809 }1810 1811 /**1812 * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).1813 *1814 * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke1815 * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN1816 * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,1817 * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet1818 * binnen, zonder dat iemand een fout zag.1819 *1820 * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt1821 * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de1822 * bezorgde inhoud NIET en halen het object op bij de bron.1823 *1824 * Vier voorwaarden, en geen ervan is optioneel:1825 *1826 * 1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te1827 * dereferencen -- het object is weg -- dus die blijft geweigerd.1828 * 2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit1829 * anker wijst een doorsturer je naar een host die hij zelf beheert, waar1830 * attributedTo alles kan beweren.1831 * 3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders1832 * levert een doorsturer een echt id met verdraaide inhoud.1833 * 4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de1834 * geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.1835 */1836 /** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een1837 * gecachete post in de tijdlijn, of een reactie die al in een thread van ons1838 * staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop1839 * doorstuurt; iets anders is dat niet. */1840 function knownNoteUri(uri) {1841 if (!uri || typeof uri !== 'string') return false;1842 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');1843 try {1844 if (base && uri.startsWith(`${base}/ap/notes/`)) {1845 const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);1846 if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;1847 if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;1848 }1849 if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;1850 if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;1851 // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).1852 if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;1853 } catch { /* bij twijfel niet ophalen */ }1854 return false;1855 }1856 1857 /**1858 * Onthoud dat we dit bericht al eens bezorgd kregen.1859 *1860 * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit1861 * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri1862 * stelt voordat er iets bij de bron wordt opgehaald.1863 *1864 * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:1865 * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een1866 * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een1867 * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor1868 * schrijvers die je zelf volgt.1869 */1870 const SEEN_NOTES_DAYS = 30;1871 let _seenSinceSnoei = 0;1872 function rememberNoteUri(uri) {1873 if (!uri || typeof uri !== 'string') return;1874 try {1875 db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);1876 // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt1877 // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat1878 // ouder is dan een maand beantwoordt geen enkele vraag meer.1879 if (++_seenSinceSnoei >= 500) {1880 _seenSinceSnoei = 0;1881 const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();1882 if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);1883 }1884 } catch { /* niet fataal */ }1885 }1886 const isFollowedActor = (uri) => {1887 try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }1888 };1889 1890 // Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging1891 // dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de1892 // vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.1893 const _derefMiss = new Map();1894 const DEREF_MISS_MS = 30 * 60 * 1000;1895 function derefRecentlyFailed(uri) {1896 const t = _derefMiss.get(uri);1897 if (t === undefined) return false;1898 if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }1899 return true;1900 }1901 function noteDerefFailure(uri) {1902 if (_derefMiss.size > 500) { // simpele begrenzing: oudste helft eruit1903 const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);1904 for (const [k] of oud) _derefMiss.delete(k);1905 }1906 _derefMiss.set(uri, Date.now());1907 }1908 1909 async function dereferenceForwarded(act, claimedActor, type, slugParam) {1910 // Every exit states its reason. Five of the six used to return silently, so a1911 // rejection count could not be told apart from a narrowing that closed too far1912 // — and that is exactly the measurement shaer-drf is waiting for. Bounded by1913 // the signer-mismatch rate (tens per hour), so this is not a noisy log.1914 const skipped = (reason, detail) => {1915 console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');1916 return null;1917 };1918 if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);1919 const o = act && act.object;1920 const objId = typeof o === 'string' ? o : (o && o.id);1921 if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');1922 try {1923 if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId); // ankereis1924 } catch { return skipped('unparsable id', objId); }1925 // Alleen dereferencen als het object beweert een antwoord te zijn op iets van1926 // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door1927 // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk1928 // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.1929 // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost1930 // niets aan legitiem verkeer waarvan we de ouder kennen.1931 const parent = typeof o === 'object' && o1932 ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))1933 : null;1934 if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');1935 if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);1936 // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een1937 // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde1938 // reden als bij fetchActor sinds efe5633.1939 let fetched = await apGetJson(objId).catch(() => null);1940 if (!fetched || fetched.id !== objId) {1941 // The signer used to be slugParam, which is null on the shared inbox — and1942 // that is where forwarded traffic lands, because we advertise a sharedInbox.1943 // signedGetJson falls back to an unsigned GET for a null slug, so a source in1944 // secure mode could never be dereferenced at all. Same fix verifyRequest got1945 // in shaer-afq: any local actor is a valid signer.1946 const asSlug = slugParam || anySigningSlug();1947 if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);1948 }1949 const attributed = fetched && (typeof fetched.attributedTo === 'string'1950 ? fetched.attributedTo1951 : (fetched.attributedTo && fetched.attributedTo.id));1952 if (!fetched || fetched.id !== objId) {1953 noteDerefFailure(objId);1954 return skipped('fetch failed', objId);1955 }1956 if (attributed !== claimedActor) {1957 // Not a transport hiccup: the source itself says someone else wrote this.1958 noteDerefFailure(objId);1959 return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);1960 }1961 return fetched;1962 }1963 1964 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.1965 export async function handleInbox(req, slugParam, preVerified = null) {1966 const act = req.body || {};1967 const type = act.type;1968 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/1969 // ignored inbox hits so an operator can see who is probing their fediverse inbox.1970 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';1971 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');1972 // preVerified is the loopback (see deliverToActor): a delivery between two1973 // actors on THIS instance never crosses a socket, so there is no signature to1974 // check — but we do know who signed, because we signed it. Handing that in1975 // keeps everything below identical, including the actor-versus-signer check,1976 // which is exactly the check that must not be skipped for being local.1977 const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);1978 1979 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very1980 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no1981 // forged replies/likes/follows/timeline posts). GET/discovery stays open.1982 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);1983 // Blocked actor/domain → silently drop (202, don't reveal the block).1984 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }1985 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];1986 if (GATED.includes(type)) {1987 // Een geldige handtekening van iemand anders dan de auteur is doorsturen,1988 // geen vervalsing. Haal het object dan bij de bron op in plaats van het af1989 // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.1990 let forwarded = null;1991 if (verified && claimedActor && verified.id !== claimedActor) {1992 forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);1993 if (forwarded) {1994 act.object = forwarded; // de OPGEHAALDE inhoud, niet de bezorgde1995 console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);1996 }1997 }1998 if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {1999 // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"2000 // heetten: geen handtekening meegestuurd, wel een handtekening maar niet2001 // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),2002 // of geldig ondertekend door iemand anders.2003 const reden = verified ? '(signer mismatch)'2004 : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'2005 : '(no signature)';2006 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);2007 return 401;2008 }2009 // One answer restores everything (FEP-633c 3.6): any VERIFIED activity2010 // from an actor that guards someone here restores it to active for those2011 // wards and cancels any lapse running against it, before the activity is2012 // even looked at. Signature-gated on purpose: an unverified claim of2013 // being gran must not wake gran up.2014 try {2015 const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());2016 if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));2017 for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);2018 } catch { /* availability is never load-bearing for delivery */ }2019 }2020 2021 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to2022 // the guardians as an Offer(Follow), their Accept/Reject back to the ward.2023 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {2024 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }2025 }2026 2027 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an2028 // Accept/Reject answers an offer a local guardian sent. Anything the2029 // guardianship module does not recognize falls through to the old paths.2030 // An Undo of the guardianship Relationship (§3.2) is handled here too, and it2031 // must be seen BEFORE the generic Undo branch below, which only knows about2032 // Follow/Like/Announce and would swallow it with a 202.2033 if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {2034 // Every LOCAL party this activity is addressed to gets its own copy of the2035 // handshake (a ward and a co-guardian may both live here). Gather candidate2036 // local slugs from the inbox owner, the `to` list, and the ward.2037 // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de2038 // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's2039 // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht2040 // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en2041 // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit2042 // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze2043 // eigen basis begint en dat de site echt bestaat.2044 const cand = new Set();2045 if (slugParam) cand.add(slugParam);2046 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {2047 if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }2048 }2049 if (type === 'Offer' || type === 'Undo') {2050 const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);2051 if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }2052 }2053 let consumed = false;2054 for (const slug of cand) {2055 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);2056 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;2057 }2058 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }2059 }2060 2061 // A moderation report (Flag) about our content — store it for the targeted site's owner2062 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).2063 if (type === 'Flag') {2064 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);2065 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);2066 let targetSlug = null;2067 const noteIds = [];2068 for (const u of objectUris) {2069 const s = localSlugOf(u); // one of OURS -- host meegewogen2070 if (s) { targetSlug = targetSlug || s; continue; }2071 const pid = postIdFromNoteUrl(u, base); // one of our notes?2072 if (pid) noteIds.push(pid);2073 }2074 if (!targetSlug && noteIds.length) {2075 try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }2076 }2077 if (!targetSlug) return 202; // not about us / can't tell → drop2078 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.2079 const ai = actorInfo(verified || null, claimedActor);2080 try {2081 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')2082 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));2083 console.log('[AP] report received for', targetSlug, 'from', claimedActor);2084 } catch { /* ignore */ }2085 return 202;2086 }2087 2088 // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:2089 // a Move often arrives seconds before the new actor's re-Follow wave, and the2090 // swap below must not race our own outgoing Follow of the target.2091 if (type === 'Move') {2092 return handleMoveInbox(act, { verifiedActor: claimedActor });2093 }2094 2095 if (type === 'Follow') {2096 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);2097 // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)2098 //2099 // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich2100 // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een2101 // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per2102 // ongeluk meenemen.2103 //2104 // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert2105 // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.2106 const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));2107 if (who && libSlug) {2108 const remote = await fetchActor(who);2109 if (!remote || !remote.inbox) return 202;2110 const fi = actorInfo(remote, who);2111 luisteraars.voegToe(libSlug, {2112 actorUri: who, inbox: remote.inbox,2113 sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,2114 name: fi.name, handle: fi.handle, icon: fi.icon,2115 });2116 const keys = getOrCreateKeys(libSlug);2117 const accept = {2118 '@context': AP_CONTEXT,2119 id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,2120 type: 'Accept', actor: actorId(base, libSlug), object: act,2121 };2122 deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)2123 .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });2124 console.log('[AP] library follow from', who, '->', libSlug);2125 return 202;2126 }2127 // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is2128 // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole2129 // kon een Follow op andermans actor met dezelfde padstaart hier een volger2130 // opleveren.2131 const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));2132 if (!who || !slug) return 400;2133 const remote = await fetchActor(who);2134 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly2135 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;2136 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)2137 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the2138 // follow is gated. A committed guardian's own Follow is auto-accepted2139 // (it needs no gate); anyone else is held pending for guardian approval.2140 // Free actors / normal sites have no guardians → fall through, unchanged.2141 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);2142 if (wardGuardians.length && !wardGuardians.includes(who)) {2143 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;2144 Guardianship.follows.recordPending(slug, {2145 id: followId, follower: who, inbox: remote.inbox, sharedInbox,2146 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,2147 });2148 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the2149 // gated follow to its guardians for approval. A LOCAL guardian gets a2150 // push and reads /guardian directly; a REMOTE guardian gets an2151 // Offer(Follow) delivered so its instance stores a copy (same distributed2152 // pattern as the adoption offer). On quorum the ward returns Accept(Follow).2153 const wardActor = actorId(base, slug);2154 const wardKeys = getOrCreateKeys(slug);2155 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };2156 // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses2157 // every guardian. The ONLY admissible evidence is a request like this2158 // one going unanswered; recordRequest itself skips a declared absence.2159 for (const g of wardGuardians) {2160 try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }2161 }2162 for (const g of wardGuardians) {2163 // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl2164 // ignores the host (an /ap/users/x path on a remote host is someone2165 // else's actor), so also require our base + an existing local site.2166 const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;2167 const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);2168 if (isLocal) {2169 const L = pushLang(gslug);2170 // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van2171 // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de2172 // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie2173 // er vraagt, en om wie het gaat (shaer-p729).2174 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folin_t'), body: i18nT(L, 'push.n_guard_folin_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });2175 } else {2176 fetchActor(g).then((ga) => {2177 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);2178 if (!inbox) return;2179 const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));2180 const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:decisive': beslissend2 };2181 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});2182 }).catch(() => {});2183 }2184 }2185 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');2186 return 202;2187 }2188 // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt2189 // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde2190 // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op2191 // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander2192 // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.2193 // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.2194 const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);2195 if (ownerGate && ownerGate.approve_followers) {2196 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;2197 Guardianship.follows.recordPending(slug, {2198 id: followId, follower: who, inbox: remote.inbox, sharedInbox,2199 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',2200 });2201 const L = pushLang(slug);2202 pushEvent(slug, {2203 type: 'follow',2204 title: i18nT(L, 'push.n_folreq_t'),2205 body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),2206 url: `${pushPrefix(slug)}/connect`,2207 });2208 console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');2209 return 202;2210 }2211 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);2212 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }2213 { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }2214 const me = actorId(base, slug);2215 const keys = getOrCreateKeys(slug);2216 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };2217 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));2218 // Auto-backfill: send our recent posts as Create so the instance has our history2219 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —2220 // Mastodon dedupes notes per-instance, so re-filling an instance that already has2221 // a follower of ours is wasted work (and won't re-populate the new follower's2222 // timeline anyway). Deliver to the shared inbox (instance-level) when present.2223 // Sync insert+check (no await between) → no interleave race with concurrent Follows.2224 const instanceFilled = sharedInbox &&2225 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')2226 .get(slug, sharedInbox, who);2227 if (!instanceFilled) {2228 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });2229 }2230 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');2231 return 202;2232 }2233 // Een luisteraar die weggaat, hoort meteen weg te zijn.2234 if (type === 'Undo' && act.object && act.object.type === 'Follow') {2235 const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);2236 const libSlug = libraryOwnerSlug(doel);2237 const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);2238 if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {2239 console.log('[AP] library unfollow from', wie, '->', libSlug);2240 return 202;2241 }2242 }2243 2244 if (type === 'Undo' && act.object) {2245 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);2246 const ot = act.object.type;2247 if (ot === 'Follow') {2248 const obj = act.object.object;2249 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));2250 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }2251 return 202;2252 }2253 if (ot === 'Like' || ot === 'Announce') {2254 const tgt = act.object.object;2255 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);2256 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }2257 return 202;2258 }2259 return 202;2260 }2261 2262 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);2263 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));2264 // Our OWN activity is already stored via ap_outbox: don't store it twice.2265 // "Our own" means THIS inbox's owner, not "anyone who happens to live on this2266 // machine". The old reading dropped every activity between two sites on one2267 // instance, so a note from a co-located guardian to its ward was accepted2268 // with a 202 and then quietly thrown away: no mention, no away, no help2269 // request. Neighbours are not us (Robins regel, 29-7: on this machine2270 // everything behaves as if every Klonkt were somewhere else).2271 const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));2272 2273 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).2274 if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {2275 const o = act.object;2276 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll2277 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored2278 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.2279 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {2280 const seg = postIdFromNoteUrl(o.inReplyTo, base);2281 if (seg && localPostExists(seg)) {2282 const rec = recordPollBallot(seg, actorUri, o.name);2283 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }2284 }2285 }2286 const tgt = findThreadTarget(o.inReplyTo, base);2287 if (tgt && actorUri && !isLocalActor) {2288 const ai = actorInfo(await resolveActor(actorUri), actorUri);2289 const html = HtmlSanitizerService.sanitize(o.content || '');2290 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }2291 iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));2292 console.log('[AP] reply', actorUri, '→', tgt.post_id);2293 // A reply is a post too: Berichten renders it the way de Krant renders a2294 // timeline row, so it needs the same media and the same quote/preview card.2295 {2296 const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';2297 const key = ['reply', tgt.post_id, actorUri, o.id || ''];2298 const mj = mediaFromNote(o);2299 if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }2300 resolveCard(o).then((c) => {2301 if (!c) return;2302 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire2303 try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }2304 }).catch(() => { /* best-effort */ });2305 }2306 {2307 // Private (followers/direct) replies push as a DM ping WITHOUT content2308 // (the push service should never carry private text, design decision);2309 // public replies carry a short snippet.2310 const ctx = pushPostCtx(tgt.post_id);2311 const vis = noteVisibility(o);2312 const priv = vis === 'direct' || vis === 'followers';2313 if (ctx) {2314 const L = pushLang(ctx.site);2315 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');2316 if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });2317 else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });2318 }2319 }2320 return 202;2321 }2322 // Home timeline (client): a top-level post from an account we follow.2323 if (actorUri && !isLocalActor && belongsInTimeline(o)) {2324 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }2325 if (subs.length) {2326 const ai = actorInfo(await resolveActor(actorUri), actorUri);2327 const { html, atts: _atts, url: _url } = timelineFields(o);2328 const media = JSON.stringify(_atts);2329 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts2330 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce2331 // incoming posts to the fediverse — that flooded followers. Boosting to the2332 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on2333 // the timeline).2334 for (const s of subs) {2335 tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));2336 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).2337 if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }2338 // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.2339 { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }2340 storeAuthorEmoji(o.id, s.slug, ai); // custom-emoji display name for the byline2341 2342 // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.2343 { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }2344 if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }2345 }2346 // FEP-044f embedded quote card: resolve the quoted post out of band so2347 // the inbox response is not blocked on a remote fetch. Best-effort.2348 if (quoteHrefOf(o)) {2349 const slugs = subs.map((s) => s.slug);2350 resolveQuote(o).then((qj) => {2351 if (!qj) return;2352 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }2353 }).catch(() => { /* best-effort */ });2354 } else {2355 // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),2356 // thumbnail-only. Also out of band, and stored for everyone; the gate2357 // that decides who may SEE it is applied at serve time (§5.3-style2358 // gated feature, see the inbox read).2359 const slugs = subs.map((s) => s.slug);2360 resolveExternalEmbed(o.content).then((ej) => {2361 if (!ej) return;2362 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }2363 }).catch(() => { /* best-effort */ });2364 }2365 console.log('[AP] timeline +', actorUri, 'x' + subs.length);2366 }2367 }2368 // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n2369 // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar2370 // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.2371 // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de2372 // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.2373 // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver2374 // moet iemand zijn die je zelf bent gaan volgen.2375 if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {2376 rememberNoteUri(o.id);2377 }2378 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned2379 // above): store a mention notification for each of our actors named in the Mention tags.2380 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is2381 // someone else's actor, not ours.2382 // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten2383 // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met2384 // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de2385 // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward2386 // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.2387 if (actorUri && !isLocalActor) {2388 const mark = Guardianship.help.parseMarker(o);2389 if (mark) {2390 const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);2391 Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);2392 wakeGuardian(slug); // een mede-guardian pakte iets op: het paneel hoort het meteen2393 console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);2394 }2395 }2396 if (actorUri && !isLocalActor && o.id) {2397 const slugs = localMentionSlugs(o.tag, base);2398 if (slugs.length) {2399 const ai = actorInfo(await resolveActor(actorUri), actorUri);2400 const html = HtmlSanitizerService.sanitize(o.content || '');2401 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the2402 // flag is stored so the Guardian PWA's message centre can list it.2403 const help = Guardianship.isHelpRequest(o);2404 const wave = Guardianship.isWave(o);2405 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only2406 // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the2407 // same direct note the mention below stores (so the kid also reads it2408 // as an ordinary message). Recorded only from an actual guardian of2409 // the addressed ward, and only with an end: an absence without an end2410 // is logged and dropped, never guessed.2411 if (Guardianship.availability.isAway(o)) {2412 const until = Guardianship.availability.parseEndTime(o.endTime);2413 for (const slug of slugs) {2414 const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();2415 if (!isG) continue;2416 if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }2417 Guardianship.availability.declareAway(slug, actorUri, until);2418 console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());2419 }2420 }2421 // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de2422 // afwezigheidsmelding: een gewone directe note met een shaer:-markering,2423 // per genoemde ontvanger afgehandeld.2424 //2425 // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag2426 // maar een onbekende die iets over jouw instellingen wil zeggen -- dat2427 // hoort in geen enkele lijst te belanden waar een guardian op afgaat.2428 {2429 const req = Guardianship.gatereq.parseRequest(o);2430 if (req) {2431 for (const slug of slugs) {2432 const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();2433 if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }2434 Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);2435 wakeGuardian(slug); // het kind vroeg om een poort2436 console.log('[AP] gate request', req.feature, actorUri, '→', slug);2437 }2438 }2439 }2440 for (const slug of slugs) {2441 try {2442 const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)2443 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)2444 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,2445 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));2446 if (r.changes) {2447 // The quote / link-preview card resolves out of band (a remote2448 // fetch), exactly as it does for a timeline post, so the inbox2449 // answer is never blocked on it.2450 resolveCard(o).then((c) => {2451 if (!c) return;2452 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire2453 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }2454 }).catch(() => { /* best-effort */ });2455 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');2456 const vis = noteVisibility(o);2457 const priv = vis === 'direct' || vis === 'followers';2458 const L = pushLang(slug);2459 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');2460 // Same privacy rule as replies: private mentions push without content.2461 // A help request pushes as its own alert type, aimed at the2462 // Guardian PWA's message centre.2463 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });2464 else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });2465 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });2466 }2467 } catch { /* ignore */ }2468 }2469 }2470 }2471 return 202;2472 }2473 // A remote post we cached was edited upstream → refresh our cached copy. This is the2474 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal2475 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't2476 // edit A's note (the signature gate guarantees claimedActor == the verified signer).2477 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {2478 const o = act.object;2479 if (o.id && claimedActor) {2480 const html = HtmlSanitizerService.sanitize(o.content || '');2481 const media = mediaFromNote(o);2482 try {2483 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug2484 // rename keeps the same AP id but changes the human url, so without this the cached2485 // post would keep linking to the old, now-dead URL.2486 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')2487 .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);2488 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);2489 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each2490 // site keeps its own `voted` state while the counts/closed update to the new totals.2491 const poll = parsePoll(o);2492 if (poll) {2493 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);2494 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');2495 for (const rw of rows) {2496 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }2497 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);2498 }2499 }2500 } catch { /* ignore */ }2501 // If this note is a cached fediverse reply on one of our posts, refresh its text too.2502 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }2503 }2504 return 202;2505 }2506 if (type === 'Like' || type === 'Announce') {2507 const tgt = act.object;2508 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);2509 const pid = postIdFromNoteUrl(objUrl, base);2510 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {2511 // A boost/like of a non-public post is dropped, not stored: nobody2512 // outside the audience should even hold it (shaer-tqc hardening).2513 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);2514 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {2515 console.log('[AP] dropped', type, 'on non-public post', pid);2516 return;2517 }2518 const ai = actorInfo(await resolveActor(actorUri), actorUri);2519 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));2520 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);2521 {2522 const ctx = pushPostCtx(pid);2523 if (ctx) {2524 const L = pushLang(ctx.site);2525 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');2526 if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });2527 else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });2528 }2529 }2530 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {2531 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.2532 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:2533 // re-announcing an incoming Announce would cascade boosts across the network).2534 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }2535 if (subs.length) {2536 const bn = await fetchNoteAP(objUrl);2537 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {2538 const origUri = actorUriOf(bn.attributedTo);2539 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL2540 // author is blocked — otherwise a block is bypassed via someone else's boost.2541 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }2542 const oai = actorInfo(await resolveActor(origUri), origUri);2543 const html = HtmlSanitizerService.sanitize(bn.content || '');2544 const media = mediaFromNote(bn);2545 const booster = actorInfo(await resolveActor(actorUri), actorUri);2546 for (const s of subs) {2547 // published = now → the boost shows as fresh activity at the top (Mastodon shows2548 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already2549 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.2550 let inserted = false;2551 try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }2552 if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }2553 storeAuthorEmoji(bn.id, s.slug, oai); // custom-emoji display name for the byline2554 // A boost carries the same renderable tags as a Create: capture the2555 // note's content emojis (FEP-9098) and object links / quote (FEP-e232/2556 // 044f) so boosted posts render like any other, not as raw shortcodes.2557 { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }2558 { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }2559 }2560 // FEP-044f: resolve the embedded quote card for a boosted post too2561 // (out of band, best-effort, so it does not block the inbox response).2562 if (quoteHrefOf(bn)) {2563 const slugs = subs.map((s) => s.slug);2564 resolveQuote(bn).then((qj) => {2565 if (!qj) return;2566 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }2567 }).catch(() => { /* best-effort */ });2568 }2569 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);2570 }2571 }2572 }2573 return 202;2574 }2575 if (type === 'Delete') {2576 // A remote note was deleted upstream → drop it from replies AND the timeline.2577 // Scope to the SIGNING actor so actor B can't delete actor A's content (the2578 // signature gate guarantees claimedActor == the verified signer here).2579 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);2580 if (oid && claimedActor) {2581 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }2582 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }2583 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page2584 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.2585 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions2586 // to A's posts).2587 try {2588 let sameHost = false;2589 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }2590 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);2591 } catch { /* ignore */ }2592 }2593 return 202;2594 }2595 // Accept/Reject of a Follow WE sent (client side).2596 if (type === 'Accept' && act.object) {2597 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);2598 let raak = 0;2599 if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }2600 // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de2601 // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een2602 // EIGEN id voor, in ONZE namespace:2603 //2604 // wij stuurden .../ap/users/dev#follow-1786161977286-bb2de32f2605 // Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...2606 //2607 // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op2608 // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op2609 // die pas opviel toen er nooit iets binnenkwam.2610 //2611 // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is2612 // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een2613 // rij die nog op pending staat wordt geraakt, dus dit kan niets anders2614 // openzetten dan een follow die wij zelf hebben verstuurd.2615 //2616 // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de2617 // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten2618 // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is2619 // onze eigen actor-URI.2620 let mij = slugParam;2621 if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);2622 if (!raak && mij && actorUri) {2623 try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }2624 }2625 // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te2626 // zien te zijn in plaats van als succes voorbij te komen.2627 console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');2628 // The moment a friendship exists is the moment the history comes along2629 // (Robins besluit, 30-7): delivery cannot reach into the past, so the2630 // fresh follower pulls the outbox, signed, and the other side now serves2631 // the friends-only posts too.2632 if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });2633 return 202;2634 }2635 if (type === 'Reject' && act.object) {2636 const who = actorUri;2637 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }2638 return 202;2639 }2640 2641 // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil2642 // tussen een soort die we bewust overslaan en een die we niet kennen -- en2643 // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt2644 // waar deze inbox alleen Note, Article en Question aanneemt.2645 const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);2646 console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',2647 'from', ip, 'by', claimedActor || '?', '(ignored)');2648 return 202;2649 1803 } 2650 1804 … … 5175 4329 // telling, de id-staart, de verhuisweigering en de attributedTo-lezer. 5176 4330 wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf }); 4331 // De schakelkast (stap 9): de lijst is bewust lang -- hij is de kaart van wat 4332 // de inbox aanraakt, en elke naam die eraf gaat is een cluster dat zelf 4333 // verhuisd is. 4334 wireInbox({ 4335 actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower, 4336 belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP, 4337 findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox, 4338 isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs, 4339 localPostExists, localSlugOf, mediaFromNote, noteVisibility, 4340 postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix, 4341 resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl, 4342 storeAuthorEmoji, timelineFields, wakeGuardian, 4343 }); 5177 4344 5178 4345 export default {
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)