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

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

De negatieve dereference-cache is geen censuurknop meer (shaer-qawr)

De cache onthield elke mislukte dereference op de note-URI alleen, en een
van de twee manieren om te mislukken is volledig door een vreemde te
sturen. Neem de echte note-URI van je slachtoffer, zet je eigen actor op
DEZELFDE host als beweerde afzender en wijs met inReplyTo naar een van
onze publieke notes: de fetch bij de bron slaagt, attributedTo is het
slachtoffer en niet jij, en die note-URI stond dertig minuten op de
zwarte lijst. Het echte doorgestuurde antwoord liep daar daarna op stuk.
Elke dertig minuten herhalen gaf onbeperkte, gerichte onderdrukking van
een specifiek antwoord, voor een verzoek per keer.

De sleutel is nu de hele bescherming, want de twee soorten mislukking
zeggen iets verschillends. Een TRANSPORTFOUT is een eigenschap van de
note zelf en geldt voor iedereen die hem doorstuurt: sleutel is de
note. Een attributedTo-MISMATCH zegt iets over DEZE doorstuurder:
sleutel is (note, beweerde actor). De leugenaar remt daarmee alleen
zichzelf af, en dat was de rem die de cache moest zijn.

Tweede punt uit dezelfde bead: query en fragment tellen niet meer mee in
de sleutel. Met een kale URL waren ?x=1 en ?x=2 losse ingangen, dus
varieren kostte niets en er was helemaal geen rem -- en met vijfhonderd
losse sleutels waren de echte ingangen eruit te duwen.

Bewust NIET gedaan: een rem per HOST. Wie een handvol niet-bestaande
URL's op een host laat mislukken zou daarmee die hele host het zwijgen
opleggen, en dat is een grotere versie van precies de fout die hier
gerepareerd wordt. Dat staat als waarschuwing bij de code.

Drie toetsen, elk met eigen tegenbewijs: met de sleutel terug op de note
alleen valt de censuurtoets en blijven de andere twee groen, en met de
URL-normalisatie eruit valt alleen de remtoets. Volle suite 1237.

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