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

main
Last change on this file since 31680c3 was 31680c3, checked in by Robin <roboburr@…>, 7 hours ago

[Add to HUB] on the Connect page — the click is the owner's yes

Putting a klonkt on the Klonkt Hub means the hub sends a Follow. With the
owner gate on (the default) that Follow then waits for the owner — the same
person who just asked for it. Without this they would sign up on the hub and
then have to approve their own sign-up on /connect.

The button POSTs to /connect/add-to-hub, which records a one-day invitation
and redirects to the hub's sign-up form with the site's handle filled in
(?add=). A Follow from the hub's host, signed by the hub itself, passes the
owner gate while that invitation holds. It is not used up on the first
Follow: a hub that re-sends after a timeout would otherwise land in the queue
for something already decided.

Narrow on purpose: only the hub's host, only signed by it, only for a day.
Anyone else still waits for the owner, and a WARD's guardians still decide —
their gate sits before this one and this path never reaches it.

If a request from the hub is already waiting (someone signed the klonkt up
earlier), the click is exactly the yes it waits for: it is accepted on the
spot and the button goes to the channel on the hub instead of the form. Once
the hub follows, /connect shows a confirmation instead of the button. Hidden
after a move, where the outgoing side is locked.

KLONKT_HUB_URL lets a self-hoster point the button at another hub; default
​https://hub.klonkt.com.

Co-Authored-By: Claude Opus 5.5 <noreply@…>

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