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