source: Klonkt/src/services/ap-inbox.js@ 6d7ea52

main
Last change on this file since 6d7ea52 was 6d7ea52, checked in by Robin <roboburr@…>, 4 days ago

De noodknop is niet meer door een vreemde uit te zetten (shaer-gt70)

De markering op een hulpvraag werd van IEDEREEN aangenomen: de enige
voorwaarde was dat de actor niet lokaal is. Ondertekening zegt WIE, niet
of het MAG, en die tweede laag stond er niet. Afgehandeld kent geen
terugdraai en de vraag verdwijnt daarna uit de teller van elke guardian,
dus elke ondertekende actor die de URI kende kon de noodknop van een kind
uitzetten.

DE WARD IS DE BRON VAN WAARHEID over wie zijn guardians zijn -- onze
eigen tabel kent alleen onze relatie. existingGuardiansOf stelt die vraag
op de goede plek: lokaal als wij de ward hosten, anders shaer:guardians
van zijn actor. En WELKE ward erbij hoort komt uit onze eigen
administratie (de hulpvraag zoals wij hem opsloegen), nooit uit wat de
afzender beweert. Kennen we die hulpvraag niet, dan is er niets te
markeren.

Met een cache van vijf minuten, want het remote geval is een
netwerkaanroep in het inbox-pad -- zonder cache is dat een manier om onze
inbox te laten wachten. Een MISLUKTE ophaal wordt niet als lege lijst
onthouden: dan zou een tijdelijk onbereikbare server vijf minuten lang
elke markering weigeren.

TWEEDE VONDST, gemeten tijdens het bouwen: dezelfde regels riepen
wakeGuardian(slug) aan met een slug die in die scope niet bestaat. De
markering werd dus vastgelegd en daarna gooide de handler een
ReferenceError -- het paneel hoorde het nooit en de rest van de
verwerking van die activiteit viel weg. Nu wakeGuardian(vraag.slug): het
paneel dat de hulpvraag houdt. slugParam zou ook fout zijn, want die is
null op de gedeelde inbox.

Vijf toetsen, tegenbewijs tegen de code van hiervoor: daar vallen ze alle
vijf. Volle suite 1260 groen.

  • Property mode set to 100644
File size: 63.2 KB
Line 
1/**
2 * ap-inbox.js — de inbox (stap 9 van shaer-drc).
3 *
4 * Het hart van de federatie-ontvangst: handleInbox (de grote switch over
5 * Follow, Accept, Undo, Create, Like, Announce, Delete, Update, Move, Flag en
6 * Block), de her-verificatie van doorgestuurde activiteiten
7 * (dereferenceForwarded, shaer-s8k) en de kleine kas eromheen (bekende notes,
8 * geziene notes, recente ophaal-missers).
9 *
10 * De inbox is de SCHAKELKAST van de dienst: hij raakt vrijwel elk cluster.
11 * Wat al een eigen module heeft komt statisch binnen (transport, tijdlijn,
12 * peilingen, volgwinkel, guardianship, ap-core); de tweeendertig werktuigen
13 * die nog in de dienstlaag wonen komen via wireInbox. Die lijst is bewust
14 * lang en expliciet -- hij IS de kaart van wat de inbox aanraakt, en elke
15 * naam die er ooit afgaat is een cluster dat zelf verhuisd is.
16 * De §5.3-goedkeuring (handleFollowApprovalInbox) blijft bij zijn
17 * guardian-broers in de dienst, zoals gateOutgoingFollow bij stap 7.
18 */
19import db, { NU_ISO } from '../config/database.js';
20import HtmlSanitizerService from './HtmlSanitizerService.js';
21import * as Guardianship from './guardianship/index.js';
22import { t as i18nT } from './i18n.js';
23import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
24import {
25 verifyRequest, fetchActor, deliver, deliverWithRetry, signedGetJson,
26 apGetJson, anySigningSlug, getOrCreateKeys,
27} from './ap-transport.js';
28import { tlStmts, extractEmojiTags, extractLinkJson, quoteHrefOf } from './ap-timeline.js';
29import { parsePoll, recordPollBallot } from './ap-polls.js';
30import { fwStmts } from './ap-following.js';
31
32/**
33 * Welke objectsoorten deze inbox in de tijdlijn opneemt.
34 *
35 * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
36 * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
37 * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
38 * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
39 */
40const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
41
42// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
43let actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
44 belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
45 findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
46 isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
47 localPostExists, localSlugOf, mediaFromNote, noteVisibility,
48 postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
49 resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
50 storeAuthorEmoji, timelineFields, wakeGuardian;
51export function wireInbox(deps) {
52 ({ actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
53 belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
54 findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
55 isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug,
56 localMentionSlugs, localPostExists, localSlugOf, mediaFromNote,
57 noteVisibility, postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx,
58 pushPrefix, resolveCard, resolveExternalEmbed, resolveQuote, rid,
59 slugFromActorUrl, storeAuthorEmoji, timelineFields, wakeGuardian } = deps);
60}
61
62/**
63 * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
64 *
65 * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
66 * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
67 * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
68 * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
69 * binnen, zonder dat iemand een fout zag.
70 *
71 * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
72 * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
73 * bezorgde inhoud NIET en halen het object op bij de bron.
74 *
75 * Vier voorwaarden, en geen ervan is optioneel:
76 *
77 * 1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
78 * dereferencen -- het object is weg -- dus die blijft geweigerd.
79 * 2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
80 * anker wijst een doorsturer je naar een host die hij zelf beheert, waar
81 * attributedTo alles kan beweren.
82 * 3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
83 * levert een doorsturer een echt id met verdraaide inhoud.
84 * 4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
85 * geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
86 */
87/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
88 * gecachete post in de tijdlijn, of een reactie die al in een thread van ons
89 * staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
90 * doorstuurt; iets anders is dat niet. */
91function knownNoteUri(uri) {
92 if (!uri || typeof uri !== 'string') return false;
93 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
94 try {
95 if (base && uri.startsWith(`${base}/ap/notes/`)) {
96 const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
97 if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
98 if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
99 }
100 if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
101 if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
102 // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
103 if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
104 } catch { /* bij twijfel niet ophalen */ }
105 return false;
106}
107
108/**
109 * Onthoud dat we dit bericht al eens bezorgd kregen.
110 *
111 * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
112 * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
113 * stelt voordat er iets bij de bron wordt opgehaald.
114 *
115 * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
116 * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
117 * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
118 * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
119 * schrijvers die je zelf volgt.
120 */
121const SEEN_NOTES_DAYS = 30;
122let _seenSinceSnoei = 0;
123function rememberNoteUri(uri) {
124 if (!uri || typeof uri !== 'string') return;
125 try {
126 db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
127 // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
128 // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
129 // ouder is dan een maand beantwoordt geen enkele vraag meer.
130 if (++_seenSinceSnoei >= 500) {
131 _seenSinceSnoei = 0;
132 const r = db.prepare(`DELETE FROM ap_seen_notes WHERE datetime(created_at) < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
133 if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
134 }
135 } catch { /* niet fataal */ }
136}
137const isFollowedActor = (uri) => {
138 try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
139};
140
141/**
142 * De hulpvraag zoals WIJ hem opsloegen (shaer-gt70).
143 *
144 * Een markering wijst naar een note-URI, en die komt van de afzender. Welke
145 * ward erbij hoort mag daarom niet uit die markering komen maar uit onze eigen
146 * rij: de hulpvraag kwam hier binnen als directe vermelding met help_request=1,
147 * en `actor_uri` daarvan IS de ward.
148 *
149 * Geen rij, geen markering. Dat sluit meteen de aardigste variant af: iemand
150 * die markeringen stuurt voor hulpvragen die hij ergens anders zag.
151 */
152function helpRequestRow(noteUri) {
153 if (!noteUri || typeof noteUri !== 'string') return null;
154 try {
155 return db.prepare('SELECT slug, actor_uri FROM ap_mentions WHERE object_uri = ? AND help_request = 1 LIMIT 1').get(noteUri) || null;
156 } catch { return null; }
157}
158
159/**
160 * Is deze actor guardian van deze ward?
161 *
162 * De WARD is de bron van waarheid over zijn eigen guardians -- onze tabel kent
163 * alleen onze eigen relatie. existingGuardiansOf stelt de vraag op de goede
164 * plek: hosten wij de ward, dan is het een databaselezing; woont hij elders,
165 * dan komt het uit shaer:guardians op zijn actor.
166 *
167 * MET EEN CACHE, want dat tweede geval is een netwerkaanroep in het inbox-pad.
168 * Zonder zou een vreemde onze inbox kunnen laten wachten door markeringen te
169 * blijven sturen. De lokale tak raakt de cache ook, en dat kost daar niets.
170 *
171 * Vijf minuten is kort genoeg dat een verse guardian niet lang buiten staat, en
172 * lang genoeg om herhaald bevragen te dempen. Een geweigerde markering is niet
173 * verloren: de andere kant levert opnieuw af, en dan is de cache ververst.
174 */
175const _guardiansOfWard = new Map(); // ward-uri -> { at, set }
176const GUARDIAN_CACHE_MS = 5 * 60 * 1000;
177async function isGuardianOfWard(actorUri, wardUri) {
178 if (!actorUri || !wardUri) return false;
179 const nu = Date.now();
180 const gecached = _guardiansOfWard.get(wardUri);
181 if (gecached && nu - gecached.at < GUARDIAN_CACHE_MS) return gecached.set.has(actorUri);
182 let lijst = [];
183 try { lijst = await Guardianship.existingGuardiansOf(wardUri); } catch { lijst = []; }
184 // Een MISLUKTE ophaal niet als lege lijst wegschrijven: dan zou een tijdelijk
185 // onbereikbare server vijf minuten lang elke markering weigeren. Bij twijfel
186 // niets onthouden en de volgende keer opnieuw kijken.
187 if (Array.isArray(lijst) && lijst.length) {
188 if (_guardiansOfWard.size > 500) _guardiansOfWard.clear(); // simpele begrenzing
189 _guardiansOfWard.set(wardUri, { at: nu, set: new Set(lijst) });
190 }
191 return Array.isArray(lijst) && lijst.includes(actorUri);
192}
193
194// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
195// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
196// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
197//
198// DE SLEUTEL IS DE HELE BESCHERMING (shaer-qawr). Er zijn twee soorten
199// mislukking en ze zeggen iets heel verschillends:
200//
201// TRANSPORTFOUT -- de note is niet op te halen. Dat is een eigenschap van de
202// note zelf en geldt voor iedereen die hem doorstuurt, dus de objId alleen is
203// de goede sleutel.
204//
205// attributedTo-MISMATCH -- de bron zegt dat iemand ANDERS de auteur is. Dat
206// zegt alles over de doorstuurder en niets over de note, dus die onthouden we
207// per (note, beweerde actor).
208//
209// Met een enkele sleutel voor allebei was dit een censuurknop: neem de echte
210// note-URI van je slachtoffer, zet er je eigen actor op dezelfde host bij en
211// wijs naar een van onze publieke notes. De fetch slaagt, de mismatch volgt, en
212// die note-URI stond dertig minuten op de zwarte lijst -- waarna het ECHTE
213// doorgestuurde antwoord erop stukliep. Elke dertig minuten herhalen gaf
214// onbeperkte, gerichte onderdrukking van een specifiek antwoord, voor een
215// verzoek per keer. Nu raakt de leugenaar alleen zijn eigen ingang.
216//
217// Query en fragment tellen niet mee. Ze horen zelden bij de identiteit van een
218// note, en met een kale URL als sleutel waren ?x=1, ?x=2 enzovoort losse
219// ingangen: dan is de rem geen rem, want varieren kost niets. Zelfde reden dat
220// de host in kleine letters gaat.
221//
222// GEEN rem per HOST, hoe verleidelijk ook: wie een handvol niet-bestaande
223// URL's op een host laat mislukken zou daarmee die HELE host het zwijgen
224// opleggen. Dat is een grotere versie van precies de fout die hier gerepareerd
225// wordt.
226const _derefMiss = new Map();
227const DEREF_MISS_MS = 30 * 60 * 1000;
228const derefKey = (uri, claimedActor) => {
229 let basis = String(uri || '');
230 try { const u = new URL(basis); basis = `${u.protocol}//${u.host.toLowerCase()}${u.pathname}`; }
231 catch { /* onparseerbaar: de kale string is dan de sleutel */ }
232 // Een NUL-teken als scheiding, als escape geschreven en niet als byte: het
233 // kan in geen enkele URL staan, dus een actor-sleutel is nooit per ongeluk
234 // als note-sleutel te lezen.
235 return claimedActor ? `${basis}\u0000${claimedActor}` : basis;
236};
237function derefRecentlyFailed(uri, claimedActor) {
238 for (const k of [derefKey(uri), derefKey(uri, claimedActor)]) {
239 const t = _derefMiss.get(k);
240 if (t === undefined) continue;
241 if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(k); continue; }
242 return true;
243 }
244 return false;
245}
246function noteDerefFailure(uri, claimedActor) {
247 if (_derefMiss.size > 500) { // simpele begrenzing: oudste helft eruit
248 const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
249 for (const [k] of oud) _derefMiss.delete(k);
250 }
251 _derefMiss.set(derefKey(uri, claimedActor), Date.now());
252}
253// Alleen voor de toets: de aanval speelt zich af in deze twee functies, en de
254// weg erheen (dereferenceForwarded) eist https en een echte fetch. De
255// dienstlaag exporteert ze niet, dus het uitvoeroppervlak blijft gelijk.
256export const _derefCacheForTests = { derefRecentlyFailed, noteDerefFailure };
257
258async function dereferenceForwarded(act, claimedActor, type, slugParam) {
259 // Every exit states its reason. Five of the six used to return silently, so a
260 // rejection count could not be told apart from a narrowing that closed too far
261 // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
262 // the signer-mismatch rate (tens per hour), so this is not a noisy log.
263 const skipped = (reason, detail) => {
264 console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
265 return null;
266 };
267 if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
268 const o = act && act.object;
269 const objId = typeof o === 'string' ? o : (o && o.id);
270 if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
271 try {
272 if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId); // ankereis
273 } catch { return skipped('unparsable id', objId); }
274 // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
275 // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
276 // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
277 // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
278 // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
279 // niets aan legitiem verkeer waarvan we de ouder kennen.
280 const parent = typeof o === 'object' && o
281 ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
282 : null;
283 if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
284 if (derefRecentlyFailed(objId, claimedActor)) return skipped('recent failure', objId);
285 // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
286 // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
287 // reden als bij fetchActor sinds efe5633.
288 let fetched = await apGetJson(objId).catch(() => null);
289 if (!fetched || fetched.id !== objId) {
290 // The signer used to be slugParam, which is null on the shared inbox — and
291 // that is where forwarded traffic lands, because we advertise a sharedInbox.
292 // signedGetJson falls back to an unsigned GET for a null slug, so a source in
293 // secure mode could never be dereferenced at all. Same fix verifyRequest got
294 // in shaer-afq: any local actor is a valid signer.
295 const asSlug = slugParam || anySigningSlug();
296 if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
297 }
298 const attributed = fetched && (typeof fetched.attributedTo === 'string'
299 ? fetched.attributedTo
300 : (fetched.attributedTo && fetched.attributedTo.id));
301 if (!fetched || fetched.id !== objId) {
302 noteDerefFailure(objId);
303 return skipped('fetch failed', objId);
304 }
305 if (attributed !== claimedActor) {
306 // Not a transport hiccup: the source itself says someone else wrote this.
307 // Per (note, beweerde actor), nooit op de note alleen: dit zegt iets over
308 // DEZE doorstuurder, en op de note alleen was het een censuurknop op de
309 // note van een ander (shaer-qawr).
310 noteDerefFailure(objId, claimedActor);
311 return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
312 }
313 return fetched;
314}
315
316// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
317export async function handleInbox(req, slugParam, preVerified = null) {
318 const act = req.body || {};
319 const type = act.type;
320 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
321 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
322 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
323 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
324 // preVerified is the loopback (see deliverToActor): a delivery between two
325 // actors on THIS instance never crosses a socket, so there is no signature to
326 // check — but we do know who signed, because we signed it. Handing that in
327 // keeps everything below identical, including the actor-versus-signer check,
328 // which is exactly the check that must not be skipped for being local.
329 const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
330
331 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
332 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
333 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
334 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
335 // Blocked actor/domain → silently drop (202, don't reveal the block).
336 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
337 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
338 if (GATED.includes(type)) {
339 // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
340 // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
341 // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
342 let forwarded = null;
343 if (verified && claimedActor && verified.id !== claimedActor) {
344 forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
345 if (forwarded) {
346 act.object = forwarded; // de OPGEHAALDE inhoud, niet de bezorgde
347 console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
348 }
349 }
350 if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
351 // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
352 // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
353 // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
354 // of geldig ondertekend door iemand anders.
355 const reden = verified ? '(signer mismatch)'
356 : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
357 : '(no signature)';
358 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
359 return 401;
360 }
361 // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
362 // from an actor that guards someone here restores it to active for those
363 // wards and cancels any lapse running against it, before the activity is
364 // even looked at. Signature-gated on purpose: an unverified claim of
365 // being gran must not wake gran up.
366 try {
367 const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
368 if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
369 for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
370 } catch { /* availability is never load-bearing for delivery */ }
371 }
372
373 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
374 // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
375 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
376 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
377 }
378
379 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
380 // Accept/Reject answers an offer a local guardian sent. Anything the
381 // guardianship module does not recognize falls through to the old paths.
382 // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
383 // must be seen BEFORE the generic Undo branch below, which only knows about
384 // Follow/Like/Announce and would swallow it with a 202.
385 if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
386 // Every LOCAL party this activity is addressed to gets its own copy of the
387 // handshake (a ward and a co-guardian may both live here). Gather candidate
388 // local slugs from the inbox owner, the `to` list, and the ward.
389 // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
390 // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
391 // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
392 // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
393 // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
394 // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
395 // eigen basis begint en dat de site echt bestaat.
396 const cand = new Set();
397 if (slugParam) cand.add(slugParam);
398 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
399 if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
400 }
401 if (type === 'Offer' || type === 'Undo') {
402 const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
403 if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
404 }
405 let consumed = false;
406 for (const slug of cand) {
407 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
408 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
409 }
410 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
411 }
412
413 // A moderation report (Flag) about our content — store it for the targeted site's owner
414 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
415 if (type === 'Flag') {
416 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
417 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
418 let targetSlug = null;
419 const noteIds = [];
420 for (const u of objectUris) {
421 const s = localSlugOf(u); // one of OURS -- host meegewogen
422 if (s) { targetSlug = targetSlug || s; continue; }
423 const pid = postIdFromNoteUrl(u, base); // one of our notes?
424 if (pid) noteIds.push(pid);
425 }
426 if (!targetSlug && noteIds.length) {
427 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 */ }
428 }
429 if (!targetSlug) return 202; // not about us / can't tell → drop
430 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
431 const ai = actorInfo(verified || null, claimedActor);
432 try {
433 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
434 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
435 console.log('[AP] report received for', targetSlug, 'from', claimedActor);
436 } catch { /* ignore */ }
437 return 202;
438 }
439
440 // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
441 // a Move often arrives seconds before the new actor's re-Follow wave, and the
442 // swap below must not race our own outgoing Follow of the target.
443 if (type === 'Move') {
444 return handleMoveInbox(act, { verifiedActor: claimedActor });
445 }
446
447 if (type === 'Follow') {
448 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
449 // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
450 //
451 // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
452 // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
453 // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
454 // ongeluk meenemen.
455 //
456 // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
457 // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
458 const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
459 if (who && libSlug) {
460 const remote = await fetchActor(who);
461 if (!remote || !remote.inbox) return 202;
462 const fi = actorInfo(remote, who);
463 luisteraars.voegToe(libSlug, {
464 actorUri: who, inbox: remote.inbox,
465 sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
466 name: fi.name, handle: fi.handle, icon: fi.icon,
467 });
468 const keys = getOrCreateKeys(libSlug);
469 const accept = {
470 '@context': AP_CONTEXT,
471 id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
472 type: 'Accept', actor: actorId(base, libSlug), object: act,
473 };
474 deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
475 .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
476 console.log('[AP] library follow from', who, '->', libSlug);
477 return 202;
478 }
479 // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
480 // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
481 // kon een Follow op andermans actor met dezelfde padstaart hier een volger
482 // opleveren.
483 const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
484 if (!who || !slug) return 400;
485 const remote = await fetchActor(who);
486 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
487 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
488 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)
489 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
490 // follow is gated. A committed guardian's own Follow is auto-accepted
491 // (it needs no gate); anyone else is held pending for guardian approval.
492 // Free actors / normal sites have no guardians → fall through, unchanged.
493 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
494 if (wardGuardians.length && !wardGuardians.includes(who)) {
495 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
496 Guardianship.follows.recordPending(slug, {
497 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
498 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
499 });
500 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
501 // gated follow to its guardians for approval. A LOCAL guardian gets a
502 // push and reads /guardian directly; a REMOTE guardian gets an
503 // Offer(Follow) delivered so its instance stores a copy (same distributed
504 // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
505 const wardActor = actorId(base, slug);
506 const wardKeys = getOrCreateKeys(slug);
507 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
508 // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
509 // every guardian. The ONLY admissible evidence is a request like this
510 // one going unanswered; recordRequest itself skips a declared absence.
511 for (const g of wardGuardians) {
512 try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
513 }
514 for (const g of wardGuardians) {
515 // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
516 // ignores the host (an /ap/users/x path on a remote host is someone
517 // else's actor), so also require our base + an existing local site.
518 const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
519 const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
520 if (isLocal) {
521 const L = pushLang(gslug);
522 // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
523 // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
524 // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
525 // er vraagt, en om wie het gaat (shaer-p729).
526 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` });
527 } else {
528 fetchActor(g).then((ga) => {
529 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
530 if (!inbox) return;
531 const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
532 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 };
533 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
534 }).catch(() => {});
535 }
536 }
537 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
538 return 202;
539 }
540 // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
541 // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
542 // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
543 // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
544 // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
545 // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
546 const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
547 if (ownerGate && ownerGate.approve_followers) {
548 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
549 Guardianship.follows.recordPending(slug, {
550 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
551 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
552 });
553 const L = pushLang(slug);
554 pushEvent(slug, {
555 type: 'follow',
556 title: i18nT(L, 'push.n_folreq_t'),
557 body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
558 url: `${pushPrefix(slug)}/connect`,
559 });
560 console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
561 return 202;
562 }
563 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
564 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
565 { 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` }); }
566 const me = actorId(base, slug);
567 const keys = getOrCreateKeys(slug);
568 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
569 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
570 // Auto-backfill: send our recent posts as Create so the instance has our history
571 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
572 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
573 // a follower of ours is wasted work (and won't re-populate the new follower's
574 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
575 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
576 const instanceFilled = sharedInbox &&
577 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
578 .get(slug, sharedInbox, who);
579 if (!instanceFilled) {
580 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
581 }
582 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
583 return 202;
584 }
585 // Een luisteraar die weggaat, hoort meteen weg te zijn.
586 if (type === 'Undo' && act.object && act.object.type === 'Follow') {
587 const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
588 const libSlug = libraryOwnerSlug(doel);
589 const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
590 if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
591 console.log('[AP] library unfollow from', wie, '->', libSlug);
592 return 202;
593 }
594 }
595
596 if (type === 'Undo' && act.object) {
597 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
598 const ot = act.object.type;
599 if (ot === 'Follow') {
600 const obj = act.object.object;
601 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
602 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
603 return 202;
604 }
605 if (ot === 'Like' || ot === 'Announce') {
606 const tgt = act.object.object;
607 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
608 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
609 return 202;
610 }
611 return 202;
612 }
613
614 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
615 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
616 // Our OWN activity is already stored via ap_outbox: don't store it twice.
617 // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
618 // machine". The old reading dropped every activity between two sites on one
619 // instance, so a note from a co-located guardian to its ward was accepted
620 // with a 202 and then quietly thrown away: no mention, no away, no help
621 // request. Neighbours are not us (Robins regel, 29-7: on this machine
622 // everything behaves as if every Klonkt were somewhere else).
623 const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
624
625 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
626 if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
627 const o = act.object;
628 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
629 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
630 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
631 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
632 const seg = postIdFromNoteUrl(o.inReplyTo, base);
633 if (seg && localPostExists(seg)) {
634 const rec = recordPollBallot(seg, actorUri, o.name);
635 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
636 }
637 }
638 const tgt = findThreadTarget(o.inReplyTo, base);
639 if (tgt && actorUri && !isLocalActor) {
640 const ai = actorInfo(await resolveActor(actorUri), actorUri);
641 const html = HtmlSanitizerService.sanitize(o.content || '');
642 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
643 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));
644 console.log('[AP] reply', actorUri, '→', tgt.post_id);
645 // A reply is a post too: Berichten renders it the way de Krant renders a
646 // timeline row, so it needs the same media and the same quote/preview card.
647 {
648 const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
649 const key = ['reply', tgt.post_id, actorUri, o.id || ''];
650 const mj = mediaFromNote(o);
651 if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
652 resolveCard(o).then((c) => {
653 if (!c) return;
654 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
655 try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
656 }).catch(() => { /* best-effort */ });
657 }
658 {
659 // Private (followers/direct) replies push as a DM ping WITHOUT content
660 // (the push service should never carry private text, design decision);
661 // public replies carry a short snippet.
662 const ctx = pushPostCtx(tgt.post_id);
663 const vis = noteVisibility(o);
664 const priv = vis === 'direct' || vis === 'followers';
665 if (ctx) {
666 const L = pushLang(ctx.site);
667 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
668 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` });
669 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 });
670 }
671 }
672 return 202;
673 }
674 // Home timeline (client): a top-level post from an account we follow.
675 if (actorUri && !isLocalActor && belongsInTimeline(o)) {
676 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
677 if (subs.length) {
678 const ai = actorInfo(await resolveActor(actorUri), actorUri);
679 const { html, atts: _atts, url: _url } = timelineFields(o);
680 const media = JSON.stringify(_atts);
681 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
682 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
683 // incoming posts to the fediverse — that flooded followers. Boosting to the
684 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
685 // the timeline).
686 for (const s of subs) {
687 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));
688 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
689 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 */ } }
690 // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
691 { 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 */ } } }
692 storeAuthorEmoji(o.id, s.slug, ai); // custom-emoji display name for the byline
693
694 // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
695 { 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 */ } } }
696 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 */ } }
697 }
698 // FEP-044f embedded quote card: resolve the quoted post out of band so
699 // the inbox response is not blocked on a remote fetch. Best-effort.
700 if (quoteHrefOf(o)) {
701 const slugs = subs.map((s) => s.slug);
702 resolveQuote(o).then((qj) => {
703 if (!qj) return;
704 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 */ } }
705 }).catch(() => { /* best-effort */ });
706 } else {
707 // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
708 // thumbnail-only. Also out of band, and stored for everyone; the gate
709 // that decides who may SEE it is applied at serve time (§5.3-style
710 // gated feature, see the inbox read).
711 const slugs = subs.map((s) => s.slug);
712 resolveExternalEmbed(o.content).then((ej) => {
713 if (!ej) return;
714 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 */ } }
715 }).catch(() => { /* best-effort */ });
716 }
717 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
718 }
719 }
720 // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
721 // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
722 // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
723 // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
724 // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
725 // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
726 // moet iemand zijn die je zelf bent gaan volgen.
727 if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
728 rememberNoteUri(o.id);
729 }
730 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
731 // above): store a mention notification for each of our actors named in the Mention tags.
732 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
733 // someone else's actor, not ours.
734 // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
735 // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
736 // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
737 // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
738 // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
739 if (actorUri && !isLocalActor) {
740 const mark = Guardianship.help.parseMarker(o);
741 if (mark) {
742 // WIE MAG DIT (shaer-gt70). Hier stond alleen "de actor is niet lokaal",
743 // en dat is geen poort: elke ondertekende actor die de URI van een
744 // hulpvraag kende kon hem op 'handled' zetten. Afgehandeld kent geen
745 // terugdraai en de vraag verdwijnt daarna uit de teller van ELKE
746 // guardian -- een vreemde kon dus de noodknop van een kind uitzetten.
747 //
748 // Ondertekening zegt WIE, niet OF HET MAG. Die tweede laag stond er niet.
749 //
750 // DE WARD IS DE BRON VAN WAARHEID over wie zijn guardians zijn; onze
751 // eigen tabel kent alleen ONZE relatie. existingGuardiansOf stelt die
752 // vraag op de goede plek: lokaal opzoeken als wij de ward hosten,
753 // anders shaer:guardians van zijn actor.
754 //
755 // En WELKE ward dat is komt uit onze EIGEN administratie -- de
756 // hulpvraag zoals wij hem opsloegen -- nooit uit wat de afzender
757 // beweert. Kennen we die hulpvraag niet, dan is er niets te markeren.
758 const vraag = helpRequestRow(mark.noteUri);
759 if (!vraag) {
760 console.warn('[AP] help-markering voor een onbekende hulpvraag, genegeerd:', actorUri, '→', mark.noteUri);
761 } else if (!(await isGuardianOfWard(actorUri, vraag.actor_uri))) {
762 console.warn('[AP] help-markering van iemand die geen guardian van deze ward is, geweigerd:', actorUri, '→', mark.noteUri);
763 } else {
764 const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
765 Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
766 // Het paneel dat de hulpvraag HOUDT wordt gewekt, en dat is
767 // `vraag.slug`. Hier stond `slug`, en die bestaat in deze scope niet:
768 // de markering werd vastgelegd en daarna gooide de handler een
769 // ReferenceError, dus het paneel hoorde het nooit en de rest van de
770 // verwerking van deze activiteit viel weg. Gemeten, niet geredeneerd.
771 // slugParam zou hier ook fout zijn: op de gedeelde inbox is die null.
772 wakeGuardian(vraag.slug); // een mede-guardian pakte iets op: het paneel hoort het meteen
773 console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
774 }
775 }
776 }
777 if (actorUri && !isLocalActor && o.id) {
778 const slugs = localMentionSlugs(o.tag, base);
779 if (slugs.length) {
780 const ai = actorInfo(await resolveActor(actorUri), actorUri);
781 const html = HtmlSanitizerService.sanitize(o.content || '');
782 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
783 // flag is stored so the Guardian PWA's message centre can list it.
784 const help = Guardianship.isHelpRequest(o);
785 const wave = Guardianship.isWave(o);
786 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only
787 // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
788 // same direct note the mention below stores (so the kid also reads it
789 // as an ordinary message). Recorded only from an actual guardian of
790 // the addressed ward, and only with an end: an absence without an end
791 // is logged and dropped, never guessed.
792 if (Guardianship.availability.isAway(o)) {
793 const until = Guardianship.availability.parseEndTime(o.endTime);
794 for (const slug of slugs) {
795 const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
796 if (!isG) continue;
797 if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
798 Guardianship.availability.declareAway(slug, actorUri, until);
799 console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
800 }
801 }
802 // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de
803 // afwezigheidsmelding: een gewone directe note met een shaer:-markering,
804 // per genoemde ontvanger afgehandeld.
805 //
806 // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag
807 // maar een onbekende die iets over jouw instellingen wil zeggen -- dat
808 // hoort in geen enkele lijst te belanden waar een guardian op afgaat.
809 {
810 const req = Guardianship.gatereq.parseRequest(o);
811 if (req) {
812 for (const slug of slugs) {
813 const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();
814 if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }
815 Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);
816 wakeGuardian(slug); // het kind vroeg om een poort
817 console.log('[AP] gate request', req.feature, actorUri, '→', slug);
818 }
819 }
820 }
821 for (const slug of slugs) {
822 try {
823 // De OUDER gaat mee (Robins melding, 26-8). Hij stond nergens in
824 // deze rij, dus een antwoord binnen een gesprek kwam bij de client
825 // aan alsof het een gesprek begon: de app kan een keten alleen
826 // teruglopen langs inReplyTo, en die was leeg.
827 //
828 // Alleen een http(s)-adres, langs dezelfde poort als `url`: een
829 // inReplyTo komt van een vreemde en mag geen ander schema
830 // binnensmokkelen. AS2 staat een string of een object toe, dus
831 // allebei uitpakken -- alleen de string erkennen zou hetzelfde gat
832 // laten voor iedereen die de objectvorm stuurt.
833 const ouder = safeUrl(typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || null;
834 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, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
835 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
836 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, ouder, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
837 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
838 if (r.changes) {
839 // The quote / link-preview card resolves out of band (a remote
840 // fetch), exactly as it does for a timeline post, so the inbox
841 // answer is never blocked on it.
842 resolveCard(o).then((c) => {
843 if (!c) return;
844 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
845 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
846 }).catch(() => { /* best-effort */ });
847 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
848 const vis = noteVisibility(o);
849 const priv = vis === 'direct' || vis === 'followers';
850 const L = pushLang(slug);
851 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
852 // Same privacy rule as replies: private mentions push without content.
853 // A help request pushes as its own alert type, aimed at the
854 // Guardian PWA's message centre.
855 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
856 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` });
857 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
858 }
859 } catch { /* ignore */ }
860 }
861 }
862 }
863 return 202;
864 }
865 // A remote post we cached was edited upstream → refresh our cached copy. This is the
866 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
867 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
868 // edit A's note (the signature gate guarantees claimedActor == the verified signer).
869 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
870 const o = act.object;
871 if (o.id && claimedActor) {
872 const html = HtmlSanitizerService.sanitize(o.content || '');
873 const media = mediaFromNote(o);
874 try {
875 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
876 // rename keeps the same AP id but changes the human url, so without this the cached
877 // post would keep linking to the old, now-dead URL.
878 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
879 .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
880 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
881 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
882 // site keeps its own `voted` state while the counts/closed update to the new totals.
883 const poll = parsePoll(o);
884 if (poll) {
885 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
886 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
887 for (const rw of rows) {
888 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
889 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
890 }
891 }
892 } catch { /* ignore */ }
893 // If this note is a cached fediverse reply on one of our posts, refresh its text too.
894 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
895 }
896 return 202;
897 }
898 if (type === 'Like' || type === 'Announce') {
899 const tgt = act.object;
900 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
901 const pid = postIdFromNoteUrl(objUrl, base);
902 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
903 // A boost/like of a non-public post is dropped, not stored: nobody
904 // outside the audience should even hold it (shaer-tqc hardening).
905 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
906 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
907 console.log('[AP] dropped', type, 'on non-public post', pid);
908 return;
909 }
910 const ai = actorInfo(await resolveActor(actorUri), actorUri);
911 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
912 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
913 {
914 const ctx = pushPostCtx(pid);
915 if (ctx) {
916 const L = pushLang(ctx.site);
917 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
918 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 });
919 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 });
920 }
921 }
922 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
923 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
924 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
925 // re-announcing an incoming Announce would cascade boosts across the network).
926 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
927 if (subs.length) {
928 const bn = await fetchNoteAP(objUrl);
929 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
930 const origUri = actorUriOf(bn.attributedTo);
931 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
932 // author is blocked — otherwise a block is bypassed via someone else's boost.
933 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
934 const oai = actorInfo(await resolveActor(origUri), origUri);
935 const html = HtmlSanitizerService.sanitize(bn.content || '');
936 const media = mediaFromNote(bn);
937 const booster = actorInfo(await resolveActor(actorUri), actorUri);
938 for (const s of subs) {
939 // published = now → the boost shows as fresh activity at the top (Mastodon shows
940 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
941 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
942 let inserted = false;
943 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 */ }
944 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 */ } }
945 storeAuthorEmoji(bn.id, s.slug, oai); // custom-emoji display name for the byline
946 // A boost carries the same renderable tags as a Create: capture the
947 // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
948 // 044f) so boosted posts render like any other, not as raw shortcodes.
949 { 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 */ } } }
950 { 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 */ } } }
951 }
952 // FEP-044f: resolve the embedded quote card for a boosted post too
953 // (out of band, best-effort, so it does not block the inbox response).
954 if (quoteHrefOf(bn)) {
955 const slugs = subs.map((s) => s.slug);
956 resolveQuote(bn).then((qj) => {
957 if (!qj) return;
958 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 */ } }
959 }).catch(() => { /* best-effort */ });
960 }
961 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
962 }
963 }
964 }
965 return 202;
966 }
967 if (type === 'Delete') {
968 // A remote note was deleted upstream → drop it from replies AND the timeline.
969 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
970 // signature gate guarantees claimedActor == the verified signer here).
971 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
972 if (oid && claimedActor) {
973 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
974 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
975 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
976 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
977 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
978 // to A's posts).
979 try {
980 let sameHost = false;
981 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
982 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
983 } catch { /* ignore */ }
984 }
985 return 202;
986 }
987 // Accept/Reject of a Follow WE sent (client side).
988 if (type === 'Accept' && act.object) {
989 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
990 let raak = 0;
991 if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
992 // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
993 // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
994 // EIGEN id voor, in ONZE namespace:
995 //
996 // wij stuurden .../ap/users/dev#follow-1786161977286-bb2de32f
997 // Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
998 //
999 // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
1000 // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
1001 // die pas opviel toen er nooit iets binnenkwam.
1002 //
1003 // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
1004 // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
1005 // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
1006 // openzetten dan een follow die wij zelf hebben verstuurd.
1007 //
1008 // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
1009 // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
1010 // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
1011 // onze eigen actor-URI.
1012 let mij = slugParam;
1013 if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
1014 if (!raak && mij && actorUri) {
1015 try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
1016 }
1017 // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
1018 // zien te zijn in plaats van als succes voorbij te komen.
1019 console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
1020 // The moment a friendship exists is the moment the history comes along
1021 // (Robins besluit, 30-7): delivery cannot reach into the past, so the
1022 // fresh follower pulls the outbox, signed, and the other side now serves
1023 // the friends-only posts too.
1024 if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
1025 return 202;
1026 }
1027 if (type === 'Reject' && act.object) {
1028 const who = actorUri;
1029 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
1030 return 202;
1031 }
1032
1033 // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
1034 // tussen een soort die we bewust overslaan en een die we niet kennen -- en
1035 // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
1036 // waar deze inbox alleen Note, Article en Question aanneemt.
1037 const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
1038 console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
1039 'from', ip, 'by', claimedActor || '?', '(ignored)');
1040 return 202;
1041}
1042
Note: See TracBrowser for help on using the repository browser.