source: Klonkt/src/services/ap-inbox.js@ 76290bf

main
Last change on this file since 76290bf was 76290bf, checked in by Robin <roboburr@…>, 2 weeks ago

Inbox: een binnengekomen antwoord houdt zijn inReplyTo

Robins melding, 26-8: de C2S-lezing serveerde antwoorden zonder ouder.

De oorzaak lag een laag dieper dan de serialisatie: ap_mentions had geen
kolom voor inReplyTo, dus de ouder viel al bij het OPSLAAN op de grond en
messageItem had niets te serveren. Een client kan een gesprek alleen
teruglopen langs inReplyTo, dus elk antwoord kwam aan als het begin van
een gesprek -- de ketenlezing in de app kon nooit verder dan een.

Vier plekken, want het is de hele weg: de kolom (met ensureColumn voor
bestaande databases), het schrijven in de inbox, MESSAGE_COLUMNS -- de
ene leesplek die zowel de inbox-lijst als de gesprekken voedt -- en
messageItem.

De ouder gaat door dezelfde poort als de note-url: alleen http(s), en zowel de
string- als de objectvorm die AS2 toestaat. Alleen de string erkennen
zou hetzelfde gat laten voor wie de objectvorm stuurt.

Nagegaan wat WEL goed ging, zodat de reparatie niet breder wordt dan de
kwaal: replyItem droeg hem al uit ap_interactions.parent_uri, sentItem
via buildNote uit ap_outbox.in_reply_to, en de tijdlijn kan hem per
definitie niet missen -- belongsInTimeline weigert alles met inReplyTo.
Deze leg was de enige.

Bestaande rijen blijven leeg: die ouder is niet meer te achterhalen
zonder hem opnieuw op te halen, en een verzonnen ouder is erger dan
geen. Twee toetsen over de hele keten, tegenbewijs gedraaid: allebei
vallen ze tegen de code van hiervoor. Volle suite 1231 groen.

  • Property mode set to 100644
File size: 56.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 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 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// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
142// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
143// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
144const _derefMiss = new Map();
145const DEREF_MISS_MS = 30 * 60 * 1000;
146function derefRecentlyFailed(uri) {
147 const t = _derefMiss.get(uri);
148 if (t === undefined) return false;
149 if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
150 return true;
151}
152function noteDerefFailure(uri) {
153 if (_derefMiss.size > 500) { // simpele begrenzing: oudste helft eruit
154 const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
155 for (const [k] of oud) _derefMiss.delete(k);
156 }
157 _derefMiss.set(uri, Date.now());
158}
159
160async function dereferenceForwarded(act, claimedActor, type, slugParam) {
161 // Every exit states its reason. Five of the six used to return silently, so a
162 // rejection count could not be told apart from a narrowing that closed too far
163 // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
164 // the signer-mismatch rate (tens per hour), so this is not a noisy log.
165 const skipped = (reason, detail) => {
166 console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
167 return null;
168 };
169 if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
170 const o = act && act.object;
171 const objId = typeof o === 'string' ? o : (o && o.id);
172 if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
173 try {
174 if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId); // ankereis
175 } catch { return skipped('unparsable id', objId); }
176 // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
177 // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
178 // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
179 // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
180 // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
181 // niets aan legitiem verkeer waarvan we de ouder kennen.
182 const parent = typeof o === 'object' && o
183 ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
184 : null;
185 if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
186 if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
187 // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
188 // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
189 // reden als bij fetchActor sinds efe5633.
190 let fetched = await apGetJson(objId).catch(() => null);
191 if (!fetched || fetched.id !== objId) {
192 // The signer used to be slugParam, which is null on the shared inbox — and
193 // that is where forwarded traffic lands, because we advertise a sharedInbox.
194 // signedGetJson falls back to an unsigned GET for a null slug, so a source in
195 // secure mode could never be dereferenced at all. Same fix verifyRequest got
196 // in shaer-afq: any local actor is a valid signer.
197 const asSlug = slugParam || anySigningSlug();
198 if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
199 }
200 const attributed = fetched && (typeof fetched.attributedTo === 'string'
201 ? fetched.attributedTo
202 : (fetched.attributedTo && fetched.attributedTo.id));
203 if (!fetched || fetched.id !== objId) {
204 noteDerefFailure(objId);
205 return skipped('fetch failed', objId);
206 }
207 if (attributed !== claimedActor) {
208 // Not a transport hiccup: the source itself says someone else wrote this.
209 noteDerefFailure(objId);
210 return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
211 }
212 return fetched;
213}
214
215// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
216export async function handleInbox(req, slugParam, preVerified = null) {
217 const act = req.body || {};
218 const type = act.type;
219 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
220 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
221 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
222 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
223 // preVerified is the loopback (see deliverToActor): a delivery between two
224 // actors on THIS instance never crosses a socket, so there is no signature to
225 // check — but we do know who signed, because we signed it. Handing that in
226 // keeps everything below identical, including the actor-versus-signer check,
227 // which is exactly the check that must not be skipped for being local.
228 const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
229
230 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
231 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
232 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
233 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
234 // Blocked actor/domain → silently drop (202, don't reveal the block).
235 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
236 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
237 if (GATED.includes(type)) {
238 // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
239 // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
240 // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
241 let forwarded = null;
242 if (verified && claimedActor && verified.id !== claimedActor) {
243 forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
244 if (forwarded) {
245 act.object = forwarded; // de OPGEHAALDE inhoud, niet de bezorgde
246 console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
247 }
248 }
249 if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
250 // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
251 // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
252 // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
253 // of geldig ondertekend door iemand anders.
254 const reden = verified ? '(signer mismatch)'
255 : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
256 : '(no signature)';
257 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
258 return 401;
259 }
260 // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
261 // from an actor that guards someone here restores it to active for those
262 // wards and cancels any lapse running against it, before the activity is
263 // even looked at. Signature-gated on purpose: an unverified claim of
264 // being gran must not wake gran up.
265 try {
266 const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
267 if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
268 for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
269 } catch { /* availability is never load-bearing for delivery */ }
270 }
271
272 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
273 // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
274 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
275 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
276 }
277
278 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
279 // Accept/Reject answers an offer a local guardian sent. Anything the
280 // guardianship module does not recognize falls through to the old paths.
281 // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
282 // must be seen BEFORE the generic Undo branch below, which only knows about
283 // Follow/Like/Announce and would swallow it with a 202.
284 if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
285 // Every LOCAL party this activity is addressed to gets its own copy of the
286 // handshake (a ward and a co-guardian may both live here). Gather candidate
287 // local slugs from the inbox owner, the `to` list, and the ward.
288 // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
289 // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
290 // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
291 // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
292 // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
293 // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
294 // eigen basis begint en dat de site echt bestaat.
295 const cand = new Set();
296 if (slugParam) cand.add(slugParam);
297 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
298 if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
299 }
300 if (type === 'Offer' || type === 'Undo') {
301 const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
302 if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
303 }
304 let consumed = false;
305 for (const slug of cand) {
306 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
307 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
308 }
309 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
310 }
311
312 // A moderation report (Flag) about our content — store it for the targeted site's owner
313 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
314 if (type === 'Flag') {
315 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
316 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
317 let targetSlug = null;
318 const noteIds = [];
319 for (const u of objectUris) {
320 const s = localSlugOf(u); // one of OURS -- host meegewogen
321 if (s) { targetSlug = targetSlug || s; continue; }
322 const pid = postIdFromNoteUrl(u, base); // one of our notes?
323 if (pid) noteIds.push(pid);
324 }
325 if (!targetSlug && noteIds.length) {
326 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 */ }
327 }
328 if (!targetSlug) return 202; // not about us / can't tell → drop
329 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
330 const ai = actorInfo(verified || null, claimedActor);
331 try {
332 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
333 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
334 console.log('[AP] report received for', targetSlug, 'from', claimedActor);
335 } catch { /* ignore */ }
336 return 202;
337 }
338
339 // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
340 // a Move often arrives seconds before the new actor's re-Follow wave, and the
341 // swap below must not race our own outgoing Follow of the target.
342 if (type === 'Move') {
343 return handleMoveInbox(act, { verifiedActor: claimedActor });
344 }
345
346 if (type === 'Follow') {
347 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
348 // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
349 //
350 // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
351 // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
352 // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
353 // ongeluk meenemen.
354 //
355 // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
356 // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
357 const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
358 if (who && libSlug) {
359 const remote = await fetchActor(who);
360 if (!remote || !remote.inbox) return 202;
361 const fi = actorInfo(remote, who);
362 luisteraars.voegToe(libSlug, {
363 actorUri: who, inbox: remote.inbox,
364 sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
365 name: fi.name, handle: fi.handle, icon: fi.icon,
366 });
367 const keys = getOrCreateKeys(libSlug);
368 const accept = {
369 '@context': AP_CONTEXT,
370 id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
371 type: 'Accept', actor: actorId(base, libSlug), object: act,
372 };
373 deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
374 .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
375 console.log('[AP] library follow from', who, '->', libSlug);
376 return 202;
377 }
378 // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
379 // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
380 // kon een Follow op andermans actor met dezelfde padstaart hier een volger
381 // opleveren.
382 const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
383 if (!who || !slug) return 400;
384 const remote = await fetchActor(who);
385 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
386 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
387 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)
388 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
389 // follow is gated. A committed guardian's own Follow is auto-accepted
390 // (it needs no gate); anyone else is held pending for guardian approval.
391 // Free actors / normal sites have no guardians → fall through, unchanged.
392 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
393 if (wardGuardians.length && !wardGuardians.includes(who)) {
394 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
395 Guardianship.follows.recordPending(slug, {
396 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
397 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
398 });
399 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
400 // gated follow to its guardians for approval. A LOCAL guardian gets a
401 // push and reads /guardian directly; a REMOTE guardian gets an
402 // Offer(Follow) delivered so its instance stores a copy (same distributed
403 // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
404 const wardActor = actorId(base, slug);
405 const wardKeys = getOrCreateKeys(slug);
406 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
407 // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
408 // every guardian. The ONLY admissible evidence is a request like this
409 // one going unanswered; recordRequest itself skips a declared absence.
410 for (const g of wardGuardians) {
411 try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
412 }
413 for (const g of wardGuardians) {
414 // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
415 // ignores the host (an /ap/users/x path on a remote host is someone
416 // else's actor), so also require our base + an existing local site.
417 const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
418 const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
419 if (isLocal) {
420 const L = pushLang(gslug);
421 // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
422 // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
423 // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
424 // er vraagt, en om wie het gaat (shaer-p729).
425 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` });
426 } else {
427 fetchActor(g).then((ga) => {
428 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
429 if (!inbox) return;
430 const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
431 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 };
432 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
433 }).catch(() => {});
434 }
435 }
436 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
437 return 202;
438 }
439 // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
440 // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
441 // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
442 // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
443 // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
444 // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
445 const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
446 if (ownerGate && ownerGate.approve_followers) {
447 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
448 Guardianship.follows.recordPending(slug, {
449 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
450 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
451 });
452 const L = pushLang(slug);
453 pushEvent(slug, {
454 type: 'follow',
455 title: i18nT(L, 'push.n_folreq_t'),
456 body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
457 url: `${pushPrefix(slug)}/connect`,
458 });
459 console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
460 return 202;
461 }
462 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
463 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
464 { 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` }); }
465 const me = actorId(base, slug);
466 const keys = getOrCreateKeys(slug);
467 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
468 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
469 // Auto-backfill: send our recent posts as Create so the instance has our history
470 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
471 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
472 // a follower of ours is wasted work (and won't re-populate the new follower's
473 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
474 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
475 const instanceFilled = sharedInbox &&
476 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
477 .get(slug, sharedInbox, who);
478 if (!instanceFilled) {
479 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
480 }
481 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
482 return 202;
483 }
484 // Een luisteraar die weggaat, hoort meteen weg te zijn.
485 if (type === 'Undo' && act.object && act.object.type === 'Follow') {
486 const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
487 const libSlug = libraryOwnerSlug(doel);
488 const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
489 if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
490 console.log('[AP] library unfollow from', wie, '->', libSlug);
491 return 202;
492 }
493 }
494
495 if (type === 'Undo' && act.object) {
496 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
497 const ot = act.object.type;
498 if (ot === 'Follow') {
499 const obj = act.object.object;
500 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
501 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
502 return 202;
503 }
504 if (ot === 'Like' || ot === 'Announce') {
505 const tgt = act.object.object;
506 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
507 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
508 return 202;
509 }
510 return 202;
511 }
512
513 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
514 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
515 // Our OWN activity is already stored via ap_outbox: don't store it twice.
516 // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
517 // machine". The old reading dropped every activity between two sites on one
518 // instance, so a note from a co-located guardian to its ward was accepted
519 // with a 202 and then quietly thrown away: no mention, no away, no help
520 // request. Neighbours are not us (Robins regel, 29-7: on this machine
521 // everything behaves as if every Klonkt were somewhere else).
522 const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
523
524 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
525 if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
526 const o = act.object;
527 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
528 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
529 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
530 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
531 const seg = postIdFromNoteUrl(o.inReplyTo, base);
532 if (seg && localPostExists(seg)) {
533 const rec = recordPollBallot(seg, actorUri, o.name);
534 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
535 }
536 }
537 const tgt = findThreadTarget(o.inReplyTo, base);
538 if (tgt && actorUri && !isLocalActor) {
539 const ai = actorInfo(await resolveActor(actorUri), actorUri);
540 const html = HtmlSanitizerService.sanitize(o.content || '');
541 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
542 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));
543 console.log('[AP] reply', actorUri, '→', tgt.post_id);
544 // A reply is a post too: Berichten renders it the way de Krant renders a
545 // timeline row, so it needs the same media and the same quote/preview card.
546 {
547 const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
548 const key = ['reply', tgt.post_id, actorUri, o.id || ''];
549 const mj = mediaFromNote(o);
550 if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
551 resolveCard(o).then((c) => {
552 if (!c) return;
553 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
554 try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
555 }).catch(() => { /* best-effort */ });
556 }
557 {
558 // Private (followers/direct) replies push as a DM ping WITHOUT content
559 // (the push service should never carry private text, design decision);
560 // public replies carry a short snippet.
561 const ctx = pushPostCtx(tgt.post_id);
562 const vis = noteVisibility(o);
563 const priv = vis === 'direct' || vis === 'followers';
564 if (ctx) {
565 const L = pushLang(ctx.site);
566 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
567 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` });
568 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 });
569 }
570 }
571 return 202;
572 }
573 // Home timeline (client): a top-level post from an account we follow.
574 if (actorUri && !isLocalActor && belongsInTimeline(o)) {
575 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
576 if (subs.length) {
577 const ai = actorInfo(await resolveActor(actorUri), actorUri);
578 const { html, atts: _atts, url: _url } = timelineFields(o);
579 const media = JSON.stringify(_atts);
580 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
581 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
582 // incoming posts to the fediverse — that flooded followers. Boosting to the
583 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
584 // the timeline).
585 for (const s of subs) {
586 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));
587 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
588 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 */ } }
589 // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
590 { 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 */ } } }
591 storeAuthorEmoji(o.id, s.slug, ai); // custom-emoji display name for the byline
592
593 // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
594 { 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 */ } } }
595 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 */ } }
596 }
597 // FEP-044f embedded quote card: resolve the quoted post out of band so
598 // the inbox response is not blocked on a remote fetch. Best-effort.
599 if (quoteHrefOf(o)) {
600 const slugs = subs.map((s) => s.slug);
601 resolveQuote(o).then((qj) => {
602 if (!qj) return;
603 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 */ } }
604 }).catch(() => { /* best-effort */ });
605 } else {
606 // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
607 // thumbnail-only. Also out of band, and stored for everyone; the gate
608 // that decides who may SEE it is applied at serve time (§5.3-style
609 // gated feature, see the inbox read).
610 const slugs = subs.map((s) => s.slug);
611 resolveExternalEmbed(o.content).then((ej) => {
612 if (!ej) return;
613 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 */ } }
614 }).catch(() => { /* best-effort */ });
615 }
616 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
617 }
618 }
619 // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
620 // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
621 // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
622 // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
623 // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
624 // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
625 // moet iemand zijn die je zelf bent gaan volgen.
626 if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
627 rememberNoteUri(o.id);
628 }
629 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
630 // above): store a mention notification for each of our actors named in the Mention tags.
631 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
632 // someone else's actor, not ours.
633 // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
634 // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
635 // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
636 // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
637 // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
638 if (actorUri && !isLocalActor) {
639 const mark = Guardianship.help.parseMarker(o);
640 if (mark) {
641 const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
642 Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
643 wakeGuardian(slug); // een mede-guardian pakte iets op: het paneel hoort het meteen
644 console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
645 }
646 }
647 if (actorUri && !isLocalActor && o.id) {
648 const slugs = localMentionSlugs(o.tag, base);
649 if (slugs.length) {
650 const ai = actorInfo(await resolveActor(actorUri), actorUri);
651 const html = HtmlSanitizerService.sanitize(o.content || '');
652 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
653 // flag is stored so the Guardian PWA's message centre can list it.
654 const help = Guardianship.isHelpRequest(o);
655 const wave = Guardianship.isWave(o);
656 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only
657 // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
658 // same direct note the mention below stores (so the kid also reads it
659 // as an ordinary message). Recorded only from an actual guardian of
660 // the addressed ward, and only with an end: an absence without an end
661 // is logged and dropped, never guessed.
662 if (Guardianship.availability.isAway(o)) {
663 const until = Guardianship.availability.parseEndTime(o.endTime);
664 for (const slug of slugs) {
665 const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
666 if (!isG) continue;
667 if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
668 Guardianship.availability.declareAway(slug, actorUri, until);
669 console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
670 }
671 }
672 // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de
673 // afwezigheidsmelding: een gewone directe note met een shaer:-markering,
674 // per genoemde ontvanger afgehandeld.
675 //
676 // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag
677 // maar een onbekende die iets over jouw instellingen wil zeggen -- dat
678 // hoort in geen enkele lijst te belanden waar een guardian op afgaat.
679 {
680 const req = Guardianship.gatereq.parseRequest(o);
681 if (req) {
682 for (const slug of slugs) {
683 const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();
684 if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }
685 Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);
686 wakeGuardian(slug); // het kind vroeg om een poort
687 console.log('[AP] gate request', req.feature, actorUri, '→', slug);
688 }
689 }
690 }
691 for (const slug of slugs) {
692 try {
693 // De OUDER gaat mee (Robins melding, 26-8). Hij stond nergens in
694 // deze rij, dus een antwoord binnen een gesprek kwam bij de client
695 // aan alsof het een gesprek begon: de app kan een keten alleen
696 // teruglopen langs inReplyTo, en die was leeg.
697 //
698 // Alleen een http(s)-adres, langs dezelfde poort als `url`: een
699 // inReplyTo komt van een vreemde en mag geen ander schema
700 // binnensmokkelen. AS2 staat een string of een object toe, dus
701 // allebei uitpakken -- alleen de string erkennen zou hetzelfde gat
702 // laten voor iedereen die de objectvorm stuurt.
703 const ouder = safeUrl(typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || null;
704 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)
705 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
706 .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,
707 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
708 if (r.changes) {
709 // The quote / link-preview card resolves out of band (a remote
710 // fetch), exactly as it does for a timeline post, so the inbox
711 // answer is never blocked on it.
712 resolveCard(o).then((c) => {
713 if (!c) return;
714 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
715 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
716 }).catch(() => { /* best-effort */ });
717 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
718 const vis = noteVisibility(o);
719 const priv = vis === 'direct' || vis === 'followers';
720 const L = pushLang(slug);
721 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
722 // Same privacy rule as replies: private mentions push without content.
723 // A help request pushes as its own alert type, aimed at the
724 // Guardian PWA's message centre.
725 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
726 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` });
727 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
728 }
729 } catch { /* ignore */ }
730 }
731 }
732 }
733 return 202;
734 }
735 // A remote post we cached was edited upstream → refresh our cached copy. This is the
736 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
737 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
738 // edit A's note (the signature gate guarantees claimedActor == the verified signer).
739 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
740 const o = act.object;
741 if (o.id && claimedActor) {
742 const html = HtmlSanitizerService.sanitize(o.content || '');
743 const media = mediaFromNote(o);
744 try {
745 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
746 // rename keeps the same AP id but changes the human url, so without this the cached
747 // post would keep linking to the old, now-dead URL.
748 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
749 .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
750 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
751 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
752 // site keeps its own `voted` state while the counts/closed update to the new totals.
753 const poll = parsePoll(o);
754 if (poll) {
755 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
756 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
757 for (const rw of rows) {
758 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
759 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
760 }
761 }
762 } catch { /* ignore */ }
763 // If this note is a cached fediverse reply on one of our posts, refresh its text too.
764 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
765 }
766 return 202;
767 }
768 if (type === 'Like' || type === 'Announce') {
769 const tgt = act.object;
770 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
771 const pid = postIdFromNoteUrl(objUrl, base);
772 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
773 // A boost/like of a non-public post is dropped, not stored: nobody
774 // outside the audience should even hold it (shaer-tqc hardening).
775 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
776 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
777 console.log('[AP] dropped', type, 'on non-public post', pid);
778 return;
779 }
780 const ai = actorInfo(await resolveActor(actorUri), actorUri);
781 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
782 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
783 {
784 const ctx = pushPostCtx(pid);
785 if (ctx) {
786 const L = pushLang(ctx.site);
787 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
788 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 });
789 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 });
790 }
791 }
792 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
793 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
794 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
795 // re-announcing an incoming Announce would cascade boosts across the network).
796 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
797 if (subs.length) {
798 const bn = await fetchNoteAP(objUrl);
799 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
800 const origUri = actorUriOf(bn.attributedTo);
801 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
802 // author is blocked — otherwise a block is bypassed via someone else's boost.
803 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
804 const oai = actorInfo(await resolveActor(origUri), origUri);
805 const html = HtmlSanitizerService.sanitize(bn.content || '');
806 const media = mediaFromNote(bn);
807 const booster = actorInfo(await resolveActor(actorUri), actorUri);
808 for (const s of subs) {
809 // published = now → the boost shows as fresh activity at the top (Mastodon shows
810 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
811 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
812 let inserted = false;
813 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 */ }
814 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 */ } }
815 storeAuthorEmoji(bn.id, s.slug, oai); // custom-emoji display name for the byline
816 // A boost carries the same renderable tags as a Create: capture the
817 // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
818 // 044f) so boosted posts render like any other, not as raw shortcodes.
819 { 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 */ } } }
820 { 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 */ } } }
821 }
822 // FEP-044f: resolve the embedded quote card for a boosted post too
823 // (out of band, best-effort, so it does not block the inbox response).
824 if (quoteHrefOf(bn)) {
825 const slugs = subs.map((s) => s.slug);
826 resolveQuote(bn).then((qj) => {
827 if (!qj) return;
828 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 */ } }
829 }).catch(() => { /* best-effort */ });
830 }
831 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
832 }
833 }
834 }
835 return 202;
836 }
837 if (type === 'Delete') {
838 // A remote note was deleted upstream → drop it from replies AND the timeline.
839 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
840 // signature gate guarantees claimedActor == the verified signer here).
841 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
842 if (oid && claimedActor) {
843 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
844 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
845 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
846 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
847 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
848 // to A's posts).
849 try {
850 let sameHost = false;
851 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
852 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
853 } catch { /* ignore */ }
854 }
855 return 202;
856 }
857 // Accept/Reject of a Follow WE sent (client side).
858 if (type === 'Accept' && act.object) {
859 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
860 let raak = 0;
861 if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
862 // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
863 // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
864 // EIGEN id voor, in ONZE namespace:
865 //
866 // wij stuurden .../ap/users/dev#follow-1786161977286-bb2de32f
867 // Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
868 //
869 // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
870 // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
871 // die pas opviel toen er nooit iets binnenkwam.
872 //
873 // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
874 // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
875 // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
876 // openzetten dan een follow die wij zelf hebben verstuurd.
877 //
878 // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
879 // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
880 // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
881 // onze eigen actor-URI.
882 let mij = slugParam;
883 if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
884 if (!raak && mij && actorUri) {
885 try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
886 }
887 // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
888 // zien te zijn in plaats van als succes voorbij te komen.
889 console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
890 // The moment a friendship exists is the moment the history comes along
891 // (Robins besluit, 30-7): delivery cannot reach into the past, so the
892 // fresh follower pulls the outbox, signed, and the other side now serves
893 // the friends-only posts too.
894 if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
895 return 202;
896 }
897 if (type === 'Reject' && act.object) {
898 const who = actorUri;
899 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
900 return 202;
901 }
902
903 // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
904 // tussen een soort die we bewust overslaan en een die we niet kennen -- en
905 // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
906 // waar deze inbox alleen Note, Article en Question aanneemt.
907 const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
908 console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
909 'from', ip, 'by', claimedActor || '?', '(ignored)');
910 return 202;
911}
912
Note: See TracBrowser for help on using the repository browser.