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