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

main
Last change on this file since 6e9bc70 was 6e9bc70, checked in by Bart <bart@…>, 2 days ago

Zachte groepen: een gesprek is de set deelnemers, niet één tegenpartij

Een direct bericht aan Anna en Ben bestond al als groep -- to_actors droeg de
hele set -- maar elke lezing sleutelde op één other. Een uitgaand bericht
aan twee mensen viel in twee gesprekken uiteen, en een binnenkomend bericht
van Anna aan jou en Ben kwam alleen bij Anna terecht: Ben viel eruit, want de
adressering van een binnenkomer werd nergens bewaard.

Nu is er een sleutel, één keer berekend bij het schrijven
(conversation-key.js): voor één ander zijn kale URI, precies zoals het altijd
was, dus bestaande gesprekken, leesmarkeringen en ?with=-URL's blijven geldig
zonder migratie; voor een groep de gesorteerde set, gescheiden door een
spatie. Koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal
via dezelfde unie, en werken daarmee per gesprek zonder dat er in die queries
iets hoefde te veranderen. Oude rijen hebben geen sleutel en lezen zoals ze
altijd lazen.

Alleen directe berichten groeperen: een openbare post aan jou en Ben is geen
besloten gesprek met z'n drieën.

/messages neemt meer dan één with, en elk item draagt shaer:conversation en
shaer:participants. Uitgaande directe notes krijgen een FEP-7888 context --
die van de ouder als het een antwoord is, anders een nieuwe -- die bewaard en
uitgestuurd wordt, maar geen bubbels samenvoegt: een bubbel die Carla toont
terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.

shaer-mbji
Co-Authored-By: Claude Opus 5.5 <claude@…>

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