source: Klonkt/src/services/ActivityPubService.js@ db81e56

main
Last change on this file since db81e56 was db81e56, checked in by Robin <roboburr@…>, 2 weeks ago

Opsplitsing stap 9 (shaer-drc): de inbox naar ap-inbox.js

Het hart van de ontvangst verhuist als een blok -- 840 regels,
byte-voor-byte: handleInbox, de her-verificatie van doorgestuurde
activiteiten (dereferenceForwarded, shaer-s8k) en de kas eromheen
(bekende notes, geziene notes, recente ophaal-missers). TIJDLIJN_SOORTEN
gaat mee met zijn commentaar: de inbox was zijn enige lezer nog.

De inbox is de schakelkast van de dienst. Wat al een module heeft komt
statisch binnen (transport, tijdlijn, peilingen, volgwinkel,
guardianship, ap-core); de vierendertig werktuigen die nog in de
dienstlaag wonen komen via wireInbox, en die lijst is bewust lang en
expliciet -- hij is de kaart van wat de inbox aanraakt, en elke naam die
er ooit afgaat is een cluster dat zelf verhuisd is. De §5.3-goedkeuring
blijft bij zijn guardian-broers, zoals gateOutgoingFollow bij stap 7.

Les van deze snede, nu deel van het regime: een aanroep-scan mist een
kale constante (TIJDLIJN_SOORTEN kostte twaalf rode toetsen op de eerste
proefrit); de controle is voortaan de doorsnede van ALLE identifiers in
de module met de topniveau-definities van de dienst.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 4374 regels.

  • Property mode set to 100644
File size: 240.5 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import fs from 'fs';
20import path from 'path';
21import db from '../config/database.js';
22import HtmlSanitizerService from './HtmlSanitizerService.js';
23import AudioEmbedService from './AudioEmbedService.js';
24import EmbedResolver from './EmbedResolver.js';
25import Push from './PushService.js';
26import { t as i18nT } from './i18n.js';
27import Blocklist from './BlocklistService.js';
28import * as Guardianship from './guardianship/index.js';
29import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection, PAGINA_GROOTTE, artiestUrl } from './ap-core.js';
30// Stap 3 van de opsplitsing (shaer-drc): het transport -- de SSRF-poort, de
31// sleutels, HTTP Signatures, de bezorging met wachtrij en de ondertekende
32// GET -- woont in ap-transport.js. Hier her-geëxporteerd zodat elke bestaande
33// importeur blijft werken, hetzelfde patroon als de Guardianship-exports onderaan.
34import {
35 safeFetch, getOrCreateKeys, deliver, fetchActor,
36 enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker,
37 anySigningSlug, verifyRequest, signedGetHeaders, signedGetJson, apGetJson,
38} from './ap-transport.js';
39export {
40 safeFetch, getOrCreateKeys, deliver, fetchActor,
41 enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker,
42 verifyRequest, signedGetHeaders, signedGetJson,
43};
44// Stap 4 (shaer-drc): de C2S-inname woont in ap-c2s.js. Die is een coordinator
45// en krijgt zijn werktuigen uit de dienstlaag onderaan dit bestand via
46// wireC2S -- de regel blijft dat een module NOOIT uit dit bestand importeert.
47import { ingestOutboxActivity, wireC2S } from './ap-c2s.js';
48export { ingestOutboxActivity };
49// Stap 5 (shaer-drc): de leeskant van de tijdlijn woont in ap-timeline.js.
50// tlStmts komt mee terug omdat de SCHRIJVERS (inbox, backfill, self-heal,
51// upsertBoostedNote) hier wonen; wireTimeline krijgt onderaan zijn ene
52// werktuig uit het reactiecluster.
53import {
54 tlStmts, wireTimeline,
55 getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
56 feedCursor, feedChangesSince, waitForFeedChange,
57 conversationHeads, conversationHistory, messageRowsByUri,
58 readMarkers, markRead, unreadPerConversation, getDirectMessages,
59 isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
60 stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
61 extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
62} from './ap-timeline.js';
63export {
64 getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
65 feedCursor, feedChangesSince, waitForFeedChange,
66 conversationHeads, conversationHistory, messageRowsByUri,
67 readMarkers, markRead, unreadPerConversation, getDirectMessages,
68 isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
69 stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
70 extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
71};
72// Stap 6 (shaer-drc): het reactiecluster woont in ap-reactions.js. Dat
73// importeert tlStmts zelf statisch uit ap-timeline; alleen movedLock gaat er
74// onderaan via wireReactions in.
75import {
76 wireReactions,
77 setMyReaction, getMyReactions,
78 markBoosted, unmarkBoosted, markLiked, unmarkLiked,
79 migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
80 setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
81} from './ap-reactions.js';
82export {
83 setMyReaction, getMyReactions,
84 markBoosted, unmarkBoosted, markLiked, unmarkLiked,
85 migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
86 setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
87};
88// Stap 7 (shaer-drc): de volgwinkel woont in ap-following.js. fwStmts komt
89// mee terug voor de Accept-tak van de inbox en de verhuizing (FEP-7628);
90// wireFollowing krijgt onderaan zijn zes werktuigen.
91import {
92 fwStmts, wireFollowing,
93 webfingerResolve, listFollowing, setAutoBoost,
94 followActor, resolveRemoteActor, unfollowActor,
95} from './ap-following.js';
96export {
97 webfingerResolve, listFollowing, setAutoBoost,
98 followActor, resolveRemoteActor, unfollowActor,
99};
100// Stap 8 (shaer-drc): de peilingen wonen in ap-polls.js. parsePoll,
101// applyPollToNote en recordPollBallot komen terug voor de inbox, buildNote en
102// de backfill, maar blijven naar buiten toe prive zoals ze waren.
103import {
104 wirePolls,
105 parsePoll, applyPollToNote, recordPollBallot,
106 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
107 voteOnPoll, voteOnRemotePoll,
108} from './ap-polls.js';
109export {
110 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
111 voteOnPoll, voteOnRemotePoll,
112};
113// Stap 9 (shaer-drc): de inbox woont in ap-inbox.js. De schakelkast krijgt
114// onderaan zijn tweeendertig werktuigen via wireInbox.
115import { handleInbox, wireInbox } from './ap-inbox.js';
116export { handleInbox };
117// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
118export { AP_CONTEXT, actorId, noteId, guessMediaType };
119// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
120// vandaan kwam, zodat elke bestaande aanroep blijft werken.
121import { luisteraars } from './music/index.js';
122import { TRACK_KOLOMMEN,
123 playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
124 buildTrackAudio, buildTrackCollection, buildTrackCreate, trackUri, buildMixtapeObject, postMusicType,
125 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
126 buildPostTrackCollection, uitgavePost,
127 buildLibrary, libraryId,
128 licentieUri, channelCategory,
129} from './music/index.js';
130export {
131 playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
132 buildTrackAudio, buildTrackCollection, buildTrackCreate,
133 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
134 buildPostTrackCollection, uitgavePost,
135 buildLibrary, libraryId,
136};
137
138
139// Short random suffix so two activity ids minted in the same millisecond (e.g.
140// parallel saves) don't collide and get deduped by a receiver.
141const rid = () => crypto.randomBytes(4).toString('hex');
142
143// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
144// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
145
146const MAX_OUTBOX = 20;
147// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
148// (square) player card. Bump this whenever the twitter:player card dimensions change.
149const FEDI_CARD_VER = '2';
150
151// ── content negotiation ───────────────────────────────────────────
152// True when the caller wants ActivityPub JSON rather than the HTML page.
153export function apWants(req) {
154 const a = String(req.headers.accept || '').toLowerCase();
155 return a.includes('application/activity+json') ||
156 (a.includes('application/ld+json') && a.includes('activitystreams'));
157}
158
159const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
160/**
161 * Hetzelfde antwoord als de vorige keer? Dan 304 (Barts punt, 9-8).
162 *
163 * De inbox doet dit al met `since` + `wait`, en de guardian-wachtrijen niet: die
164 * stuurden bij elke verversing de hele lijst terug, ook als er niets veranderd
165 * was. Bij honderd wards is dat 217 KB JSON die de telefoon opnieuw moet
166 * parsen -- over de lijn valt het mee (2,8 KB gzip), maar het OPBOUWEN van
167 * veertienhonderd objecten is wat je merkt.
168 *
169 * EEN INHOUDS-ETAG, geen cursor. Een cursor vraagt een tweede beschrijving van
170 * wanneer iets "veranderd" is, en die kan uit de pas gaan lopen met wat er
171 * werkelijk in het antwoord staat; een hash van het antwoord zelf kan dat per
172 * definitie niet. De server bouwt het antwoord nog steeds (26 ms) -- wat we
173 * besparen is de overdracht en het parsen.
174 *
175 * NOOIT 304 OP EEN LEEG ANTWOORD. Dezelfde les als de '0'-uitzondering bij de
176 * inbox: gaat er bij het opbouwen iets mis en komt er een lege lijst uit, dan is
177 * die hash ook stabiel, en zou een client voor eeuwig 304 krijgen op niets.
178 */
179export function etagFor(body) {
180 return `"${crypto.createHash('sha256').update(body).digest('base64url').slice(0, 27)}"`;
181}
182
183export function sendMaybe304(req, res, obj, { cacheControl, contentType } = {}) {
184 const body = JSON.stringify(obj);
185 const leeg = !obj || (Array.isArray(obj.orderedItems) && obj.orderedItems.length === 0);
186 res.set('Vary', 'Authorization');
187 if (!leeg) {
188 const tag = etagFor(body);
189 res.set('ETag', tag);
190 if (req.headers['if-none-match'] === tag) return res.status(304).end();
191 }
192 res.type(contentType || AP_CONTENT_TYPE);
193 // `no-cache` betekent NIET "niet bewaren": de client bewaart het antwoord en
194 // vraagt elke keer of het nog klopt. Precies wat we willen -- zonder dit
195 // stuurt een browser geen If-None-Match en is de ETag decoratie.
196 res.set('Cache-Control', cacheControl || 'private, no-cache');
197 return res.send(body);
198}
199
200export function sendAP(res, obj, cacheControl) {
201 res.type(AP_CONTENT_TYPE);
202 // A per-caller (e.g. guardian-widened) view must not be publicly cached.
203 res.set('Cache-Control', cacheControl || 'public, max-age=120');
204 res.send(JSON.stringify(obj));
205}
206
207// ── document builders ─────────────────────────────────────────────
208
209
210/** Eén Link uit een AS2 `url` kiezen op mediaType. Een `url` mag een string,
211 * een Link of een array van beide zijn; dit is de enige plek die dat weet. */
212function pickLink(url, test) {
213 const links = Array.isArray(url) ? url : (url ? [url] : []);
214 for (const l of links) {
215 const href = safeUrl(typeof l === 'string' ? l : (l && l.href));
216 const mt = (l && typeof l === 'object' && l.mediaType) || '';
217 if (href && test(mt)) return { href, mediaType: mt };
218 }
219 return null;
220}
221
222/**
223 * De `url` van de actor als kanaal (shaer-0nh): de webpagina en, als die er is,
224 * de RSS-feed ernaast.
225 *
226 * De RSS-link gaat er ALLEEN in voor de site waar de instance op gepind staat.
227 * Sinds hub-modus verdween serveert routes/feed.js `/feed.xml` van de primaire
228 * site en bestaat `/user/<slug>` niet meer als route; een feed-link voor een
229 * andere site zou naar de verkeerde feed wijzen. Liever een link minder dan een
230 * link die iemand anders' muziek belooft.
231 */
232export function channelUrls(base, site) {
233 const isPrimair = site.slug === site.primary_slug;
234 const pagina = `${base}/${isPrimair ? '' : 'user/' + encodeURIComponent(site.slug)}`;
235 const uit = [{ type: 'Link', href: pagina, mediaType: 'text/html' }];
236 if (isPrimair) uit.push({ type: 'Link', href: `${base}/feed.xml`, mediaType: 'application/rss+xml' });
237 return uit;
238}
239
240
241/**
242 * Wat de tijdlijn van een binnengekomen object nodig heeft, PER SOORT: de
243 * inhoud-HTML, de bijlagen voor media_json, en de link van het item.
244 *
245 * Eén plek, zodat een nieuwe soort erbij een tak is en geen speurtocht. De
246 * Krant rendert media_json al naar soort -- audio/* wordt een speler -- dus een
247 * track komt vanzelf als echte speler binnen zonder dat de weergave iets van
248 * Funkwhale hoeft te weten.
249 */
250/**
251 * De waarschuwingstekst van een object, of niets.
252 *
253 * `summary` IS in AS2 een SAMENVATTING -- "a natural language summarization of
254 * the object". Dat Mastodon dat veld hergebruikt als waarschuwing is Mastodons
255 * conventie, en die zet er `sensitive` bij. Zonder `sensitive` is een summary
256 * dus gewoon een samenvatting.
257 *
258 * WordPress + ActivityPub stuurt daar de EXCERPT van een artikel in, netjes
259 * afgekapt voor Mastodon. Wij lazen dat als waarschuwing en verborgen de post
260 * daarmee achter zijn eigen eerste alinea (Barts melding, 13-8:
261 * europeanpirates.eu). Niemand krijgt dan te zien wat er staat, en de
262 * waarschuwing waarschuwt nergens voor.
263 */
264export function contentWarning(o) {
265 if (!o || !o.sensitive) return null;
266 const s = typeof o.summary === 'string' ? o.summary.trim() : '';
267 return s || null;
268}
269
270export function timelineFields(o) {
271 // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
272 // is het de kaart-afbeelding van een player-post), bij een Audio altijd,
273 // want daar IS het de albumhoes.
274 const hoes = () => {
275 if (!o.image) return null;
276 const im = Array.isArray(o.image) ? o.image[0] : o.image;
277 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
278 return iu ? { url: iu, type: (im && im.mediaType) || 'image/jpeg' } : null;
279 };
280
281 if (o.type === 'Audio') {
282 const geluid = pickLink(o.url, (mt) => /^audio\//i.test(mt));
283 // De webpagina van de track. Zonder mediaType is dat de veilige aanname:
284 // er een speler op zetten zou een HTML-pagina als geluid aanbieden.
285 const pagina = pickLink(o.url, (mt) => /^text\/html/i.test(mt)) || pickLink(o.url, (mt) => !mt);
286 const atts = [];
287 const h = hoes(); if (h) atts.push(h); // eerst kijken, dan luisteren
288 if (geluid) atts.push({ url: geluid.href, type: geluid.mediaType || 'audio/mpeg' });
289 // Een Audio heeft geen `content`; de titel is wat er te lezen valt. Door de
290 // sanitizer, want hij komt van een vreemde server.
291 return {
292 html: o.name ? HtmlSanitizerService.sanitize(`<p>${o.name}</p>`) : '',
293 atts,
294 url: pagina ? pagina.href : null,
295 };
296 }
297
298 // Een ARTIKEL heeft een titel, en die is het eerste wat je wilt zien. Zonder
299 // dit kwam een WordPress-post binnen als kale body: de titel zit in `name` en
300 // die gooiden we weg, terwijl de excerpt in `summary` ten onrechte als
301 // waarschuwing dienstdeed. Nu allebei goed -- en dit is dezelfde greep die
302 // resolveRemoteNote al doet voor niet-Note-objecten, dus de tijdlijn en het
303 // antwoordpad zeggen eindelijk hetzelfde.
304 if (o.type && o.type !== 'Note' && typeof o.name === 'string' && o.name.trim()) {
305 const kop = `<p><strong>${HtmlSanitizerService.escape ? HtmlSanitizerService.escape(o.name) : o.name}</strong></p>`;
306 const atts = mediaFromNote(o);
307 const pagina = pickLink(o.url, (mt) => !mt || /html/i.test(mt));
308 return {
309 html: HtmlSanitizerService.sanitize(kop + (o.content || '')),
310 atts,
311 url: pagina ? pagina.href : null,
312 };
313 }
314
315 // Note / Question -- ongewijzigd gedrag.
316 const atts = (Array.isArray(o.attachment) ? o.attachment : [])
317 .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
318 .filter((m) => m.url);
319 if (!atts.some((m) => !m.type || /image/i.test(m.type))) {
320 const h = hoes(); if (h) atts.push(h);
321 }
322 const pagina = pickLink(o.url, () => true);
323 return { html: HtmlSanitizerService.sanitize(o.content || ''), atts, url: pagina ? pagina.href : null };
324}
325
326/**
327 * De site achter een library-uri, of null. Zelfde strengheid als localSlugOf:
328 * de uri moet met ONZE basis beginnen en de site moet bestaan -- anders levert
329 * andermans /library met dezelfde padstaart hier een volger op onze naam op.
330 */
331function libraryOwnerSlug(uri) {
332 const u = String(uri || '');
333 if (!u.endsWith('/library')) return null;
334 return localSlugOf(u.slice(0, -'/library'.length));
335}
336
337export function buildActor(base, site) {
338 const id = actorId(base, site.slug);
339 const keys = getOrCreateKeys(site.slug);
340 // FEP-633c §5.3: a ward's follows are gated (guardians approve), so the actor
341 // MUST advertise manuallyApprovesFollowers:true — otherwise a follower's server
342 // (Mastodon) assumes auto-accept and shows "Following" while we hold it pending.
343 const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
344 const actor = {
345 '@context': AP_CONTEXT,
346 id,
347 type: 'Person',
348 preferredUsername: site.slug,
349 name: site.title || site.slug,
350 summary: site.tagline || site.description || '',
351 // Een Link-ARRAY in plaats van een kale string (shaer-0nh): zo adverteert
352 // een kanaal zichzelf, en zo vindt een podcast-app de feed. De text/html
353 // staat VOORAAN, want een lezer die maar één url verwacht pakt de eerste --
354 // dezelfde vorm die Funkwhale in productie met Mastodon uitwisselt.
355 url: channelUrls(base, site),
356 ...(channelCategory(site) ? { category: channelCategory(site) } : {}),
357 // …and the same honesty for the OWNER gate (Robins wens, 18-8): a site
358 // with approve_followers on holds follows pending until the owner decides.
359 manuallyApprovesFollowers: isWard || !!site.approve_followers,
360 discoverable: true,
361 inbox: `${id}/inbox`,
362 outbox: `${id}/outbox`,
363 followers: `${id}/followers`,
364 following: `${id}/following`,
365 featured: `${id}/featured`,
366 // AS2-kern `streams`: "supplementary Collections which may be of
367 // interest" -- precies wat de playlist-lijst is (shaer-ayc, stap 2).
368 // Geen eigen vocabulaire nodig, en wie het niet kent negeert het.
369 streams: [`${id}/tracks`, `${id}/playlists`],
370 // AP §5.6: the private blocked collection (owner-only GET). The server
371 // list is the source of truth for Shaer's "in Orbit"; clients keep no
372 // separate state.
373 blocked: `${id}/blocked`,
374 // FEP-1580: de vertaaltabel van een verhuizing plus de Moves die hem
375 // rechtvaardigen. Deze twee staan er ALTIJD, ook leeg, en dat is met opzet:
376 // de FEP wijst er apart op dat "een verhuizing zonder objecten" en "een
377 // server die dit niet kent" anders niet uit elkaar te houden zijn.
378 migration: `${id}/migration`,
379 moves: `${id}/moves`,
380 // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
381 // (guardianship module owns these).
382 ...Guardianship.guardianshipActorProps(id, site.slug),
383 // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
384 // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
385 // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
386 endpoints: {
387 sharedInbox: `${base}/ap/inbox`,
388 oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
389 oauthTokenEndpoint: `${base}/oauth/token`,
390 uploadMedia: `${id}/uploadMedia`,
391 },
392 publicKey: {
393 id: `${id}#main-key`,
394 owner: id,
395 publicKeyPem: keys.public_pem,
396 },
397 };
398 if (site.profile_photo) {
399 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
400 actor.icon = { type: 'Image', url: u };
401 }
402 // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
403 if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
404 // FEP-7628: former identities this account claims. The OLD server checks for
405 // exactly this back-reference before it will move followers here, so the
406 // list must be on the public actor, not tucked away in settings.
407 try {
408 const aka = JSON.parse(site.ap_aliases || '[]');
409 if (Array.isArray(aka)) {
410 const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
411 if (clean.length) actor.alsoKnownAs = clean;
412 }
413 } catch { /* skip malformed ap_aliases */ }
414 // FEP-7628 slice 3: this account moved. The old actor stays online AS A
415 // SIGNPOST — that is the whole point of keeping it: whoever missed the Move
416 // activity (offline server, later visitor) still learns where we went by
417 // fetching us. Per the FEP the moved actor "should be considered inactive",
418 // and publishers should stop delivering here.
419 if (site.moved_to && /^https?:\/\//i.test(String(site.moved_to))) actor.movedTo = String(site.moved_to);
420 // Zie movedLock() verderop: het serveren van movedTo is de ENE helft, het
421 // stilzetten van de uitgaande kant de andere.
422 // De MusicBrainz-koppeling van de artiest (shaer-mbz). Alleen als hij ZELF
423 // gekozen heeft -- er staat niets als er niets gekoppeld is, want een lege
424 // of geraden verwijzing is erger dan geen.
425 //
426 // schema:sameAs en niet alsoKnownAs: dat laatste is in AS2 voor vroegere
427 // identiteiten van dezelfde actor, en FEP-7628 leunt erop bij een verhuizing.
428 // Een MBID hier neerzetten zou een verhuizing kunnen laten mislukken.
429 const mbUrl = artiestUrl(site.mb_artist_id);
430 if (mbUrl) actor.sameAs = mbUrl;
431 // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
432 // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
433 try {
434 const links = JSON.parse(site.profile_links || '[]');
435 if (Array.isArray(links) && links.length) {
436 const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
437 const rows = links
438 .filter((l) => l && l.url && /^https?:/i.test(l.url))
439 .map((l) => ({
440 type: 'PropertyValue',
441 name: esc(l.platform || 'Link'),
442 value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
443 }));
444 if (rows.length) actor.attachment = rows;
445 }
446 } catch { /* skip malformed profile_links */ }
447 return actor;
448}
449
450// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
451// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
452// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
453export function hasPlayableAudio(content, siteId) {
454 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
455 try {
456 for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT media_id FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.media_id) return true; }
457 for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL LIMIT 1').get(siteId, m[1].trim())) return true; }
458 for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL LIMIT 1').get(m[1])) return true; }
459 } catch { /* non-fatal */ }
460 return false;
461}
462
463// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
464// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
465// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
466// later body mutation can't affect it.
467//
468// Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
469// nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
470function openAudioAttachments(base, site, post) {
471 const openAudio = [];
472 if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
473 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
474 const seenA = new Set();
475 const addRow = (r) => {
476 const fn = r.filename || (r.storage_path || '').split('/').pop();
477 if (!fn || seenA.has(fn)) return; seenA.add(fn);
478 const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
479 // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
480 // Mastodon renders it as the artwork thumbnail on its native audio player.
481 const art = abs(r.cover_url || post.cover_image_url || null);
482 if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
483 openAudio.push(a);
484 };
485 const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
486 try {
487 for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
488 for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
489 for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
490 } catch { /* non-fatal */ }
491 return openAudio;
492}
493
494// HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
495// eigen code: de playlist-collectie blijft namelijk een OrderedCollection
496// (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
497// object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
498// Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
499// niet voor, en de hub gooide zo'n bandje daarom stil weg.
500//
501// Als bijlage en niet als het object zelf: de post blijft een Note, zodat
502// Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
503// ziet. Wie het type wel kent, vindt het bandje als geheel.
504//
505// Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
506// fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
507function mixtapeAttachment(base, site, post) {
508 try {
509 const soort = postMusicType(post.content || '', site.id);
510 if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
511 const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
512 .get(soort.collectie.id, site.id);
513 if (!pl) return null;
514 return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
515 } catch { return null; /* een bandje minder is geen kapotte post */ }
516}
517
518// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
519export function buildNote(base, site, post, opts = {}) {
520 // Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
521 // (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
522 // machinery, addressed to the parent actor + thread. This early branch keeps that output
523 // byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
524 // this branch collapses and replies flow through the full post pipeline below. `post` here
525 // is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
526 if (opts.isReply) {
527 const meR = actorId(base, site.slug);
528 // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
529 // attachment array with absolute URLs and the matching object type.
530 let replyAtt;
531 try {
532 const list = post.attachments ? JSON.parse(post.attachments) : [];
533 if (Array.isArray(list) && list.length) {
534 replyAtt = list.map((a) => ({
535 type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
536 mediaType: a.mediaType,
537 url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
538 name: a.name || undefined,
539 }));
540 }
541 } catch { /* malformed attachments never block the Note */ }
542 return {
543 id: noteId(base, post.id),
544 type: 'Note',
545 attributedTo: meR,
546 inReplyTo: post.in_reply_to || undefined,
547 content: post.content,
548 // Reply language (rich replies): the AS2 language map next to `content`.
549 contentMap: post.language ? { [post.language]: post.content } : undefined,
550 attachment: replyAtt,
551 url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
552 published: toISO(post.created_at),
553 // A direct note (private mention, shaer-tqc) addresses ONLY its
554 // recipients: no Public anywhere, so it cannot be boosted and never
555 // shows in public timelines (the Mastodon DM model).
556 to: post.visibility === 'direct'
557 ? (JSON.parse(post.to_actors || '[]'))
558 : (post.to_actor ? [post.to_actor] : [PUBLIC]),
559 // Followers-only reply ('friends', shaer detail-view Reply): the parent
560 // author (in `to`) + our followers, but NO Public — it does not federate
561 // into open discovery. Default reply stays quiet-public (Public in cc).
562 cc: post.visibility === 'direct' ? []
563 : post.visibility === 'friends' ? [`${meR}/followers`]
564 : [PUBLIC, `${meR}/followers`],
565 // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
566 ...Guardianship.helpRequestProps(post),
567 ...Guardianship.waveProps(post),
568 ...Guardianship.awayProps(post),
569 // FEP-633c §2.2: object hint that the author is a ward.
570 ...Guardianship.hasGuardiansProps(site.slug),
571 tag: [
572 ...mentionTags(post.content),
573 ...hashtagTags(base, post.content),
574 ],
575 };
576 }
577 const id = noteId(base, post.id);
578 const aId = actorId(base, site.slug);
579 const human = `${base}/${encodeURIComponent(post.slug)}`;
580 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
581 // first line) — the standard blog→fediverse convention. post.content is
582 // already sanitized HTML; the title is plain text, so escape it.
583 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
584 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
585
586 // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
587 // content, so nothing leaks past the paywall. Images stay home too.
588 //
589 // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
590 // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
591 // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
592 // en die nummers federeren toch al los als eigen Audio-objecten met hun
593 // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
594 // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
595 // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
596 // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
597 // dient de betaalde post beter dan vier weesnummers.
598 if (post.paid) {
599 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
600 const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
601 const rawTeaser = String(post.excerpt || '').trim()
602 || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
603 const openBijlagen = openAudioAttachments(base, site, post);
604 const band = mixtapeAttachment(base, site, post);
605 // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
606 // elk nummer gesloten is, is een lege doos met een titel erop.
607 if (band && openBijlagen.length) openBijlagen.push(band);
608 return {
609 '@context': AP_CONTEXT,
610 id,
611 type: 'Note',
612 attributedTo: aId,
613 content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
614 url: human,
615 published: toISO(post.published_at || post.created_at || Date.now()),
616 ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
617 to: [PUBLIC],
618 cc: [`${aId}/followers`],
619 tag: [...hashtagTags(base, post.content)],
620 replies: `${id}/replies`,
621 // DE WAARSCHUWING REIST MEE (Barts melding, 15-8). Deze vroege return liet
622 // `sensitive` en `summary` vallen, want die worden pas na de gewone tak
623 // gezet. Gevolg: een betaalde post met een waarschuwing ging ZONDER die
624 // waarschuwing de deur uit -- en de teaser is publiek, dus juist die had
625 // hem nodig. Een gevoelige teaser zonder vlag is erger dan geen teaser.
626 sensitive: !!post.nsfw,
627 ...(post.nsfw ? { summary: post.content_warning || 'Gevoelige inhoud' } : {}),
628 ...Guardianship.hasGuardiansProps(site.slug),
629 };
630 }
631
632 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
633 // the cover + any inline <img>, make absolute, then strip <img> from the content
634 // to avoid duplicate rendering on clients that DO keep them.
635 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
636 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
637 const playable = hasPlayableAudio(post.content || '', site && site.id);
638 // A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
639 // Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
640 // card, never both — so when the post has an embed link we skip the image attachments so the
641 // card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
642 const hasEmbed = (() => {
643 const c = post.content || '';
644 if (/\[\[embed:/i.test(c)) return true;
645 for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
646 return false;
647 })();
648 // Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
649 // so we federate them — Mastodon cards the first (its player), the rest show as clickable links
650 // — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
651 const trackEmbedLinks = (() => {
652 if (playable) return [];
653 const out = [];
654 try {
655 for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
656 const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
657 if (r && !r.media_id) for (const u of [r.link_spotify, r.link_youtube, r.link_soundcloud]) if (u && /^https?:\/\//i.test(u)) out.push(u);
658 }
659 } catch { /* non-fatal */ }
660 return [...new Set(out)].slice(0, 6);
661 })();
662 const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
663 const urls = [];
664 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
665 // the player CARD (twitter:player) instead of the cover — media attachment and
666 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
667 // keeps its cover (no player card to show).
668 // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
669 // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
670 // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
671 // Media a C2S composer attached (shaer-j3uh): federate with their REAL
672 // mediaType, because the extension map below knows no audio and would call
673 // an m4a an Image. Pushed BEFORE the covers: a C2S video doubles as the
674 // cover video, and the URL-dedupe keeps the FIRST entry, which must be the
675 // one that knows its type and poster. Images also live inline in the
676 // content, so the dedupe keeps those single too.
677 try {
678 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
679 if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType, poster: a.poster ? abs(a.poster) : null });
680 }
681 } catch { /* malformed never blocks the Note */ }
682 if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
683 else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
684 let body = post.content || '';
685 // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
686 // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
687 // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
688 // as the attachment description.
689 if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
690 const tag = m[0];
691 const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
692 if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
693 const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
694 urls.push({ url: abs(src), name: alt });
695 }
696 body = body.replace(/<img\b[^>]*>/gi, '');
697 // Video and audio tags leave the federated content the same way (30-7):
698 // they ride as AS2 attachments (c2s_attachments), and the tag itself
699 // carries a RELATIVE /media src that is dead everywhere but our own web.
700 // Leaving it in showed every remote reader a broken player above the
701 // working one. The web keeps its tags: this strip is federation-only.
702 body = body.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, '').replace(/<video\b[^>]*\/?>/gi, '');
703 body = body.replace(/<audio\b[^>]*>[\s\S]*?<\/audio>/gi, '').replace(/<audio\b[^>]*\/?>/gi, '');
704 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
705 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
706 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
707 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
708 // a click-through to the protected player (discovery without leaking the file).
709 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
710 // Elke titel met zijn track-id erbij, zodat hij hieronder een EIGEN link
711 // krijgt naar #track-<id> op de postpagina (shaer-38y). Zonder id was dit een
712 // vetgedrukte opsomming waar je niets mee kon: vijf namen en een enkele
713 // "listen on"-link naar de post als geheel. Elke track heeft daar al een
714 // anker -- direct ingesloten, in een album of in een playlist -- dus dit
715 // wijst naar precies het nummer waar de naam bij hoort.
716 const audioLabels = [];
717 try {
718 const zien = new Set();
719 const voegToe = (id, titel) => {
720 const t = String(titel || '').trim();
721 if (!t) return;
722 const sleutel = id || ('naam:' + t);
723 if (zien.has(sleutel)) return;
724 zien.add(sleutel);
725 audioLabels.push({ id: id || null, titel: t });
726 };
727 // In de volgorde van de POST: een enkele scan over alle drie de vormen,
728 // zodat de opsomming leest zoals de post is neergezet.
729 for (const m of body.matchAll(/\[\[(track|album|playlist):([^\]]+)\]\]/gi)) {
730 const soort = m[1].toLowerCase(), waarde = m[2].trim();
731 if (soort === 'track') {
732 const r = db.prepare('SELECT id, title FROM audio_tracks WHERE id = ?').get(waarde);
733 if (r) voegToe(r.id, r.title);
734 } else if (soort === 'album') {
735 const rs = db.prepare('SELECT id, title FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY rowid').all(site.id, waarde);
736 if (rs.length) for (const r of rs) voegToe(r.id, r.title);
737 else voegToe(null, waarde); // album zonder tracks: dan maar de naam
738 } else {
739 for (const r of db.prepare('SELECT t.id, t.title FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? ORDER BY pt.position').all(waarde)) voegToe(r.id, r.title);
740 }
741 }
742 } catch { /* non-fatal */ }
743 const openAudio = openAudioAttachments(base, site, post);
744 // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
745 // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
746 // speler van. Bakken we ze eerst om, dan komt er een tekstlink aan en is de
747 // speler weg. Onherstelbaar bovendien: het bakken STRIPT de shorthand en
748 // plakt achteraan hooguit VIER titels, dus een album van tien nummers
749 // overleeft het niet.
750 //
751 // Dezelfde regel als bij de outbox en de tracks: wie ondertekend vraagt
752 // namens de actor waar wij naartoe verhuisd zijn, krijgt onze eigen kijk.
753 if (!opts.rauweInhoud) body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
754 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
755 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
756 // of federating the raw shortcode text.
757 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
758 const u = esc(raw.trim().replace(/&amp;/g, '&'));
759 return `<p><a href="${u}">${u}</a></p>`;
760 });
761 if (hadAudio && !opts.rauweInhoud) {
762 // Elke titel als eigen link naar zijn anker; een titel zonder id (een
763 // albumnaam zonder tracks) blijft gewone tekst.
764 const lbl = audioLabels.slice(0, 4)
765 .map((a) => (a.id ? `<a href="${human}#track-${esc(a.id)}">${esc(a.titel)}</a>` : esc(a.titel)))
766 .join(', ');
767 if (trackEmbedLinks.length) {
768 // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
769 // player), the rest render as clickable links — the fediverse-native "embed + links".
770 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong>` : ''}</p>`;
771 for (const u of trackEmbedLinks) { const eu = esc(u); body += `<p><a href="${eu}">${eu}</a></p>`; }
772 } else {
773 // For playable posts, append a version param to the listen-link so Mastodon
774 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
775 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
776 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
777 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
778 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
779 }
780 }
781 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
782 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
783 // so convert newlines to <br> for the federated copy (content already made with
784 // shift+enter uses <br> and has no \n → this is a no-op there).
785 body = body.replace(/\r?\n/g, '<br>');
786 body = linkHashtags(base, body); // link inline #hashtags in the post body too
787 body = linkUrls(body); // bare URLs → clickable links on the federated copy
788 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
789 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
790 // multi-word tags; skip any already present inline in the body.
791 {
792 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
793 const addSeen = new Set();
794 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
795 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
796 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
797 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
798 }
799 const seen = new Set();
800 const attachment = urls.filter((x) => x && x.url)
801 .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
802 .map((x) => { const mt = x.mt || guessMediaType(x.url); // the stored type wins; the extension map is the fallback
803 const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
804 const a = { type: ty, mediaType: mt, url: x.url };
805 if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
806 if (x.poster) a.icon = { type: 'Image', url: x.poster }; // the video's still (shaer-zowq)
807 return a; });
808 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
809
810 // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
811 const tape = mixtapeAttachment(base, site, post);
812 if (tape) attachment.push(tape);
813
814 // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
815 // present when the content was already mention-linked (deliverCreate/Update resolve them
816 // at send time); a plain buildNote (outbox/notes) yields none.
817 const _mentionTags = mentionTags(body);
818 const _mentionCc = _mentionTags.map((t) => t.href);
819
820 const note = {
821 id,
822 type: 'Note',
823 attributedTo: aId,
824 content: titleHtml + body,
825 url: human,
826 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
827 // fan_only = "fans only" → followers-only visibility (delivered to your followers
828 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
829 to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
830 // Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
831 // so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
832 cc: [...new Set([
833 ...(post.ap_visibility === 'quiet' ? [PUBLIC] : []), // quiet public: Public in cc, not to
834 ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
835 ..._mentionCc])],
836 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content, post)],
837 replies: `${id}/replies`,
838 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
839 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
840 sensitive: !!post.nsfw,
841 };
842 // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
843 Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
844 // FEP-044f: this post quotes a fediverse object. Emit it the way the network
845 // actually reads it, and address the quoted author so they get told.
846 applyQuoteProps(note, post.quote_uri, post.quote_actor);
847 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
848 if (attachment.length) note.attachment = attachment;
849 // When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
850 // so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
851 // card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
852 // `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
853 if (post.cover_image_url && noImages) {
854 const cov = abs(post.cover_image_url);
855 if (cov) { note.image = { type: 'Image', mediaType: guessMediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
856 }
857 // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
858 // (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
859 // getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
860 // standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
861 // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
862 if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
863 // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
864 // language from its key for the timeline language filter + the translate button. Emitted
865 // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
866 if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
867 // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
868 // reuses the note's content/addressing/tags, then swaps the type and strips media.
869 const ownPoll = parseOwnPoll(post.poll_json);
870 if (ownPoll) applyPollToNote(note, post.id, ownPoll);
871 return note;
872}
873
874// All reply note URIs on a local post (inbound fediverse replies + our own
875// outbound replies) — backs the Note's `replies` Collection so remote servers
876// can fetch the whole thread.
877export function getReplyUris(base, postId) {
878 const out = [];
879 try {
880 for (const r of db.prepare("SELECT object_uri FROM ap_interactions WHERE kind = 'reply' AND post_id = ? AND object_uri != '' ORDER BY created_at").all(postId)) out.push(r.object_uri);
881 for (const r of db.prepare('SELECT id FROM ap_outbox WHERE post_id = ? ORDER BY rowid').all(postId)) out.push(`${base}/ap/notes/${r.id}`);
882 } catch { /* non-fatal */ }
883 return out;
884}
885
886// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
887export function markNotificationsSeen(slug) {
888 try {
889 db.prepare("INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP")
890 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
891 } catch { /* non-fatal */ }
892}
893export function countUnseenNotifications(slug) {
894 try {
895 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
896 const seen = row ? Date.parse(row.value) : 0;
897 let n = 0;
898 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
899 return n;
900 } catch { return 0; }
901}
902// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
903// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
904export function notificationsSeenAt(slug) {
905 try {
906 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
907 return row ? (Date.parse(row.value) || 0) : 0;
908 } catch { return 0; }
909}
910
911// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
912// every notification PLUS your own outbound replies ('sent', with edit/delete via their
913// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
914// one grouped item (actors list + count) so activity doesn't drown out conversations.
915/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
916 * leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
917 * zodat een kapotte kolom hooguit media kost en niet de hele regel. */
918function outboxMediaJson(attachments) {
919 if (!attachments) return null;
920 try {
921 const list = JSON.parse(attachments);
922 if (!Array.isArray(list) || !list.length) return null;
923 const media = list
924 .filter((a) => a && a.url)
925 .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
926 return media.length ? JSON.stringify(media) : null;
927 } catch { return null; }
928}
929
930export function getMessages(slug, limit, offset) {
931 const off = Math.max(0, offset || 0);
932 const lim = limit || 60;
933 // The stream is grouped (consecutive likes/boosts collapse), so paging is done by
934 // recomputing the whole stream top-down and slicing [off, off+lim] — stable across
935 // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
936 const need = off + lim + 100;
937 const items = getNotifications(slug, need);
938 try {
939 for (const m of listOutbox(slug).slice(0, need)) {
940 items.push({
941 type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
942 in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
943 editable: m.editable, language: m.language, created_at: m.created_at,
944 // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
945 // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
946 // media uit media_json met een `type`, terwijl ap_outbox ze als
947 // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
948 // een foto die JIJ meestuurde als kale tekst binnen.
949 media_json: outboxMediaJson(m.attachments),
950 });
951 }
952 } catch { /* ignore */ }
953 // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
954 // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
955 // zei alleen een slug, dus vullen we ze in één query aan.
956 try {
957 const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
958 if (missing.length) {
959 const rows = db.prepare(
960 `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
961 AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
962 ).all(...missing, slug);
963 const byslug = new Map(rows.map((r) => [r.slug, r.title]));
964 for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
965 }
966 } catch { /* zonder titel valt de draad terug op de slug */ }
967 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
968 const out = [];
969 for (const it of items) {
970 const prev = out[out.length - 1];
971 if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
972 && prev.post_slug === it.post_slug) {
973 prev.actors = prev.actors || [prev.name || prev.handle || '?'];
974 prev.actors.push(it.name || it.handle || '?');
975 prev.count = (prev.count || 1) + 1;
976 continue;
977 }
978 out.push(it);
979 }
980 // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
981 // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
982 // telt een draad als één item voor de paginering, wat klopt: je scrolt door
983 // gesprekken, niet door losse zinnen.
984 return groupConversations(out).slice(off, off + lim);
985}
986
987// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
988// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
989// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
990// moest dus tussen chips heen en weer, terwijl het één draad is.
991const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
992
993/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
994 *
995 * 1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
996 * op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
997 * dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
998 * 2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
999 * eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
1000 * loopt per tegenpartij.
1001 *
1002 * De post wint van de persoon: twee mensen die onder dezelfde post reageren
1003 * voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
1004 * is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
1005 * door.
1006 */
1007export function threadKey(it) {
1008 if (!it || !CONV_TYPES.has(it.type)) return null;
1009 if (it.post_slug) return `post:${it.post_slug}`;
1010 let who = it.handle || it.to_handle || '';
1011 // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
1012 // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
1013 // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
1014 // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
1015 if (!who && it.to_actors) {
1016 try {
1017 const first = JSON.parse(it.to_actors)[0];
1018 if (first) who = deriveHandle(first);
1019 } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
1020 }
1021 const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
1022 return norm ? `actor:${norm}` : null;
1023}
1024
1025/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
1026 * ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
1027 * (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
1028 * nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
1029 * de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
1030 */
1031export function groupConversations(items) {
1032 const threads = new Map();
1033 const out = [];
1034 for (const it of items || []) {
1035 const key = threadKey(it);
1036 if (!key) { out.push(it); continue; }
1037 let t = threads.get(key);
1038 if (!t) {
1039 // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
1040 // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
1041 t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
1042 threads.set(key, t);
1043 out.push(t);
1044 }
1045 t.messages.push(it);
1046 // De context bij de draad: gaat het over een post, dan hoort de link
1047 // erbij, anders is een los antwoord in een lijst niet te plaatsen.
1048 // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
1049 // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
1050 // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
1051 // dan kan het ontvangen antwoord eronder de echte titel niet meer
1052 // invullen. De terugval op de slug hoort in de weergave, niet in de data.
1053 if (it.post_slug) {
1054 if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
1055 else if (!t.post.title && it.post_title) t.post.title = it.post_title;
1056 }
1057 }
1058 for (const t of threads.values()) {
1059 t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
1060 t.count = t.messages.length;
1061 // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
1062 const seen = new Set();
1063 for (const m of t.messages) {
1064 if (m.type === 'sent') continue;
1065 const h = m.handle || m.name;
1066 if (!h || seen.has(h)) continue;
1067 seen.add(h);
1068 t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
1069 }
1070 // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
1071 // onbeantwoord bericht leest.
1072 t.mine = t.messages.some((m) => m.type === 'sent');
1073 // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
1074 // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
1075 // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
1076 // een direct bericht aan de tegenpartij.
1077 const inkomend = t.messages.filter((m) => m.type !== 'sent');
1078 const laatste = inkomend[inkomend.length - 1];
1079 t.replyTo = {
1080 interactionId: (laatste && laatste.interactionId) || null,
1081 postSlug: (t.post && t.post.slug) || null,
1082 actorUri: (laatste && (laatste.actorUri || laatste.url))
1083 || (t.messages.find((m) => m.to_actor) || {}).to_actor
1084 || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
1085 };
1086 }
1087 return out;
1088}
1089
1090export function buildCreate(base, site, post, opts = {}) {
1091 const note = buildNote(base, site, post, opts);
1092 return {
1093 '@context': AP_CONTEXT,
1094 id: note.id + '#create',
1095 type: 'Create',
1096 actor: actorId(base, site.slug),
1097 published: note.published,
1098 to: note.to,
1099 cc: note.cc,
1100 object: note,
1101 };
1102}
1103
1104
1105/**
1106 * De outbox: wat deze actor heeft uitgebracht. Posts EN tracks (shaer-0nh,
1107 * stap 4).
1108 *
1109 * WAAROM HIER EN NIET IN EEN BEZORGING. Een kanaal-lezer HAALT de outbox op --
1110 * zo heb ik zelf Funkwhales kanaal uitgelezen. Een Create(Audio) ook naar de
1111 * inboxen van volgers duwen zou schade doen: Mastodon neemt Audio aan als
1112 * statustype, dus bij een album-post zou dezelfde muziek twee keer in hun
1113 * tijdlijn komen -- een keer als bijlage bij de Note, en dan nog N keer los.
1114 * De post is het bericht, de outbox is de discografie.
1115 *
1116 * Door elkaar op datum, nieuwste eerst, zodat de outbox één verhaal vertelt in
1117 * plaats van twee lijstjes achter elkaar.
1118 *
1119 * De tracks komen als ARGUMENT binnen, net als de posts, en worden hier
1120 * uitdrukkelijk NIET zelf opgehaald. De route beslist wie wat mag zien -- een
1121 * geblokkeerde bezoeker krijgt daar een lege outbox, en een bouwer die stiekem
1122 * zijn eigen database bevraagt zou dwars door die deur heen leveren.
1123 */
1124/**
1125 * Een PAGINA van de outbox, in SQL (shaer-sk4).
1126 *
1127 * De outbox mengt twee bronnen: posts en open tracks, gevlochten op datum. Een
1128 * offset over die twee kan niet met twee losse queries -- je weet niet hoeveel
1129 * van elk er in pagina drie horen. Vandaar een UNION met de datum als sleutel,
1130 * daar de LIMIT/OFFSET overheen, en pas dan de rijen zelf ophalen.
1131 *
1132 * Wat er stond was geen paginering maar een KAP: de route haalde twintig posts
1133 * en hield daarvan twintig items over. Alles daarvoor was niet op een volgende
1134 * pagina maar helemaal onbereikbaar.
1135 *
1136 * @param {boolean} fanOnly mag de lezer ook de fans-only posts zien?
1137 */
1138export function outboxSlice(siteId, { fanOnly = false, offset = 0, limit = MAX_OUTBOX } = {}) {
1139 const fanClause = fanOnly ? '' : 'AND (p.fan_only IS NULL OR p.fan_only = 0)';
1140 const unie = `
1141 SELECT 'post' AS soort, p.id AS id, COALESCE(p.published_at, p.created_at) AS wanneer
1142 FROM posts p WHERE p.site_id = ? AND p.status = 'published' ${fanClause}
1143 UNION ALL
1144 SELECT 'track', t.id, t.created_at
1145 FROM audio_tracks t WHERE t.site_id = ? AND t.fedi_open = 1`;
1146 let rijen = [], totaal = 0;
1147 try {
1148 totaal = db.prepare(`SELECT COUNT(*) n FROM (${unie})`).get(siteId, siteId).n;
1149 rijen = db.prepare(`SELECT soort, id FROM (${unie}) ORDER BY wanneer DESC LIMIT ? OFFSET ?`)
1150 .all(siteId, siteId, limit, Math.max(0, offset));
1151 } catch { return { posts: [], tracks: [], totaal: 0 }; }
1152
1153 const postIds = rijen.filter((r) => r.soort === 'post').map((r) => r.id);
1154 const trackIds = rijen.filter((r) => r.soort === 'track').map((r) => r.id);
1155 const gaten = (n) => Array.from({ length: n }, () => '?').join(',');
1156 const posts = postIds.length ? db.prepare(
1157 // fan_only en ap_visibility MOETEN mee. buildNote adresseert hierop, en
1158 // zonder deze twee kolommen is post.fan_only altijd undefined: elke
1159 // fan-only post ging dan de outbox uit met to: as:Public, terwijl hij
1160 // alleen aan vrienden geserveerd wordt. Een volger kreeg dus een
1161 // vrienden-post met een publiek etiket erop, en die mag hij dan publiek
1162 // boosten. Gevonden tijdens de FEP-1580 end-to-end test (shaer-fuyo).
1163 // EN paid + excerpt, om exact dezelfde reden (Barts melding, 15-8). Zonder
1164 // `paid` is post.paid hier `undefined`, dan slaat buildNote zijn redactie
1165 // over en gaat de VOLLEDIGE tekst van een betaalde post de outbox uit. Zo
1166 // kwam een post via een hub-actor gewoon te lezen. `excerpt` moet mee omdat
1167 // de teaser daaruit komt; zonder dat veld valt hij terug op de eerste
1168 // alinea van precies de tekst die verborgen hoort te blijven.
1169 //
1170 // Dit is een KOLOMMENLIJST, en die faalt stil: een vergeten kolom is
1171 // `undefined` en niet een fout. Wie hier een veld toevoegt waar buildNote
1172 // op beslist, moet het HIER ook toevoegen.
1173 `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
1174 c2s_attachments, quote_json, embed_json, published_at, created_at,
1175 fan_only, ap_visibility, paid, paid_min_cents
1176 FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
1177 const tracks = trackIds.length ? db.prepare(
1178 `SELECT ${TRACK_KOLOMMEN}
1179 FROM audio_tracks t JOIN media m ON m.id = t.media_id
1180 WHERE t.id IN (${gaten(trackIds.length)})`).all(...trackIds) : [];
1181 return { posts, tracks, totaal };
1182}
1183
1184export function buildOutbox(base, site, posts, tracks = [], { page = false, totalItems, alGesneden = false, rauweInhoud = false } = {}) {
1185 const id = `${actorId(base, site.slug)}/outbox`;
1186 const wanneer = (x) => Date.parse(x && x.published ? x.published : 0) || 0;
1187 const items = [
1188 ...(posts || []).map((p) => buildCreate(base, site, p, { rauweInhoud })),
1189 // Eén zoekopdracht voor alle tracks samen, niet per stuk.
1190 ...(() => {
1191 const posts = (tracks || []).length && site.id ? trackHostPosts(site.id) : null;
1192 return (tracks || []).map((r) => buildTrackCreate(base, site, r, { hostPosts: posts }));
1193 })(),
1194 ]
1195 .sort((a, b) => wanneer(b) - wanneer(a))
1196 .slice(0, alGesneden ? Infinity : MAX_OUTBOX);
1197 // WAT HIER NOG NIET GEPAGINEERD IS, en dat hoort genoemd (shaer-sk4): deze
1198 // lijst is al door de route op twintig rijen afgekapt, dus pagina 2 is leeg.
1199 // Echt doorbladeren vraagt een LIMIT/OFFSET in SQL -- en dat is hier lastiger
1200 // dan bij volgers, want posts en tracks worden op DATUM door elkaar gevlochten
1201 // en komen uit twee tabellen. Dat vraagt een UNION met een offset erover, geen
1202 // tweede slice. De vorm klopt nu wel: pagina 2 zegt eerlijk dat hij leeg is en
1203 // biedt geen `next` aan, in plaats van pagina 1 nog eens te geven.
1204 // GEPAGINEERD, ook al past alles op een pagina (Funkwhale, 11-8).
1205 //
1206 // Hun serializer weigerde onze outbox met "first: This field is required" en
1207 // "last: This field is required". AS2 EIST ze niet -- een collectie mag zijn
1208 // items inline dragen -- maar bijna iedereen pagineert, en een lezer die de
1209 // paginaweg volgt liep hier dood. Dit is de eerste concrete reden die we
1210 // hoorden waarom er niets van ons binnenkwam.
1211 //
1212 // De items blijven WEL inline op de wortel. Shaer bouwt zijn feed daaruit, en
1213 // wie hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Er is
1214 // precies een pagina, dus first en last wijzen naar dezelfde.
1215 return pagedCollection(id, items, { page, totalItems, alGesneden });
1216}
1217
1218// Public callers get a count-only collection (privacy). The authenticated
1219// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
1220// `items`, so their own client can build a friends list.
1221export function buildFollowers(base, site, count, items = null, { page = false } = {}) {
1222 const id = `${actorId(base, site.slug)}/followers`;
1223 // count-only for the public; full for the owner
1224 return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
1225}
1226
1227// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
1228// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
1229export function buildFollowing(base, site, count, items = null, { page = false } = {}) {
1230 const id = `${actorId(base, site.slug)}/following`;
1231 // count-only for the public; full for the owner
1232 return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
1233}
1234
1235// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
1236// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
1237// rank; embedded as full Notes so a remote server doesn't need extra fetches.
1238export function buildFeatured(base, site, posts, { page = false } = {}) {
1239 const id = `${actorId(base, site.slug)}/featured`;
1240 const items = (posts || []).map((p) => buildNote(base, site, p));
1241 return pagedCollection(id, items, { page });
1242}
1243
1244// ── Playlist als AP-collectie (shaer-ayc, stap 1 van het Funkwhale-spoor) ──
1245// Een playlist heeft, anders dan een album-als-tekstveld, een id — dus kan hij
1246// een stabiele URI dragen en federeren. De vorm is bewust kaal AS2: een
1247// OrderedCollection van Audio-objecten, dezelfde rijvorm die een post als
1248// attachment meestuurt, zodat elke client die post-audio al speelt dit ook
1249// speelt.
1250//
1251// De poortregel verandert hier NIET: alleen fedi_open-tracks staan erin, met
1252// echte bestands-URL. Een gated track is niet "een rij zonder url" maar
1253// afwezig — wie de collectie leest ziet het open deel en kan niet aftellen
1254// hoeveel er achter de poort staat. totalItems telt daarom ook alleen het
1255// open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
1256// niet over wat wij thuis in de kast hebben.
1257
1258// ── followers store (lazy stmts) ──────────────────────────────────
1259let _insF, _updFDisp, _delF, _listF, _cntF;
1260function fStmts() {
1261 if (!_insF) {
1262 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
1263 _updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
1264 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
1265 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
1266 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
1267 }
1268 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
1269}
1270export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
1271
1272// Followers with delivery health, for the management list. Never-delivered accounts
1273// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
1274export function listFollowers(slug) {
1275 return db.prepare(
1276 `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
1277 FROM ap_followers WHERE slug = ?
1278 ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
1279 ).all(slug);
1280}
1281// Manually drop a follower after a check (a still-live account would have to re-follow).
1282/**
1283 * Een volger verwijderen, en het hem ook LATEN WETEN (Robin, 21-8).
1284 *
1285 * Reject(Follow) is het standaardsignaal voor "je volgt me niet meer": de
1286 * andere kant ruimt de relatie dan op in plaats van te blijven denken dat hij
1287 * volgt. Zonder dit merkte de hub niets -- die bleef als volger in zijn eigen
1288 * boeken staan terwijl er nooit meer iets werd bezorgd.
1289 *
1290 * Verwijderen gaat altijd door; de melding is een gunst en mag mislukken.
1291 */
1292export function removeFollower(slug, id) {
1293 const rij = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? AND id = ?').get(slug, id);
1294 const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
1295 if (info.changes > 0 && rij && rij.actor_uri) meldNietLangerVolger(slug, rij.actor_uri);
1296 return info.changes > 0;
1297}
1298
1299/** Reject(Follow) naar een ex-volger; faalt stil, want de relatie is al weg. */
1300export function meldNietLangerVolger(slug, actorUri) {
1301 try {
1302 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1303 const me = actorId(base, slug);
1304 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1305 if (!site) return;
1306 const reject = {
1307 '@context': AP_CONTEXT,
1308 id: `${me}#reject-follow-${Date.now()}-${rid()}`,
1309 type: 'Reject',
1310 actor: me,
1311 to: [actorUri],
1312 object: { type: 'Follow', actor: actorUri, object: me },
1313 };
1314 deliverToActor(site, actorUri, reject)
1315 .then((r) => console.log('[AP] Reject(Follow)', slug, '→', actorUri, r && r.delivered ? 'bezorgd' : 'niet bezorgd'))
1316 .catch(() => {});
1317 } catch { /* nooit blokkerend */ }
1318}
1319
1320// Best cached display for an actor URI, across the caches Klonkt already fills:
1321// followers (now with name/icon), following, interactions, timeline, mentions.
1322// Falls back to a handle derived from the URI. Display info is not sensitive.
1323export function actorDisplay(slug, uri) {
1324 const ok = (r) => r && (r.name || r.icon);
1325 try {
1326 let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
1327 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
1328 r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
1329 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
1330 r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_interactions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
1331 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
1332 r = db.prepare('SELECT author_name AS name, author_handle AS handle, author_icon AS icon FROM ap_timeline WHERE author_uri = ? AND (author_name IS NOT NULL OR author_icon IS NOT NULL) LIMIT 1').get(uri);
1333 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
1334 r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_mentions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
1335 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
1336 } catch { /* ignore */ }
1337 return { name: null, handle: deriveHandle(uri), icon: null };
1338}
1339
1340// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
1341// Pure and testable; the route sets the response headers around it.
1342export function prefersEnriched(preferHeader) {
1343 return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
1344}
1345
1346// AS2 actor reference with display, for the owner C2S followers/following view.
1347// preferredUsername = the local part of the handle; name = the set display name.
1348export function buildActorRef(slug, uri) {
1349 const d = actorDisplay(slug, uri);
1350 const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
1351 const out = { id: uri, type: 'Person' };
1352 if (d.name) out.name = d.name;
1353 if (user) out.preferredUsername = user;
1354 if (d.icon) out.icon = { type: 'Image', url: d.icon };
1355 return out;
1356}
1357
1358// The site's OWN display info in the same shape as `shaer:author` on timeline
1359// entries. The owner's app reads its own posts from the outbox, which carried
1360// no author info, so every card but your own had a byline (Robins melding,
1361// 30-7: geen header van self op eigen posts).
1362export function selfAuthor(base, site) {
1363 const out = {
1364 name: site.title || site.slug,
1365 handle: `@${site.slug}@${String(base).replace(/^https?:\/\//, '')}`,
1366 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
1367 };
1368 if (site.profile_photo) {
1369 out.icon = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
1370 }
1371 return out;
1372}
1373
1374// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
1375// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
1376// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
1377// `unreachable` flag (never delivered, or last attempt failed after the last success) so
1378// the view can split dead connections into their own section. Powers the Connect page.
1379export function listConnections(slug) {
1380 const byUri = new Map();
1381 for (const f of listFollowing(slug)) {
1382 byUri.set(f.actor_uri, {
1383 actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
1384 icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
1385 status: f.status || null, following: true, follower: false,
1386 last_delivery_at: null, last_error_at: null, follower_id: null,
1387 });
1388 }
1389 for (const fo of listFollowers(slug)) {
1390 const e = byUri.get(fo.actor_uri);
1391 if (e) { e.follower = true; e.last_delivery_at = fo.last_delivery_at; e.last_error_at = fo.last_error_at; e.follower_id = fo.id; }
1392 else byUri.set(fo.actor_uri, {
1393 actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
1394 auto_boost: 0, status: null, following: false, follower: true,
1395 last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
1396 });
1397 }
1398 return [...byUri.values()].map((e) => {
1399 e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
1400 e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
1401 return e;
1402 });
1403}
1404
1405// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
1406let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
1407// ── moderation tombstones (ap_rejected_objects) ───────────────────
1408// A reply the owner removed stays removed: its object URI is tombstoned and
1409// checked at ingest AND by the thread-crawler (else thread-filling would
1410// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
1411// private notes that authorize_interaction can't fetch (401/404).
1412let _insRj, _hasRj;
1413function rjStmts() {
1414 if (!_insRj) {
1415 _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
1416 _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
1417 }
1418 return { ins: _insRj, has: _hasRj };
1419}
1420export function isRejectedObject(uri) {
1421 if (!uri) return false;
1422 try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
1423}
1424// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
1425// interaction's post must belong to the caller's site.
1426export function rejectInteraction(site, interactionId, reason) {
1427 if (!site || !site.slug) return { error: 'forbidden' };
1428 const row = iStmts().getI.get(interactionId);
1429 if (!row) return { error: 'not_found' };
1430 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
1431 .get(row.post_id, site.slug);
1432 if (!owns) return { error: 'forbidden' };
1433 if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
1434 db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
1435 console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
1436 return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
1437}
1438// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
1439// from the local copy (works for private notes; no remote fetch needed to target).
1440export function interactionReportTarget(site, interactionId) {
1441 if (!site || !site.slug) return null;
1442 const row = iStmts().getI.get(interactionId);
1443 if (!row) return null;
1444 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
1445 .get(row.post_id, site.slug);
1446 if (!owns) return null;
1447 return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
1448}
1449
1450// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
1451// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
1452// followers-collectie zonder Public = followers-only, anders direct (DM). Public
1453// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
1454export function noteVisibility(o) {
1455 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
1456 const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
1457 const to = arr(o && o.to).map(String);
1458 const cc = arr(o && o.cc).map(String);
1459 if (to.some(isPub)) return 'public';
1460 if (cc.some(isPub)) return 'unlisted';
1461 if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
1462 return 'direct';
1463}
1464
1465/**
1466 * Does this note belong in the home timeline (de Krant)?
1467 *
1468 * Only if it is a POST. A direct note is addressed to named people, so it is a
1469 * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
1470 * guardian's wave. Those are stored as mentions instead and surface in
1471 * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
1472 */
1473export function belongsInTimeline(o) {
1474 if (!o || !o.id || o.inReplyTo) return false;
1475 return noteVisibility(o) !== 'direct';
1476}
1477
1478function iStmts() {
1479 if (!_insI) {
1480 _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, visibility, emoji_json, actor_emoji_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
1481 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
1482 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
1483 _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility, emoji_json, actor_emoji_json FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
1484 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
1485 _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
1486 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
1487 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
1488 }
1489 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
1490}
1491
1492export function getInteractionById(id) { return iStmts().getI.get(id); }
1493export function setInteractionBoosted(id, on) {
1494 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
1495}
1496export function setInteractionLiked(id, on) {
1497 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
1498}
1499
1500const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
1501// Extract our local post id from a note URL, but only if it's ours (base match).
1502// One host, two spellings (Barts WebFinger-les, 2-8): a URL the client hands
1503// back may carry the punycoded host (every URL parser silently punycodes)
1504// while PUBLIC_BASE_URL carries the typed one. WHATWG URL does the IDNA, so
1505// compare origins in ASCII and never the bytes the client happened to send.
1506function asciiOrigin(u) {
1507 try { const x = new URL(String(u)); return `${x.protocol}//${x.host}`.toLowerCase(); } catch { return null; }
1508}
1509function isOwnUrl(u) {
1510 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1511 if (!base) return false;
1512 const a = asciiOrigin(u);
1513 return !!a && a === asciiOrigin(base);
1514}
1515function postIdFromNoteUrl(url, base) {
1516 const s = String(url || '');
1517 // ASCII origins, not startsWith: xn--zz9h.example IS 🩵.example, and a
1518 // byte comparison read our own note as a stranger's.
1519 if (base) { const a = asciiOrigin(s); if (!a || a !== asciiOrigin(base)) return null; }
1520 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
1521 return m ? decodeURIComponent(m[1]) : null;
1522}
1523export function deriveHandle(actorUri) {
1524 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
1525}
1526function actorInfo(doc, actorUri) {
1527 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
1528 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
1529 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
1530 const name = (doc && (doc.name || doc.preferredUsername)) || handle;
1531 // Een AS2 `url` mag een ARRAY van Links zijn -- onze eigen buildActor doet
1532 // dat (profiel + RSS), en een oudere consument stringde die array tot
1533 // "[object Object],[object Object]" in de mention-hrefs van een hulpvraag
1534 // (Barts vondst, 8-8). pickLink kiest de html-Link; de kale string blijft
1535 // de gewone weg, en de actor-id de terugval.
1536 const profiel = (doc && Array.isArray(doc.url))
1537 ? ((pickLink(doc.url, (mt) => !mt || /html/i.test(mt)) || {}).href || safeUrl(doc.id || actorUri))
1538 : safeUrl((doc && (doc.url || doc.id)) || actorUri);
1539 return {
1540 name,
1541 handle,
1542 url: profiel || null,
1543 icon: safeUrl(icon) || null,
1544 // FEP-9098 custom emojis in the display name (":shortcode:"), so the byline
1545 // renders them. Only computed when the name actually has a shortcode.
1546 emojis: /:[A-Za-z0-9_+-]+:/.test(name) ? actorNameEmojis(doc) : undefined,
1547 };
1548}
1549
1550// Map ":shortcode:" → image url from an actor doc's Emoji tags (for a custom-
1551// emoji display name). Undefined when there are none.
1552function actorNameEmojis(doc) {
1553 const arr = doc && Array.isArray(doc.tag) ? doc.tag : (doc && doc.tag ? [doc.tag] : []);
1554 const out = {};
1555 for (const t of arr) {
1556 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Emoji' || typeof t.name !== 'string' || !t.icon) continue;
1557 const u = t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url);
1558 if (u) out[t.name] = u;
1559 }
1560 return Object.keys(out).length ? out : undefined;
1561}
1562
1563// Given an inReplyTo note URL, find which local post the thread belongs to + the
1564// note being replied to (parent), so a reply-to-a-comment can be nested.
1565function findThreadTarget(inReplyTo, base) {
1566 if (!inReplyTo) return null;
1567 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
1568 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
1569 if (seg) {
1570 try { const o = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(seg); if (o && o.post_id) return { post_id: o.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
1571 }
1572 try { const row = db.prepare("SELECT post_id FROM ap_interactions WHERE object_uri = ? AND kind = 'reply' LIMIT 1").get(inReplyTo); if (row && row.post_id) return { post_id: row.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
1573 return null;
1574}
1575
1576// Drop the leading @mention(s) a federated reply carries (the person being replied to),
1577// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
1578// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
1579export function stripLeadingMentions(html) {
1580 if (!html) return html;
1581 let s = String(html);
1582 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
1583 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
1584 return s;
1585}
1586
1587// View-ready threaded view of a post's fediverse activity (inbound replies +
1588// our outbound replies, nested), plus like/boost counts.
1589export function getInteractions(postId, base, site) {
1590 const s = iStmts();
1591 // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
1592 // public web, so it must NOT render in the public thread. It still reaches the owner
1593 // via notifications (post context + reference included there). Legacy rows without a
1594 // visibility value are treated as public. Likes/boosts stay counted (count-only).
1595 const rows = s.list.all(postId).filter((r) =>
1596 r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
1597 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1598 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
1599 // Our own (outbound) replies show the SITE identity for everyone (not "You").
1600 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
1601 const siteName = (site && (site.title || site.slug)) || '';
1602 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
1603 const siteUrl = baseClean ? `${baseClean}/` : '';
1604 const siteIcon = (site && site.profile_photo) || null;
1605
1606 // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
1607 // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
1608 // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
1609 // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
1610 // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
1611 const mijnSleutel = new Map();
1612 for (const r of rows) {
1613 if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
1614 }
1615 const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
1616 const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
1617
1618 const nodes = [];
1619 for (const r of rows) {
1620 if (r.kind !== 'reply') continue;
1621 const ik = mijnReactie(r.object_uri);
1622 nodes.push({
1623 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
1624 actor_uri: r.actor_uri,
1625 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
1626 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
1627 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (thread render)
1628 acted_boost: ik.boosted, acted_like: ik.liked,
1629 children: [],
1630 });
1631 }
1632 for (const o of s.listO.all(postId)) {
1633 nodes.push({
1634 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
1635 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
1636 media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
1637 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
1638 children: [],
1639 });
1640 }
1641
1642 const byId = new Map(nodes.map((n) => [n.noteId, n]));
1643 // Conversation partners per node (u02, the reply editor's mentions bar): the
1644 // node's author plus the ancestor authors up the chain. Our own nodes are
1645 // skipped (we do not mention ourselves), deduped by actor, capped at 8.
1646 for (const n of nodes) {
1647 const seen = new Set();
1648 const list = [];
1649 let cur = n, guard = 0;
1650 while (cur && guard++ < 12 && list.length < 8) {
1651 if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
1652 seen.add(cur.actor_uri);
1653 list.push({
1654 uri: cur.actor_uri,
1655 url: cur.actor_url || cur.actor_uri,
1656 handle: cur.actor_handle || deriveHandle(cur.actor_uri),
1657 });
1658 }
1659 cur = cur.parent ? byId.get(cur.parent) : null;
1660 }
1661 n.participants = list;
1662 }
1663 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
1664 const tops = [];
1665 for (const n of nodes) {
1666 if (isTop(n)) { tops.push(n); continue; }
1667 let anc = n, guard = 0;
1668 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
1669 anc.children.push(n);
1670 }
1671 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
1672 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
1673
1674 return {
1675 thread: tops,
1676 likeCount: rows.filter((r) => r.kind === 'like').length,
1677 announceCount: rows.filter((r) => r.kind === 'announce').length,
1678 total: nodes.length,
1679 };
1680}
1681
1682const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
1683// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
1684// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
1685// an existing site. Deduped.
1686export function localMentionSlugs(tags, base) {
1687 if (!base) return [];
1688 const out = [], seen = new Set();
1689 for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
1690 if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
1691 if (!t.href.startsWith(base + '/ap/users/')) continue;
1692 const slug = slugFromActorUrl(t.href);
1693 if (!slug || seen.has(slug)) continue; seen.add(slug);
1694 try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
1695 }
1696 return out;
1697}
1698
1699// ── Authorized fetch for a single Note (2-8) ─────────────────────
1700// Who may read this post's Note over AP GET? 'public' needs nobody;
1701// friends-only (fan_only, Shaer's DEFAULT) needs a verified follower;
1702// 'direct' is addressed to people and is never served over a GET at all.
1703export function noteAudience(post) {
1704 if (!post) return 'direct';
1705 if (post.ap_visibility === 'direct') return 'direct';
1706 if (post.fan_only || post.ap_visibility === 'friends') return 'followers';
1707 return 'public';
1708}
1709// A follower earns the friends-only Note; a blocked actor gets the same
1710// nothing as a stranger (the standing rule: a blocked actor's signed fetch
1711// earns the empty set, gated server-side at serialisation).
1712export function mayReadNote(site, post, actorUri) {
1713 const aud = noteAudience(post);
1714 if (aud === 'public') return true;
1715 if (aud === 'direct' || !site || !actorUri) return false;
1716 // FEP-1580: dezelfde regel als in outboxAudience, en hier net zo hard nodig.
1717 // De outbox geeft de LIJST vrij; zonder deze tak strandt de doelinstantie
1718 // alsnog op elke losse Note die niet publiek is.
1719 if (isMoveTarget(site.slug, actorUri)) return true;
1720 try {
1721 const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri);
1722 if (blocked) return false;
1723 let host = null; try { host = new URL(actorUri).host; } catch { /* geen host, geen domein-block */ }
1724 if (host) {
1725 const dom = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'domain' AND target = ?").get(site.slug, host);
1726 if (dom) return false;
1727 }
1728 return !!db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(site.slug, actorUri);
1729 } catch { return false; }
1730}
1731
1732
1733// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
1734// Fire-and-forget: a notification must never block or break inbox processing.
1735function pushEvent(slug, event) {
1736 try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
1737 wakeNews(slug); // long-poll waiters (Robins verzoek, 31-7): same moments as push
1738}
1739
1740// ── Long-poll on news (Robins verzoek, 31-7) ─────────────────────
1741// The app holds GET /ap/users/:slug/inbox/wait open; the moment anything
1742// push-worthy lands for that account (a message, a reply, a wave, a help
1743// request) every waiter is woken and the app re-reads its feed. In-process
1744// on purpose: one Klonkt is one process, and a waiter is one callback.
1745const _newsWaiters = new Map(); // slug -> Set<cb>
1746export function onNews(slug, cb) {
1747 let set = _newsWaiters.get(slug);
1748 if (!set) { set = new Set(); _newsWaiters.set(slug, set); }
1749 set.add(cb);
1750 return () => { set.delete(cb); if (!set.size) _newsWaiters.delete(slug); };
1751}
1752/**
1753 * Wachters op het Guardian-paneel (Barts opdracht, 9-8).
1754 *
1755 * APART VAN onNews, en dat is met opzet. `news` gaat over de tijdlijn; dit gaat
1756 * over alles wat een guardian te VERWERKEN krijgt -- een aanbod, een
1757 * volgverzoek, een gate-voorstel, een hulpvraag, een lapse. De guardianship-
1758 * module zendt daar al veertien soorten voor uit; die gingen alleen naar push,
1759 * en push kiest bewust maar een handvol. Het paneel moet ze allemaal weten.
1760 *
1761 * Een wachter wordt EEN keer gewekt en daarna vergeten: het antwoord dat volgt
1762 * is de nieuwe waarheid, en de client komt terug met een nieuwe wachter.
1763 */
1764const _guardWaiters = new Map(); // slug -> Set<cb>
1765export function onGuardian(slug, cb) {
1766 let set = _guardWaiters.get(slug);
1767 if (!set) { set = new Set(); _guardWaiters.set(slug, set); }
1768 set.add(cb);
1769 return () => { set.delete(cb); if (!set.size) _guardWaiters.delete(slug); };
1770}
1771export function wakeGuardian(slug) {
1772 const set = _guardWaiters.get(slug);
1773 if (!set || !set.size) return;
1774 const cbs = [...set];
1775 set.clear();
1776 _guardWaiters.delete(slug);
1777 for (const cb of cbs) { try { cb(); } catch { /* een wachter mag de rest nooit breken */ } }
1778}
1779
1780export function wakeNews(slug) {
1781 const set = _newsWaiters.get(slug);
1782 if (!set || !set.size) return;
1783 const cbs = [...set];
1784 set.clear();
1785 _newsWaiters.delete(slug);
1786 for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } }
1787}
1788// Path prefix for a site's pages. One instance is one owner, so the site
1789// lives at the root; kept as a function because the push URLs read like
1790// `${pushPrefix(slug)}/messages` all over this file.
1791function pushPrefix() { return ''; }
1792// Notification language: the site's content language (fallback: instance default).
1793function pushLang(slug) {
1794 try { const r = db.prepare('SELECT language FROM sites WHERE slug = ?').get(slug); return (r && r.language) || process.env.KLONKT_DEFAULT_LANG || 'nl'; } catch { return 'nl'; }
1795}
1796// Site slug, target URL and title for a post-scoped notification.
1797function pushPostCtx(postId) {
1798 try {
1799 const r = db.prepare('SELECT p.slug AS post, p.title, s.slug AS site FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ?').get(postId);
1800 if (!r) return null;
1801 return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
1802 } catch { return null; }
1803}
1804
1805// ── Op slot na een verhuizing (FEP-7628) ──────────────────────────
1806//
1807// Een verhuisd account serveert `movedTo` en is daarmee dood verklaard. Toch kon
1808// je er gewoon op posten, volgen, liken en reageren, en dat federeerde vrolijk
1809// de wereld in. Drie dingen gaan daar mis:
1810//
1811// - Nieuwe posts krijgen een object-URI op een adres dat je hebt opgezegd. Die
1812// URI's overleven het domein niet, en de reacties erop ook niet.
1813// - Je volgers zijn al verhuisd, dus je post in het niets terwijl het lijkt of
1814// je post.
1815// - Een server die je movedTo ziet EN tegelijk verse activiteit van dat adres
1816// krijgt, krijgt tegenstrijdige signalen over de verhuizing.
1817//
1818// Daarom staat de poort op de UITGAANDE kant en niet op de knoppen: een
1819// C2S-client (Shaer) praat rechtstreeks met deze functies en zou anders zo langs
1820// een verborgen knop lopen. De UI volgt de poort, niet andersom.
1821//
1822// WAT DICHT GAAT: posten, reageren, volgen, liken, boosten, stemmen, en een
1823// tweede verhuizing.
1824// WAT OPEN BLIJFT: alles wat de wegwijzer draagt (de actor, webfinger, je
1825// bestaande posts, de outbox), alles inkomend (reacties op oude posts blijven
1826// binnenkomen en leesbaar), je eigen beheer (archief exporteren, volglijst
1827// downloaden), en ontvolgen -- opruimen mag altijd.
1828// Rapporteren blijft OOK open: dat is een veiligheidsklep, geen inhoud maken.
1829//
1830// OMKEERBAAR: `moved_to` leegmaken heft het slot op. Een verhuizing kan mislukken
1831// en dan moet je terug kunnen.
1832export function movedLock(site) {
1833 const to = site && site.moved_to && /^https?:\/\//i.test(String(site.moved_to))
1834 ? String(site.moved_to) : null;
1835 return to ? { locked: true, movedTo: to } : { locked: false, movedTo: null };
1836}
1837
1838/** Weigering in de vorm die de aanroepers al kennen: een object met `error`. */
1839function movedRefusal(site, wat) {
1840 const l = movedLock(site);
1841 if (!l.locked) return null;
1842 console.warn('[AP] geweigerd, dit account is verhuisd:', wat, '→', l.movedTo);
1843 return { error: 'moved', movedTo: l.movedTo };
1844}
1845
1846// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
1847// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
1848export async function deliverCreate(site, post) {
1849 if (movedLock(site).locked) { console.warn('[AP] Create niet bezorgd, account verhuisd:', site && site.slug); return; }
1850 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1851 if (!base || !site || !site.slug) return;
1852 // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
1853 // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
1854 const mres = await resolveMentionsInText(base, post.content || '');
1855 let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1856 // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
1857 // and remember it on the post, so buildNote (sync, also used by the outbox)
1858 // never has to fetch. The quoted author's inbox joins the delivery set: that
1859 // IS the notification.
1860 const quoteInboxes = [];
1861 // EERST BAKKEN, dan pas linken zoeken (shaer-k3f, gevonden op het toestel):
1862 // firstExternalUrl leest <a href>-ankers, en de web-editor bakt die er bij
1863 // het opslaan al in -- maar een post uit de APP is platte tekst waarin de
1864 // URL nog geen anker is. Zonder deze bak zag het C2S-pad dus nooit een link
1865 // en kreeg een app-post nooit een kaart, terwijl de preview hem net wel
1866 // beloofd had.
1867 const gebakken = bakePostContent(post2.content || '');
1868 if (post2.quote_uri === undefined || post2.quote_uri === null) {
1869 const q = await resolveOwnQuote(gebakken);
1870 if (q) {
1871 try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
1872 post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
1873 }
1874 }
1875 // De kaart op de eigen post (shaer-k3f), langs dezelfde pijplijn als een
1876 // binnenkomende: een fediverse-quote wordt een quote-snapshot, anders
1877 // probeert de link een externe kaart. VOOR de vroege return hieronder, want
1878 // ook een post zonder volgers hoort zijn kaart te krijgen -- de app leest
1879 // hem uit de outbox, niet uit een bezorging. Best-effort en eenmalig: wat
1880 // hier niet lukt blijft een kale link, precies wat het was.
1881 if (!post2.quote_json && !post2.embed_json) {
1882 try {
1883 if (post2.quote_uri) {
1884 const qj = await resolveQuoteByUri(post2.quote_uri);
1885 if (qj) { db.prepare('UPDATE posts SET quote_json = ? WHERE id = ?').run(qj, post.id); post2 = { ...post2, quote_json: qj }; }
1886 } else {
1887 const ej = await resolveExternalEmbed(gebakken);
1888 if (ej) { db.prepare('UPDATE posts SET embed_json = ? WHERE id = ?').run(ej, post.id); post2 = { ...post2, embed_json: ej }; }
1889 }
1890 } catch { /* een kaart is nooit een blokkade voor de post zelf */ }
1891 }
1892 if (post2.quote_actor) {
1893 const a = await fetchActor(post2.quote_actor).catch(() => null);
1894 const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1895 if (inbox) quoteInboxes.push(inbox);
1896 }
1897 const followers = fStmts().list.all(site.slug);
1898 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
1899 if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
1900 const keys = getOrCreateKeys(site.slug);
1901 const keyId = `${actorId(base, site.slug)}#main-key`;
1902 const create = buildCreate(base, site, post2);
1903 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
1904}
1905
1906// On a new Follow, send that follower our most recent posts as Create so their
1907// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
1908// so they sort into the follower's timeline at their original dates.
1909async function backfillNewFollower(base, slug, inbox) {
1910 if (!base || !slug || !inbox) return;
1911 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1912 if (!site) return;
1913 // Deze lijst filterde op fan_only maar NIET op paid, en haalde `paid` ook niet
1914 // op -- dus stond post.paid op undefined, sloeg buildNote zijn redactie over,
1915 // en duwden we bij ELKE nieuwe volger twintig posts de deur uit met de
1916 // volledige tekst van de betaalde erbij. Een push, dus onherroepelijk: het
1917 // staat daarna in hun inbox. Zelfde reden voor ap_visibility, dat hier
1918 // helemaal ontbrak: een friends- of direct-post hoort niet in een backfill.
1919 // (Barts melding, 15 augustus 2026.)
1920 const recent = db.prepare(
1921 `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
1922 c2s_attachments, published_at, created_at, fan_only, ap_visibility, paid, paid_min_cents
1923 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1924 AND IFNULL(ap_visibility, 'public') = 'public'
1925 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
1926 ).all(site.id).reverse();
1927 if (!recent.length) return;
1928 const keys = getOrCreateKeys(slug);
1929 const keyId = `${actorId(base, slug)}#main-key`;
1930 for (const p of recent) {
1931 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
1932 await new Promise((r) => setTimeout(r, 150));
1933 }
1934 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
1935}
1936
1937// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
1938/**
1939 * Delete(Tombstone) voor een van onze EIGEN objecten, naar alle volgers.
1940 *
1941 * De romp staat apart omdat een post niet het enige is dat wij de draad op
1942 * sturen. Een track is een eersterangs Audio-object met een eigen id
1943 * (shaer-0nh), en die werd bij verwijderen nergens aangekondigd: de rij ging
1944 * weg, het object ging 404 en elke server die hem had geindexeerd hield hem
1945 * voor altijd. Op de hub kwam dat op 21-8 aan het licht als een track die naar
1946 * een dode URL wees.
1947 *
1948 * Het object-id komt van de aanroeper. Dat moet ook wel: bij verwijderen is de
1949 * rij vaak al weg, dus er valt niets meer op te zoeken.
1950 */
1951export async function deliverObjectDelete(site, objectId) {
1952 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1953 if (!base || !site || !site.slug || !objectId) return;
1954 const followers = fStmts().list.all(site.slug);
1955 if (!followers.length) return;
1956 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1957 const keys = getOrCreateKeys(site.slug);
1958 const me = actorId(base, site.slug);
1959 const del = {
1960 '@context': AP_CONTEXT,
1961 id: `${objectId}#delete-${Date.now()}-${rid()}`,
1962 type: 'Delete',
1963 actor: me,
1964 to: [PUBLIC],
1965 object: { id: objectId, type: 'Tombstone' },
1966 };
1967 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
1968}
1969
1970export async function deliverDelete(site, post) {
1971 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1972 if (!base || !post || !post.id) return;
1973 return deliverObjectDelete(site, noteId(base, post.id));
1974}
1975
1976/**
1977 * Zelfde voor een track. Roep dit aan VOOR het verwijderen van de rij, net als
1978 * bij een post: daarna is `id` er nog wel maar de context niet meer.
1979 */
1980export async function deliverTrackDelete(site, trackId) {
1981 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1982 if (!base || !site || !site.slug || !trackId) return;
1983 return deliverObjectDelete(site, trackUri(base, site, trackId));
1984}
1985
1986// Tell followers an already-published post changed (Update + edited Note) so
1987// Mastodon refreshes the cached copy (e.g. after fixing content).
1988export async function deliverUpdate(site, post) {
1989 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1990 if (!base || !site || !site.slug || !post || !post.id) return;
1991 const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
1992 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1993 const followers = fStmts().list.all(site.slug);
1994 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
1995 if (!inboxes.length) return;
1996 const keys = getOrCreateKeys(site.slug);
1997 const me = actorId(base, site.slug);
1998 const note = buildNote(base, site, post2);
1999 note.updated = new Date().toISOString();
2000 const update = {
2001 '@context': AP_CONTEXT,
2002 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
2003 type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
2004 object: note,
2005 };
2006 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
2007}
2008
2009// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
2010// account AND re-fetches the featured (pinned) collection — there is no standard
2011// "featured changed" activity, so this is how a pin/unpin propagates promptly.
2012export async function deliverActorUpdate(site) {
2013 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2014 if (!base || !site || !site.slug) return;
2015 const followers = fStmts().list.all(site.slug);
2016 if (!followers.length) return;
2017 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
2018 const keys = getOrCreateKeys(site.slug);
2019 const me = actorId(base, site.slug);
2020 const update = {
2021 '@context': AP_CONTEXT,
2022 id: `${me}#update-${Date.now()}-${rid()}`,
2023 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
2024 object: buildActor(base, site),
2025 };
2026 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
2027}
2028
2029// Reliably set the pinned order on followers' instances via Add/Remove activities
2030// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
2031// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
2032// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
2033// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
2034// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
2035// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
2036// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
2037// resync already in flight for a site coalesces later requests into ONE rerun after it
2038// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
2039const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
2040export function resyncFeaturedPins(site, alsoRemove = []) {
2041 if (!site || !site.slug) return Promise.resolve();
2042 const slug = site.slug;
2043 const running = _pinResync.get(slug);
2044 if (running) {
2045 running.pending = true;
2046 running.site = site; // use the latest site object on the rerun
2047 for (const id of alsoRemove) running.pendingRemove.add(id);
2048 return running.promise;
2049 }
2050 const state = { promise: null, pending: false, pendingRemove: new Set(), site };
2051 state.promise = (async () => {
2052 let extra = alsoRemove;
2053 for (;;) {
2054 try { await doResyncFeaturedPins(state.site, extra); }
2055 catch (e) { console.warn('[AP] pin resync failed:', e.message); }
2056 if (!state.pending) break;
2057 state.pending = false;
2058 extra = [...state.pendingRemove];
2059 state.pendingRemove = new Set();
2060 }
2061 _pinResync.delete(slug);
2062 })();
2063 _pinResync.set(slug, state);
2064 return state.promise;
2065}
2066
2067// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
2068// it stays serialized per site.
2069async function doResyncFeaturedPins(site, alsoRemove = []) {
2070 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2071 if (!base || !site || !site.slug) return;
2072 const followers = fStmts().list.all(site.slug);
2073 if (!followers.length) return;
2074 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
2075 const keys = getOrCreateKeys(site.slug);
2076 const me = actorId(base, site.slug);
2077 const keyId = `${me}#main-key`;
2078 const featured = `${me}/featured`;
2079 const note = (id) => noteId(base, id);
2080 const pinned = db.prepare(
2081 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
2082 AND pinned IS NOT NULL AND pinned > 0
2083 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
2084 ).all(site.id);
2085 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
2086 // 1. Remove every current pin so Mastodon can recreate them in order.
2087 for (const id of removeIds) {
2088 const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
2089 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
2090 }
2091 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
2092 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
2093 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
2094 for (const p of pinned) {
2095 const add = { '@context': AP_CONTEXT, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
2096 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
2097 await new Promise((r) => setTimeout(r, 2000));
2098 }
2099 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
2100}
2101
2102// ── outbound replies (Klonkt → fediverse) ─────────────────────────
2103const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
2104const toISO = (v) => { if (!v) return new Date().toISOString(); const s = String(v); const d = new Date(/[TZ]/.test(s) ? s : s.replace(' ', 'T') + 'Z'); return isNaN(d) ? new Date().toISOString() : d.toISOString(); };
2105
2106// Build one of OUR outbound reply Notes from an ap_outbox row.
2107// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
2108function linkHashtags(base, html) {
2109 // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
2110 // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
2111 return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
2112 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
2113}
2114// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
2115// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
2116// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
2117// outside the link (Mastodon-style).
2118function linkUrls(html) {
2119 const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
2120 for (let i = 0; i < parts.length; i++) {
2121 if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
2122 parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
2123 (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
2124 }
2125 return parts.join('');
2126}
2127// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
2128// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
2129// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
2130// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
2131// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
2132export function linkifyBody(base, html) {
2133 const withTags = String(html || '')
2134 .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
2135 .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
2136 .join('');
2137 return linkUrls(withTags);
2138}
2139
2140// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
2141// at save and cached in posts.content_rendered, so page views serve it statically instead of
2142// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
2143// resolve @mentions here too (webfinger once at save instead of per page view).
2144export function bakePostContent(source) {
2145 return linkifyBody('', source || '');
2146}
2147
2148// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
2149// same resolver the federated copy uses) and bakes the profile links into content_rendered,
2150// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
2151// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
2152// the save response so the request never blocks on a slow/dead remote server.
2153export async function bakePostContentWithMentions(source) {
2154 const withHashUrls = bakePostContent(source);
2155 try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
2156 catch { return withHashUrls; }
2157}
2158
2159// Extract the AP Hashtag tag objects from already-linked reply content.
2160
2161// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
2162// normalizeTags en tagParts staan sinds shaer-38y in ap-core: music/ heeft ze
2163// ook nodig en mag hier niet uit importeren.
2164// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
2165// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
2166// slug/href stays lowercase ("livemusic").
2167// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
2168// Hashtag tag list (with hrefs to our /tag page).
2169// hashtagTags en buildHashtagList staan sinds shaer-38y in ap-core: music/
2170// heeft dezelfde lijst nodig en mag hier niet uit importeren.
2171
2172// Extract Mention tag objects from already-linked content (class="u-url mention").
2173function mentionTags(content) {
2174 const tags = [], seen = new Set();
2175 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
2176 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
2177 let m;
2178 while ((m = re.exec(content || ''))) {
2179 const href = m[1];
2180 if (seen.has(href)) continue; seen.add(href);
2181 tags.push({ type: 'Mention', href, name: '@' + m[2] });
2182 }
2183 return tags;
2184}
2185// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
2186// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
2187async function resolveMentionsInText(base, html) {
2188 const inboxes = [];
2189 const handles = new Set();
2190 // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
2191 // miss: a bracketed mention federated as plain text and its target was never notified).
2192 const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
2193 let m;
2194 while ((m = re.exec(html || ''))) handles.add(m[2]);
2195 let out = String(html || '');
2196 for (const h of handles) {
2197 let actorUri = null;
2198 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
2199 if (!actorUri) continue;
2200 const actor = await fetchActor(actorUri).catch(() => null);
2201 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
2202 if (inbox) inboxes.push(inbox);
2203 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
2204 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2205 out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
2206 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
2207 }
2208 return { html: out, inboxes };
2209}
2210
2211export function buildReplyNote(base, site, row) {
2212 // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
2213 return buildNote(base, site, row, { isReply: true });
2214}
2215
2216// The account's own outbound notes (replies and direct messages) as AS2
2217// Notes, newest first. The C2S inbox read serves these alongside the
2218// timeline: without them your own reply existed everywhere EXCEPT in your
2219// own app (Robins melding, 30-7: "replyen werkt nog niet"; het antwoord
2220// stond op de server maar de app kreeg het nooit terug, dus je probeerde
2221// het opnieuw en liep in de duplicate-guard).
2222export function getSentNotes(base, site, limit = 60) {
2223 return db.prepare('SELECT * FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC LIMIT ?')
2224 .all(site.slug, limit)
2225 .map((row) => buildReplyNote(base, site, row));
2226}
2227
2228// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
2229export function getOutboxNote(base, id) {
2230 const row = iStmts().getO.get(id);
2231 if (!row) return null;
2232 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
2233 if (!site) return null;
2234 return buildReplyNote(base, site, row);
2235}
2236
2237// The direct-note leg (ward call-for-help) lives in the guardianship module
2238// (src/services/guardianship/delivery.js); wired with our AP helpers at the
2239// bottom of this file. Re-exported so every existing caller keeps working.
2240export const c2sVisibility = Guardianship.c2sVisibility;
2241export const deliverDirectNote = Guardianship.deliverDirectNote;
2242
2243// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
2244// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
2245/**
2246 * Een gate-voorstel de deur uit (FEP-633c 5.6, shaer-8ru).
2247 *
2248 * STOND IN routes/guardian.js en kon daar alleen door de PWA aangeroepen worden.
2249 * De apps moeten hetzelfde kunnen, en een tweede implementatie ernaast zou een
2250 * tweede weg naar hetzelfde besluit zijn -- precies de fout die we vandaag bij
2251 * de antwoordpoort hebben rechtgezet, toen de innamepoort alleen in C2S bleek te
2252 * zitten en het webpad eromheen liep. Een pad dus.
2253 *
2254 * EEN WEG, waar de ward ook woont (Robins regel, 29-7): voorstellen over de
2255 * lijn en de server van de ward laat tellen. Co-locatie verandert alleen het
2256 * transport -- deliverToActor lust een lokale ontvanger terug door dezelfde
2257 * inbox. De oude kortsluiting boekte de stem hier meteen, en zo bleef het
2258 * remote-pad een maand stuk zonder dat iemand het merkte.
2259 */
2260export function proposeGate(site, wardUri, feature, allow) {
2261 const uri = String(wardUri || '').trim();
2262 if (!uri) return { status: 400, error: 'empty_uri' };
2263 if (!Guardianship.gated.featureColumn(feature)) return { status: 400, error: 'unknown_feature' };
2264 // Alleen een guardian van dit kind. Zonder deze regel zou iedereen met een
2265 // token een instelling van een vreemd kind kunnen aanvragen.
2266 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
2267 return { status: 403, error: 'not_your_ward' };
2268 }
2269 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2270 const me = actorId(base, site.slug);
2271 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
2272 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
2273 // Ons eigen spoor van wat we stuurden: de server van de ward antwoordt op deze
2274 // Offer zodra het besluit valt, en dat antwoord heeft een rij nodig om in te
2275 // landen. Het is ook het enige waardoor het scherm van de voorsteller meer kan
2276 // zeggen dan een knoptekst.
2277 Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
2278 deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
2279 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
2280 const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
2281 return { status: 200, ok: true, allow, state: 'open', offerId, ...(progress || { federated: true }) };
2282}
2283
2284export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions, visibility }) {
2285 const _mv = movedRefusal(site, 'reply'); if (_mv) return _mv;
2286 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2287 // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
2288 // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
2289 const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
2290 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2291 // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
2292 // upload route is the sole producer), image/audio/video only, max 4.
2293 const media = (Array.isArray(attachments) ? attachments : [])
2294 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
2295 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
2296 .slice(0, 4)
2297 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
2298 // A media-only reply (no text) is a valid reply.
2299 if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
2300 // DE POORT STAAT HIER en niet alleen in de outbox (shaer-r4c). routes/posts.js
2301 // roept deliverReply op drie plekken rechtstreeks aan -- de eigen webinterface
2302 // van Klonkt gaat dus nooit langs ingestOutboxActivity. Een poort die alleen in
2303 // C2S staat is een poort met een deur ernaast.
2304 //
2305 // Dit is het knooppunt dat beide paden delen. De reddingsboei komt hier niet
2306 // langs: een hulpvraag is altijd direct en loopt via deliverDirectNote, dus de
2307 // boei blijft open zonder dat daar een uitzondering voor nodig is.
2308 {
2309 const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
2310 if (!Guardianship.wardGateAllowed(site.gate_replies, isWard)) return null;
2311 }
2312 const me = actorId(base, site.slug);
2313 // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
2314 // parent author). An ARRAY (possibly empty) = the kept conversation partners
2315 // exactly as the bar shows them; the mention prefix, the Mention tags (via
2316 // mentionTags over the content) and the delivery targets all follow it.
2317 const kept = Array.isArray(mentions)
2318 ? mentions
2319 .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
2320 .slice(0, 8)
2321 .map((m) => ({
2322 uri: m.uri,
2323 url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
2324 handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
2325 }))
2326 : null;
2327 const mentionAnchor = (uri, url, h) => {
2328 const disp = h && h[0] === '@' ? h : '@' + (h || '');
2329 return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
2330 };
2331 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
2332 const mention = kept
2333 ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
2334 : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
2335 // Who the stored reply is "to": the parent when kept, else the first kept chip.
2336 const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
2337 const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
2338 const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
2339 let content;
2340 let mres;
2341 if (rich) {
2342 // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
2343 // sanitized editor HTML. The parent mention goes inline into the first
2344 // paragraph (Mastodon convention), or becomes its own leading one.
2345 mres = await resolveMentionsInText(base, rich);
2346 const processed = linkUrls(linkHashtags(base, mres.html));
2347 if (processed.startsWith('<p>')) {
2348 content = processed.replace('<p>', `<p>${mention}`); // inline in the first paragraph
2349 } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
2350 content = `<p>${mention}</p>${processed}`; // block content: own leading paragraph
2351 } else {
2352 content = `<p>${mention}${processed}</p>`; // bare inline text: one paragraph together
2353 }
2354 } else {
2355 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
2356 mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
2357 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2358 }
2359 const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
2360 // Dedup: skip if the exact same reply was already sent (double-submit guard).
2361 // Attachments count toward "the same": two media-only replies share content.
2362 const mediaJson = media.length ? JSON.stringify(media) : null;
2363 // A duplicate is idempotent success, not an error: it answers with the
2364 // EXISTING id. Returning without one made the C2S ingest say 502
2365 // reply_failed on a double-submit (Robins schermafdruk, 30-7), so a retry
2366 // of a reply the app never showed looked like the reply itself failing.
2367 const dup = db.prepare('SELECT id FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
2368 .get(site.slug, parent.object_uri || '', content, mediaJson);
2369 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, id: dup.id, delivered: 0 }; }
2370 const id = crypto.randomUUID();
2371 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
2372 // Followers-only reply (shaer detail-view): mark the row so buildNote drops
2373 // Public from cc. Default (undefined/'public'/'quiet') stays quiet-public.
2374 if (visibility === 'friends') { try { db.prepare('UPDATE ap_outbox SET visibility = ? WHERE id = ?').run('friends', id); } catch { /* ignore */ } }
2375 const row = iStmts().getO.get(id);
2376 const note = buildReplyNote(base, site, row);
2377 const create = {
2378 '@context': AP_CONTEXT,
2379 id: note.id + '#create', type: 'Create', actor: me,
2380 published: note.published, to: note.to, cc: note.cc, object: note,
2381 };
2382 const keys = getOrCreateKeys(site.slug);
2383 const keyId = `${me}#main-key`;
2384 const inboxes = new Set();
2385 // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
2386 const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
2387 for (const uri of mentionTargets) {
2388 const a = await fetchActor(uri).catch(() => null);
2389 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
2390 }
2391 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
2392 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
2393 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2394 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
2395 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
2396 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
2397 let delivered = 0;
2398 for (const inbox of [...inboxes].filter(Boolean)) {
2399 let ok = false;
2400 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2401 if (ok) delivered++;
2402 else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
2403 }
2404 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
2405 return { id, content, delivered };
2406}
2407
2408// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
2409// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
2410function actorUriOf(att) {
2411 if (!att) return null;
2412 if (typeof att === 'string') return att;
2413 if (Array.isArray(att)) {
2414 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
2415 if (person) return person.id;
2416 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
2417 return null;
2418 }
2419 return att.id || null;
2420}
2421
2422// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
2423// Returns a parent-shaped object usable by deliverReply(), or null.
2424// The server's own note, built straight from the DB. resolveRemoteNote used
2425// to fetch EVERYTHING over HTTPS, including notes living right here: a
2426// hairpin fetch fails on home setups (a Klonkt on a Mac behind a tunnel), the
2427// /ap/notes route rightly hides friends-only posts, and a punycode-spelled
2428// own URL read as remote on a byte comparison. For the authenticated C2S
2429// caller none of those walls apply; the DB is one prepare() away.
2430// `forSlug` is that caller: only the post's own site gets its non-public
2431// notes on this shortcut (public ones anyone, same as the route serves).
2432function localNoteObject(url, forSlug) {
2433 if (!isOwnUrl(url)) return null;
2434 const m = String(url).match(/\/ap\/notes\/([^/?#]+)/);
2435 if (!m) return null;
2436 const id = decodeURIComponent(m[1]);
2437 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2438 const post = db.prepare("SELECT * FROM posts WHERE id = ? AND status = 'published'").get(id);
2439 if (post) {
2440 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
2441 if (!site) return null;
2442 const nonPublic = post.fan_only || post.ap_visibility === 'friends' || post.ap_visibility === 'direct';
2443 if (nonPublic && (!forSlug || forSlug !== site.slug)) return null;
2444 return buildNote(base, site, post);
2445 }
2446 return getOutboxNote(base, id); // our own outbound replies
2447}
2448// The own actor document, same shortcut, same reason.
2449function localActorObject(uri) {
2450 if (!isOwnUrl(uri)) return null;
2451 const m = String(uri).match(/\/ap\/users\/([^/?#]+)/);
2452 const site = m ? db.prepare('SELECT * FROM sites WHERE slug = ?').get(decodeURIComponent(m[1])) : null;
2453 return site ? buildActor((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site) : null;
2454}
2455
2456// ── De thread onder een post (shaer-tqz) ───────────────────────────
2457//
2458// Klonkt is hier een TOLK, geen archief (Barts besluit, 7-8): de antwoorden
2459// worden opgehaald op het moment dat iemand kijkt en daarna weer vergeten.
2460// Geen tabel, geen migratie -- wie replies bewaart van elke post die iemand
2461// tegenkomt, laat de omvang van zijn database bepalen door surfgedrag. Dat is
2462// de AFWIJKING, niet de norm: Mastodon serveert /context uit zijn eigen
2463// database, en dat verdient zich daar terug omdat honderden mensen de cache
2464// delen. Een Klonkt-instance is de server van één persoon.
2465//
2466// Waarom dit niet in de app kan: de replies-collectie van een vreemde server
2467// eist in secure mode een ONDERTEKEND verzoek, en de sleutel staat hier en kan
2468// hier niet weg. Voor de opgaande inReplyTo-keten komt de app weg met een
2469// ongetekende GET (mist er een, jammer); voor een thread van dertig is "de
2470// helft doet het niet" geen resultaat.
2471//
2472// Je krijgt hier NOOIT de hele thread: een replies-collectie bevat alleen wat
2473// die ene server gezien heeft. De UI hoort "wat de bron weet" te tonen en geen
2474// volledigheid te suggereren.
2475// NIET hetzelfde als maybeCrawlThread verderop: die kruipt de thread onder je
2476// EIGEN posts af en bewaart de antwoorden in ap_interactions (dat zijn de
2477// jouwe, die horen te blijven). Dit hier is voor een post van een ANDER die je
2478// tegenkomt, en bewaart niets.
2479const THREAD_VIEW_LIMIT = 30;
2480const THREAD_VIEW_TTL_MS = 120_000;
2481const THREAD_VIEW_CACHE_MAX = 200;
2482const threadViewCache = new Map(); // `${slug}|${uri}` -> { at, out } -- geheugen, weg bij herstart
2483
2484/** Eén pagina items uit een AS2-collectie, welke spelling hij ook koos. */
2485function collectionItems(coll) {
2486 if (!coll || typeof coll !== 'object') return [];
2487 const arr = coll.orderedItems || coll.items;
2488 return Array.isArray(arr) ? arr : [];
2489}
2490
2491/**
2492 * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
2493 *
2494 * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
2495 * kringfilter (de dichte stand van shaer:externalThreads) woont in
2496 * filterThreadToCircle en de beeld/muziek/emoji-poorten in
2497 * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
2498 * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
2499 * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
2500 * een blokkade is onzichtbaar, ook als getal.
2501 */
2502export async function getThread(slug, objectUri) {
2503 const key = `${slug}|${objectUri}`;
2504 const hit = threadViewCache.get(key);
2505 if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
2506
2507 // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
2508 // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
2509 // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
2510 // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
2511 let sourceStatus = 0;
2512 const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
2513 const note = await get(objectUri);
2514 const repliesRef = note && note.replies;
2515 let coll = null;
2516 if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
2517 else if (repliesRef && typeof repliesRef === 'object') {
2518 coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
2519 : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
2520 }
2521 // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
2522 // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
2523 // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
2524 // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
2525 // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
2526 // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
2527 // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
2528 let items = [];
2529 let node = coll;
2530 if (node && node.first && !collectionItems(node).length) {
2531 node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
2532 }
2533 let pages = 0;
2534 while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
2535 items.push(...collectionItems(node));
2536 if (!node.next) break;
2537 node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
2538 }
2539 items = items.slice(0, THREAD_VIEW_LIMIT);
2540
2541 // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
2542 // afwachten is een halve minuut kijken naar een spinner.
2543 const objs = await Promise.all(items.map(async (it) => {
2544 const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
2545 return (o && o.id && o.attributedTo) ? o : null;
2546 }));
2547
2548 const kept = [];
2549 for (const o of objs) {
2550 if (!o) continue;
2551 const actorUri = actorUriOf(o.attributedTo);
2552 if (!actorUri || isBlockedAny(actorUri)) continue; // een blokkade telt niet mee
2553 kept.push({ o, actorUri });
2554 }
2555
2556 // Bylines: één fetch per unieke auteur, niet één per antwoord.
2557 const authors = new Map();
2558 await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
2559 authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
2560 }));
2561
2562 const notes = kept.map(({ o, actorUri }) => ({
2563 id: o.id,
2564 type: 'Note',
2565 // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
2566 // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
2567 attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
2568 inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
2569 content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
2570 url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
2571 published: typeof o.published === 'string' ? o.published : undefined,
2572 sensitive: !!o.sensitive,
2573 summary: (contentWarning(o) || '').slice(0, 500) || undefined,
2574 attachment: (() => {
2575 const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
2576 const out = arr.map((a) => ({ type: 'Document', mediaType: (a && a.mediaType) || undefined, url: safeUrl(a && a.url), name: (a && typeof a.name === 'string') ? a.name.slice(0, 1500) : undefined }))
2577 .filter((a) => a.url);
2578 return out.length ? out.slice(0, 8) : undefined;
2579 })(),
2580 // FEP-9098: de custom emoji van het antwoord (":shortcode:" -> plaatje).
2581 // Zonder deze tags rendert een reply van een Mastodon-account zijn emoji
2582 // als kale tekst (Barts punt, 8-8). Alleen naam + geschoond icoon-adres
2583 // gaan door; de rest van de vreemde tag-array blijft achter.
2584 tag: (() => {
2585 const j = extractEmojiTags(o.tag);
2586 if (!j) return undefined;
2587 const out = JSON.parse(j)
2588 .map((t) => ({ type: 'Emoji', name: t.name, icon: { type: 'Image', url: safeUrl(t.icon && (t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url))) } }))
2589 .filter((t) => t.icon.url)
2590 .slice(0, 30);
2591 return out.length ? out : undefined;
2592 })(),
2593 })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
2594
2595 const out = { notes, found: !!note, sourceStatus };
2596 threadViewCache.set(key, { at: Date.now(), out });
2597 if (threadViewCache.size > THREAD_VIEW_CACHE_MAX) {
2598 const oldest = [...threadViewCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
2599 if (oldest) threadViewCache.delete(oldest[0]);
2600 }
2601 return out;
2602}
2603
2604/**
2605 * De thread gefilterd op de kring die de guardians al kennen (gevolgd of
2606 * volgend) -- de dichte stand van shaer:externalThreads. PER VERZOEK, buiten de
2607 * threadcache om: een poort die de guardians net dichtzetten mag niet nog twee
2608 * minuten open nawerken uit een cache. Wat er buiten valt wordt GETELD, nooit
2609 * stil weggelaten.
2610 */
2611export function filterThreadToCircle(slug, notes) {
2612 const circle = new Set();
2613 try { for (const r of db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted'").all(slug)) circle.add(r.actor_uri); } catch { /* geen tabel */ }
2614 try { for (const r of db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ?').all(slug)) circle.add(r.actor_uri); } catch { /* geen tabel */ }
2615 const kept = [], out = { hidden: 0 };
2616 for (const n of notes) {
2617 // actorUriOf, niet n.attributedTo: sinds de byline ingesloten meegaat is
2618 // dat een OBJECT en zou een kale vergelijking hier stil alles wegfilteren
2619 // -- een ward met een lege thread en nergens een foutmelding.
2620 if (circle.has(actorUriOf(n.attributedTo))) kept.push(n);
2621 else out.hidden += 1;
2622 }
2623 out.notes = kept;
2624 return out;
2625}
2626
2627export async function resolveRemoteNote(url, opts = {}) {
2628 if (!/^https?:\/\//i.test(String(url || ''))) return null;
2629 // With `asSlug` the fetches are SIGNED as that local actor. An anonymous
2630 // GET can only read public notes; a friends-only note (Shaer's default!)
2631 // rightly refuses it, which made every reply to a friend's post fail while
2632 // a reply to your own public post worked (Robins melding, 30-7). Signed,
2633 // the other server sees WHO asks and serves what the friendship earns.
2634 const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null));
2635 const note = localNoteObject(url, opts.asSlug) || await get(url); // own DB first, then AP GET
2636 if (!note || !note.id) return null;
2637 const att = note.attributedTo;
2638 const actorUri = actorUriOf(att);
2639 if (!actorUri) return null;
2640 const actor = localActorObject(actorUri) || await get(actorUri);
2641 const ai = actorInfo(actor, actorUri);
2642 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
2643 // link our reply to that local post so it shows nested in the post thread.
2644 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
2645 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
2646 // and collect every ancestor author's inbox, so each participant's server —
2647 // including the original post's author — receives + threads our reply.
2648 const threadInboxes = [];
2649 const seenInbox = new Set();
2650 let cursor = note.inReplyTo, guard = 0;
2651 while (cursor && guard++ < 6) {
2652 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
2653 if (!url) break;
2654 const pn = localNoteObject(url, opts.asSlug) || await get(url);
2655 if (!pn) break;
2656 const pa = actorUriOf(pn.attributedTo);
2657 if (pa && pa !== actorUri) {
2658 const paDoc = await get(pa);
2659 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
2660 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
2661 }
2662 cursor = pn.inReplyTo; // climb to the next ancestor
2663 }
2664 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
2665 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
2666 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
2667 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
2668 const images = (Array.isArray(note.attachment) ? note.attachment : [])
2669 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
2670 .map((a) => safeUrl(a.url)).filter(Boolean);
2671 // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
2672 // shows the player card, not a loose image) and puts it in `image` instead.
2673 // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
2674 if (!images.length && note.image) {
2675 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2676 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2677 if (iu) images.push(iu);
2678 }
2679 return {
2680 object_uri: safeUrl(note.id) || note.id,
2681 actor_uri: actorUri,
2682 actor_url: ai.url,
2683 actor_handle: ai.handle,
2684 actor_name: ai.name,
2685 actor_icon: ai.icon,
2686 url: note.url || url,
2687 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
2688 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
2689 cw: contentWarning(note) || '',
2690 images,
2691 // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
2692 // image-only for the interact page preview; a boosted video-only post (Loops)
2693 // lost its media entirely because upsertBoostedNote only saw `images`.
2694 media: mediaFromNote(note),
2695 threadInboxes, // every ancestor author's inbox
2696 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
2697 poll: parsePoll(note), // a Question → its options/counts (else null)
2698 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
2699 };
2700}
2701
2702// List a site's own outbound fediverse replies (for the manage/delete view).
2703// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
2704// so the manage view can prefill an edit box; the mention is re-added on save.
2705function outboxEditableText(content) {
2706 return String(content || '')
2707 .replace(/<br\s*\/?>/gi, '\n')
2708 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
2709 .replace(/<[^>]+>/g, '')
2710 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
2711 .trim();
2712}
2713export function listOutbox(siteSlug) {
2714 // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
2715 // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
2716 // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
2717 // elkaar in "Verzonden" en "Gesprekken".
2718 return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
2719 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
2720}
2721
2722// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
2723export async function deliverOutboxDelete(site, outboxId) {
2724 const row = iStmts().getO.get(outboxId);
2725 if (!row || row.site_slug !== site.slug) return false;
2726 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2727 if (base) {
2728 const me = actorId(base, site.slug);
2729 const nid = noteId(base, row.id);
2730 const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
2731 const keys = getOrCreateKeys(site.slug);
2732 const inboxes = new Set();
2733 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
2734 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2735 for (const inbox of [...inboxes].filter(Boolean)) {
2736 try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
2737 enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
2738 }
2739 }
2740 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
2741 return true;
2742}
2743
2744// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
2745// re-linked) and send an Update(Note) so recipients refresh their cached copy.
2746export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
2747 const row = iStmts().getO.get(outboxId);
2748 if (!row || row.site_slug !== site.slug) return false;
2749 const text = String(newText || '').trim();
2750 // Rich edit: same sanitize + enrichment pipeline as deliverReply.
2751 const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
2752 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2753 if (!text && !rich) return false;
2754 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2755 if (!base) return false;
2756 const me = actorId(base, site.slug);
2757 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
2758 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
2759 const _h = row.to_handle || deriveHandle(row.to_actor);
2760 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
2761 // An edit must not drop co-mentions (u02): reuse the OLD content's leading
2762 // mention anchors (the bar's kept list at send time) when present; only fall
2763 // back to rebuilding the single to_actor mention for legacy rows.
2764 const oldPrefix = (String(row.content || '')
2765 .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
2766 const mention = oldPrefix || (row.to_actor
2767 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
2768 let content;
2769 let mres;
2770 if (rich) {
2771 mres = await resolveMentionsInText(base, rich);
2772 const processed = linkUrls(linkHashtags(base, mres.html));
2773 if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
2774 else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
2775 else content = `<p>${mention}${processed}</p>`;
2776 } else {
2777 mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
2778 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2779 }
2780 // Language may be updated with the edit; attachments always survive untouched.
2781 const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
2782 db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
2783 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
2784 note.updated = new Date().toISOString();
2785 const update = {
2786 '@context': AP_CONTEXT,
2787 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
2788 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
2789 };
2790 const keys = getOrCreateKeys(site.slug);
2791 const inboxes = new Set();
2792 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
2793 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2794 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
2795 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
2796 let delivered = 0;
2797 for (const inbox of [...inboxes].filter(Boolean)) {
2798 let ok = false;
2799 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2800 if (ok) delivered++;
2801 else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
2802 }
2803 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
2804 return { ok: true, content, delivered };
2805}
2806
2807
2808
2809// Store the author's display-name emoji map (from actorInfo().emojis) on a
2810// timeline row, so the byline can render a ":shortcode:" name. No-op when the
2811// name has no custom emoji (the common case).
2812function storeAuthorEmoji(id, slug, ai) {
2813 if (!ai || !ai.emojis || !Object.keys(ai.emojis).length) return;
2814 try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(ai.emojis), id, slug); } catch { /* ignore */ }
2815}
2816
2817// A display-name emoji map (actorInfo().emojis) → JSON to store, or null.
2818function emojiJsonOf(map) { return (map && Object.keys(map).length) ? JSON.stringify(map) : null; }
2819
2820// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
2821let _abCount, _cirkelPosts, _cirkelMembers;
2822export function autoBoostCount(slug) {
2823 try { if (!_abCount) _abCount = db.prepare('SELECT COUNT(*) AS n FROM ap_following WHERE slug = ? AND auto_boost = 1'); return _abCount.get(slug).n; } catch { return 0; }
2824}
2825export function getCirkelPosts(slug, limit, offset) {
2826 try {
2827 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
2828 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
2829 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
2830 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
2831 t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
2832 (rb.target_uri IS NOT NULL) AS boosted
2833 FROM ap_timeline t
2834 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
2835 -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
2836 -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
2837 -- deze join kan geen rijen verdubbelen.
2838 LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
2839 WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
2840 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
2841 LIMIT ? OFFSET ?`);
2842 return _cirkelPosts.all(slug, limit || 60, offset || 0);
2843 } catch { return []; }
2844}
2845export function getCirkelMembers(slug) {
2846 try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
2847}
2848
2849
2850// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
2851// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
2852// note and refreshes content + media (recovers covers/edits that were delivered
2853// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
2854// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
2855const SELFHEAL_VERSION = 22; // v22: summary is pas een waarschuwing MET sensitive, en een artikel houdt zijn titel
2856async function fetchNoteAP(url) {
2857 try {
2858 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
2859 if (r.status === 404 || r.status === 410) return 404;
2860 if (r.ok) return await r.json();
2861 } catch { /* unreachable */ }
2862 return null;
2863}
2864function mediaFromNote(note) {
2865 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => {
2866 const m = { url: safeUrl(a && a.url), type: (a && a.mediaType) || '' };
2867 // A federated video may carry its poster as an AS2 icon (shaer-zowq).
2868 const iconUrl = a && a.icon && safeUrl(typeof a.icon === 'string' ? a.icon : a.icon.url);
2869 if (iconUrl && /^video\//i.test(m.type)) m.poster = iconUrl;
2870 return m;
2871 }).filter((m) => m.url);
2872 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
2873 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2874 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2875 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
2876 }
2877 return JSON.stringify(atts);
2878}
2879
2880// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
2881// own posts quotes a fediverse object, say so in the shapes the network really
2882// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
2883// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
2884// third form. All three point at the same object, which is what every reader
2885// expects. The quoted author goes in `cc`, because being quoted without being
2886// told is exactly the rudeness this FEP is trying to design away.
2887export function applyQuoteProps(note, quoteUri, quoteActor) {
2888 if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
2889 note.quote = quoteUri;
2890 note.quoteUrl = quoteUri;
2891 note['_misskey_quote'] = quoteUri;
2892 note.tag = [...(note.tag || []), {
2893 type: 'Link',
2894 mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
2895 href: quoteUri,
2896 rel: ['https://misskey-hub.net/ns#_misskey_quote'],
2897 name: quoteUri,
2898 }];
2899 if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
2900 note.cc = [...new Set([...(note.cc || []), quoteActor])];
2901 }
2902 return note;
2903}
2904
2905// The first external (non-fediverse) link in a note, resolved to the same card
2906// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
2907// third-party frame inside a kid-safe app is a hole you cannot close again, so
2908// the embed carries an image and a title and nothing executable.
2909// Returns the JSON to store, or null when there is nothing worth showing.
2910export async function resolveExternalEmbed(html) {
2911 const first = firstExternalUrl(html);
2912 if (!first) return null;
2913 const io = EmbedResolver.liveIO({
2914 safeFetch,
2915 detectProvider: (u) => AudioEmbedService.detectProvider(u),
2916 fetchActor,
2917 actorInfo,
2918 });
2919 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
2920 // 'ap' is handled by the quote path; a bare 'link' is not worth a card.
2921 if (!card || card.kind === 'ap' || card.kind === 'link') return null;
2922 const thumb = (card.media || []).find((m) => m && m.url);
2923 if (!thumb && !card.title) return null;
2924 // Title, provider and author name come from a third party. Store them as
2925 // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
2926 // be the one that remembers to escape. A card is a card, not an essay.
2927 const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
2928 return JSON.stringify({
2929 url: card.url,
2930 kind: card.kind, // 'provider' | 'oembed'
2931 provider: plain(card.provider),
2932 title: plain(card.title),
2933 author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
2934 media: thumb ? [thumb] : [], // thumbnail only, no html/iframe
2935 });
2936}
2937
2938/**
2939 * Does our own post link to a fediverse object? Returns { uri, actor } when the
2940 * first external link resolves to a quotable AP object, else null. Runs once at
2941 * publish time; the answer is stored on the post.
2942 */
2943export async function resolveOwnQuote(html) {
2944 const first = firstExternalUrl(html);
2945 if (!first) return null;
2946 const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
2947 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
2948 if (!card || card.kind !== 'ap' || !card.id) return null;
2949 return { uri: card.id, actor: card.attributedTo || null };
2950}
2951
2952/**
2953 * De composer-preview (shaer-k3f): één URL langs exact dezelfde pijplijn als
2954 * publiceren, zodat wat de preview toont ook is wat de post krijgt. Twee
2955 * uitkomsten, hoogstens een gevuld: een AP-object wordt een quote-snapshot,
2956 * een externe link probeert een kaart. Beide als JSON-string, dezelfde vorm
2957 * als de kolommen -- de route serveert ze door timelineQuote/timelineEmbed en
2958 * de gate, net als de tijdlijn.
2959 */
2960export async function previewCard(url) {
2961 if (!/^https?:\/\//i.test(String(url || ''))) return {};
2962 const html = `<a href="${String(url).replace(/"/g, '&quot;')}">x</a>`;
2963 const q = await resolveOwnQuote(html);
2964 if (q && q.uri) {
2965 const quoteJson = await resolveQuoteByUri(q.uri).catch(() => null);
2966 if (quoteJson) return { quoteJson };
2967 } else {
2968 const embedJson = await resolveExternalEmbed(html).catch(() => null);
2969 if (embedJson) return { embedJson };
2970 }
2971 return {};
2972}
2973
2974/** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
2975export function firstExternalUrl(html) {
2976 if (!html || typeof html !== 'string') return null;
2977 for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) {
2978 const tag = m[0];
2979 if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue;
2980 const href = m[1];
2981 if (/^https?:\/\//i.test(href)) return href;
2982 }
2983 return null;
2984}
2985
2986/** The stored external-embed card, for the C2S read. */
2987// ── Standaardvormen in plaats van eigen dialect (shaer-nmw) ───────
2988//
2989// Robins waarschuwing: geen Klonkt/Shaer-dialect schrijven waar AS2 of een FEP
2990// het al regelt. Vier eigen properties hadden een standaard naast zich staan,
2991// en deze helpers zijn die standaard -- een definitie per vorm, zodat de tien
2992// plekken die ze emitten niet elk hun eigen variant krijgen.
2993//
2994// De oude shaer:-velden blijven er voorlopig NAAST staan. Een app in het veld
2995// leest ze nog, en een leeg scherm is een duurdere fout dan een dubbel veld;
2996// ze gaan eruit als de clients om zijn (tweede helft van shaer-nmw).
2997
2998/** FEP-9098 Emoji-tags uit een {shortcode: url}-kaart. */
2999function emojiTagsFromMap(emojis) {
3000 const uit = Object.entries(emojis || {})
3001 .filter(([naam, url]) => naam && url)
3002 .map(([naam, url]) => ({ type: 'Emoji', name: naam, icon: { type: 'Image', url } }));
3003 return uit.length ? uit : undefined;
3004}
3005
3006/**
3007 * Een actor als INGESLOTEN OBJECT voor `attributedTo` / `actor`.
3008 *
3009 * AS2 staat toe dat attributedTo een object is in plaats van een URI, en dan
3010 * heeft ELKE client er wat aan -- niet alleen de onze, die er shaer:author
3011 * naast kreeg. `preferredUsername` is de lokale naam; een lezer leidt de handle
3012 * af uit die naam plus de host van de id, precies zoals wij serverkant ook
3013 * doen. Weten we niets van de persoon, dan blijft het de kale URI: een leeg
3014 * object zou beweren dat we hem kennen.
3015 */
3016export function actorObject(uri, info) {
3017 if (!uri) return undefined;
3018 const iets = info && (info.name || info.handle || info.icon || info.url);
3019 if (!iets) return uri;
3020 const o = { id: uri, type: 'Person' };
3021 if (info.name) o.name = info.name;
3022 const lokaal = String(info.handle || '').replace(/^@/, '').split('@')[0];
3023 if (lokaal) o.preferredUsername = lokaal;
3024 if (info.icon) o.icon = { type: 'Image', url: info.icon };
3025 if (info.url) o.url = info.url;
3026 const tags = emojiTagsFromMap(info.emojis);
3027 if (tags) o.tag = tags;
3028 return o;
3029}
3030
3031/**
3032 * De linkkaart als AS2 `preview` (core: "identifies an entity that provides a
3033 * preview of this object"). Een Page met url, name en image IS een kaart; daar
3034 * hoefde shaer:embed nooit voor te bestaan.
3035 *
3036 * Wat WEL van ons blijft is de spelerpagina: dat die alleen meegaat als de
3037 * guardians de poort openden is FEP-633c-gedrag en heeft geen AS2-tegenhanger.
3038 */
3039export function previewObject(embedJson, { playback = false } = {}) {
3040 const e = timelineEmbed(embedJson, { playback });
3041 if (!e) return undefined;
3042 const thumb = (e.media || []).find((m) => m && m.url);
3043 const p = { type: 'Page', url: e.url };
3044 if (e.title) p.name = e.title;
3045 if (thumb) p.image = { type: 'Image', url: thumb.url };
3046 if (e.author && (e.author.name || e.author.handle)) {
3047 p.attributedTo = { type: 'Person', name: e.author.name || e.author.handle };
3048 }
3049 if (e['shaer:playerUrl']) p['shaer:playerUrl'] = e['shaer:playerUrl'];
3050 if (e['shaer:playable']) p['shaer:playable'] = e['shaer:playable'];
3051 return p;
3052}
3053
3054/**
3055 * De geciteerde post als OBJECT in `quote` (FEP-044f staat toe dat quote het
3056 * object zelf is, niet alleen een URI). De opgeslagen momentopname wordt hier
3057 * een echte Note, met de auteur als ingesloten actor -- dus geen tweede eigen
3058 * property voor iets dat de FEP al kan.
3059 */
3060export function quoteObject(quoteJson) {
3061 const q = timelineQuote(quoteJson);
3062 if (!q) return undefined;
3063 const note = { type: 'Note', id: q.url, url: q.url };
3064 if (q.content) note.content = q.content;
3065 if (q.published) note.published = q.published;
3066 if (q.author) {
3067 note.attributedTo = actorObject(q.author.url || q.url, {
3068 name: q.author.name, handle: q.author.handle, icon: q.author.icon,
3069 });
3070 }
3071 const media = (q.media || []).filter((m) => m && m.url)
3072 .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
3073 if (media.length) note.attachment = media;
3074 const tags = emojiTagsFromMap(q.emojis);
3075 if (tags) note.tag = tags;
3076 return note;
3077}
3078
3079export function timelineEmbed(embedJson, { playback = false } = {}) {
3080 try {
3081 const e = embedJson ? JSON.parse(embedJson) : null;
3082 if (!e || typeof e !== 'object' || !e.url) return undefined;
3083 // The player URL is served ONLY when the playback gate is open (FEP-633c
3084 // 5.6). Deciding it here keeps the provider knowledge in one place: the
3085 // client never needs a list of hosts, it just plays what it is handed.
3086 // Privacy-enhanced variants only: nocookie for YouTube, the instance's own
3087 // player for PeerTube. Without one the card stays a thumbnail.
3088 const player = playback ? playerUrlFor(e.url) : null;
3089 if (player) return { ...e, 'shaer:playerUrl': player };
3090 // The gate is shut and there IS something behind it. Saying so costs
3091 // nothing (the card already shows a video thumbnail) and saves the child
3092 // from tapping a card that will never answer: the app can explain instead
3093 // of doing nothing. It stays a statement of fact, never a way in.
3094 return playerUrlFor(e.url) ? { ...e, 'shaer:playable': true } : e;
3095 } catch { return undefined; }
3096}
3097
3098/** The embeddable player for a URL, or null when we will not frame it. */
3099export function playerUrlFor(url) {
3100 if (typeof url !== 'string') return null;
3101 let p = null;
3102 try { p = AudioEmbedService.detectProvider(url); } catch { p = null; }
3103 if (p && p.provider === 'youtube' && p.id) return `https://www.youtube-nocookie.com/embed/${p.id}?rel=0&modestbranding=1&playsinline=1`;
3104 if (p && p.provider === 'vimeo' && p.id) return `https://player.vimeo.com/video/${p.id}`;
3105 // PeerTube is decentralised, so it is matched by its watch-URL shape rather
3106 // than a provider list. Host chars are validated before it is inlined.
3107 const pt = url.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
3108 if (pt) return `https://${pt[1]}/videos/embed/${pt[2]}`;
3109 return null;
3110}
3111
3112// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
3113// snapshot { url, author{name,handle,icon}, content, published, media } so the
3114// client can render it as a nested card instead of a bare link. Best-effort and
3115// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
3116// to the object-link chip. The content goes through the same sanitiser as every
3117// other note, so the kid-safe guarantees hold.
3118async function resolveQuote(note) {
3119 const url = quoteHrefOf(note);
3120 if (!url) return null;
3121 return resolveQuoteByUri(url);
3122}
3123
3124/** Hetzelfde snapshot, maar vanaf een kale URI: eigen posts en de
3125 * composer-preview (shaer-k3f) kennen alleen de link, niet de tag-vorm. */
3126async function resolveQuoteByUri(url) {
3127 const q = await apGetJson(url);
3128 if (!q || typeof q !== 'object') return null;
3129 const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
3130 : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
3131 const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
3132 // The quoted post's own FEP-9098 emojis, so :shortcode: renders in the card.
3133 const emojis = {};
3134 try {
3135 for (const e of JSON.parse(extractEmojiTags(q.tag) || '[]')) {
3136 const u = e.icon && (e.icon.url || (Array.isArray(e.icon) && e.icon[0] && e.icon[0].url));
3137 if (typeof e.name === 'string' && u) emojis[e.name] = u;
3138 }
3139 } catch { /* ignore */ }
3140 let media = []; try { media = JSON.parse(mediaFromNote(q)); } catch { /* ignore */ }
3141 const snapshot = {
3142 url: safeUrl(q.url || q.id || url) || url,
3143 author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
3144 content: HtmlSanitizerService.sanitize(q.content || ''),
3145 published: q.published || null,
3146 media,
3147 emojis: Object.keys(emojis).length ? emojis : undefined,
3148 };
3149 return JSON.stringify(snapshot);
3150}
3151
3152/**
3153 * The card under a post: a fediverse quote (FEP-044f) when the note has one,
3154 * otherwise an external link preview. Both render as the SAME card, so only one
3155 * of the two is ever stored. Returns {column, json} or null.
3156 *
3157 * Both halves reach out over the network, which is why every caller runs this
3158 * out of band: an inbox answer must never wait on a third party.
3159 */
3160async function resolveCard(o) {
3161 if (quoteHrefOf(o)) {
3162 const qj = await resolveQuote(o);
3163 return qj ? { column: 'quote_json', json: qj } : null;
3164 }
3165 const ej = await resolveExternalEmbed(o && o.content);
3166 return ej ? { column: 'embed_json', json: ej } : null;
3167}
3168
3169// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
3170// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
3171// history-from-before-you-followed or a delivery that was missed while you were down;
3172// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
3173export async function backfillFromOutbox(slug, actorUri, limit = 20) {
3174 try {
3175 if (!slug || !actorUri) return 0;
3176 const actor = await fetchActor(actorUri);
3177 if (!actor || !actor.outbox) return 0;
3178 // Signed as the follower (30-7): the serving side recognises an accepted
3179 // friend and hands the friends-only history along; an anonymous GET only
3180 // ever sees the public set. A server that ignores the signature behaves
3181 // exactly as before.
3182 let page = await signedGetJson(slug, typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
3183 let items = (page && (page.orderedItems || page.items)) || [];
3184 if (!items.length && page && page.first) {
3185 page = await signedGetJson(slug, typeof page.first === 'string' ? page.first : page.first.id);
3186 items = (page && (page.orderedItems || page.items)) || [];
3187 }
3188 if (!Array.isArray(items) || !items.length) return 0;
3189 const ai = actorInfo(actor, actorUri);
3190 let added = 0;
3191 for (const it of items.slice(0, limit)) {
3192 // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
3193 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
3194 if (!o || !o.id) continue;
3195 if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
3196 if (o.inReplyTo) continue; // top-level only
3197 const auth = actorUriOf(o.attributedTo);
3198 if (auth && auth !== actorUri) continue; // their OWN posts only
3199 const html = HtmlSanitizerService.sanitize(o.content || '');
3200 const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
3201 try {
3202 const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, contentWarning(o));
3203 if (r && r.changes > 0) added++;
3204 // FEP-9098: keep custom-emoji tags from backfilled posts too.
3205 { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, slug); } catch { /* ignore */ } } }
3206 storeAuthorEmoji(o.id, slug, ai); // custom-emoji display name for the byline
3207 // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
3208 { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
3209 // FEP-044f: resolve the embedded quote card for backfilled posts too.
3210 if (quoteHrefOf(o)) { const qj = await resolveQuote(o); if (qj) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, slug); } catch { /* ignore */ } } }
3211 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
3212 if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = COALESCE(poll_json, ?) WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, slug); } catch { /* ignore */ } }
3213 } catch { /* ignore */ }
3214 }
3215 if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
3216 return added;
3217 } catch { return 0; }
3218}
3219
3220// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
3221// Most replies reach us by delivery, but replies-to-replies that live on other servers and
3222// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
3223// we DO have, caching any newly-found ones in ap_interactions.
3224//
3225// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
3226// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
3227// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
3228// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
3229// never runs in a page request — the view renders from cache; a stale post kicks off a
3230// background refresh for the NEXT view.
3231const THREAD_TTL_MS = 15 * 60 * 1000; // don't re-crawl a post more than ~4×/hour
3232const THREAD_MAX_DEPTH = 1; // one hop per crawl (like Mastodon); deeper fills in over crawls
3233const THREAD_MAX_FETCHES = 30; // hard cap on remote GETs per crawl (be a good peer)
3234const _crawlingThreads = new Set(); // per-post in-flight lock (no stampede across views)
3235
3236function threadCrawlTs(postId) {
3237 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
3238 catch { return 0; }
3239}
3240function setThreadCrawlTs(postId, ts) {
3241 try { db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run('thread_crawl:' + postId, String(ts)); }
3242 catch { /* ignore */ }
3243}
3244
3245// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
3246// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
3247async function collectReplyItems(repliesRef, maxPages, budget) {
3248 const uris = [];
3249 let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
3250 if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
3251 let pages = 0;
3252 while (node && pages++ < maxPages) {
3253 for (const it of (node.items || node.orderedItems || [])) {
3254 const u = typeof it === 'string' ? it : (it && it.id);
3255 if (u && /^https?:\/\//i.test(u)) uris.push(u);
3256 }
3257 if (!node.next) break;
3258 node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
3259 }
3260 return uris;
3261}
3262
3263async function crawlThread(postId) {
3264 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3265 if (!base) return;
3266 // Seed frontier = the remote reply note URIs we already have; also the dedup set.
3267 let known;
3268 try { known = new Set(db.prepare("SELECT object_uri FROM ap_interactions WHERE post_id = ? AND kind = 'reply' AND object_uri != ''").all(postId).map((r) => r.object_uri)); }
3269 catch { return; }
3270 const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
3271 if (!seeds.length) return; // nothing remote to expand
3272 // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
3273 // crawler never re-adds them via thread-filling (they're gone from the seeds
3274 // already because rejectInteraction deleted their ap_interactions row).
3275 try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
3276 catch { /* table always exists after boot migration */ }
3277
3278 let fetches = 0;
3279 const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
3280 const visited = new Set(); // notes whose replies collection we've already expanded
3281 let frontier = seeds.slice();
3282 let added = 0;
3283
3284 for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
3285 const nextFrontier = [];
3286 for (const noteUri of frontier) {
3287 if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
3288 visited.add(noteUri);
3289 const note = await budget.get(noteUri);
3290 if (!note || !note.replies) continue;
3291 const childUris = await collectReplyItems(note.replies, 2, budget);
3292 for (const cu of childUris) {
3293 if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
3294 known.add(cu);
3295 const child = await budget.get(cu);
3296 if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
3297 if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
3298 const actorUri = actorUriOf(child.attributedTo);
3299 if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
3300 const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
3301 const ai = actorInfo(actor, actorUri);
3302 const html = HtmlSanitizerService.sanitize(child.content || '');
3303 // The child replies to `note` by construction (it's in note's replies collection).
3304 try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri, noteVisibility(child), extractEmojiTags(child.tag), emojiJsonOf(ai.emojis)); added++; } catch { /* ignore */ }
3305 nextFrontier.push(child.id); // expand this reply's own replies next depth
3306 }
3307 }
3308 frontier = nextFrontier;
3309 }
3310 if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
3311}
3312
3313// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
3314// fires a background crawl only if this post hasn't been crawled within the TTL.
3315export function maybeCrawlThread(postId) {
3316 if (!postId || _crawlingThreads.has(postId)) return;
3317 if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
3318 _crawlingThreads.add(postId);
3319 setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
3320 crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
3321}
3322
3323let _selfHealing = false;
3324export async function selfHealTimeline() {
3325 if (_selfHealing) return; _selfHealing = true;
3326 try {
3327 let cur = 0;
3328 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
3329 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
3330 // v21: direct notes used to land in the timeline as if they were posts, so a
3331 // ward's 🛟 help request showed up in the guardian's Krant. The insert now
3332 // refuses them; drop the ones already cached. Scoped to the two kinds we can
3333 // still recognise afterwards (help request, wave) — a plain public mention
3334 // from someone you follow IS a timeline post and must stay.
3335 try {
3336 const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
3337 SELECT 1 FROM ap_mentions m
3338 WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
3339 AND (m.help_request = 1 OR m.wave = 1))`).run();
3340 if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
3341 } catch { /* table may predate the columns */ }
3342 let rows = [];
3343 try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
3344 let healed = 0, failed = 0;
3345 for (const r of rows) {
3346 // Link previews first, and deliberately BEFORE the note re-fetch. A
3347 // preview is resolved from the content we already hold, so hanging it
3348 // behind a remote fetch meant one unreachable origin skipped the whole
3349 // row (`continue` below) and the card never appeared. It needs nothing
3350 // from the origin, so it must not depend on it.
3351 if (!r.quote_json && !r.embed_json) {
3352 try {
3353 const ej = await resolveExternalEmbed(r.content);
3354 if (ej) db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id);
3355 } catch { /* best-effort, never blocks the heal */ }
3356 }
3357 try {
3358 const note = await fetchNoteAP(r.id);
3359 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
3360 if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
3361 // Door DEZELFDE bouwer als de innamekant (v22). Hij bouwde de inhoud
3362 // hier zelf op, en daardoor miste een gerepareerde rij precies wat de
3363 // inname wel doet -- de titel van een artikel bijvoorbeeld. Een
3364 // zelfherstel dat een andere vorm oplevert dan de inname repareert naar
3365 // een derde toestand.
3366 const velden = timelineFields(note);
3367 const html = velden.html;
3368 const media = velden.atts.length ? JSON.stringify(velden.atts) : mediaFromNote(note);
3369 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
3370 const cw = contentWarning(note);
3371 const url = note.url || null; // re-sync the human url (catches a remote slug rename)
3372 const emoji = extractEmojiTags(note.tag); // FEP-9098: re-capture custom-emoji tags (v8)
3373 const link = extractLinkJson(note); // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
3374 // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
3375 // cached snapshot if the quoted post is momentarily unreachable now.
3376 const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
3377 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '') || (quote || '') !== (r.quote_json || '')) {
3378 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ?, quote_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, quote, r.id);
3379 healed++;
3380 }
3381 // v13: a custom-emoji display name needs the author's emoji map. Fetch
3382 // the actor once, only for rows whose name has a shortcode and no map yet.
3383 if (/:[A-Za-z0-9_+-]+:/.test(r.author_name || '') && !r.author_emoji_json && r.author_uri) {
3384 const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
3385 if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
3386 }
3387 // v14: same for the booster's display name ("X boosted"). The row stores
3388 // no booster URI, so resolve it from the handle via webfinger. Scoped to
3389 // this exact row (slug) since a note can be boosted by different people.
3390 if (/:[A-Za-z0-9_+-]+:/.test(r.reblog_name || '') && !r.reblog_emoji_json && r.reblog_handle) {
3391 const bUri = await webfingerResolve(r.reblog_handle);
3392 const em = bUri ? actorNameEmojis(await fetchActor(bUri)) : undefined;
3393 if (em) { try { db.prepare('UPDATE ap_timeline SET reblog_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(em), r.id, r.slug); } catch { /* ignore */ } }
3394 }
3395 } catch { failed++; /* per-note best-effort */ }
3396 }
3397 // Only mark this version DONE after a clean pass. Some origins are briefly
3398 // offline exactly when we heal (phone-hosted instances!): skipping them and
3399 // consuming the version would leave those rows stale forever. Instead retry
3400 // on the next boots, giving up after a few attempts (permanently-dead
3401 // origins answer 404/410 and are deleted above, so they don't loop).
3402 const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
3403 let attempts = 0;
3404 try { const a = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_attempts'); attempts = a ? (parseInt(a.value, 10) || 0) : 0; } catch { /* ignore */ }
3405 if (failed === 0 || attempts >= 4) {
3406 setSetting('selfheal_version', SELFHEAL_VERSION);
3407 setSetting('selfheal_attempts', 0);
3408 } else {
3409 setSetting('selfheal_attempts', attempts + 1);
3410 }
3411 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
3412 } catch { /* never block boot */ } finally { _selfHealing = false; }
3413}
3414
3415// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
3416
3417/**
3418 * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
3419 * still change): an account our sites follow says it moved to a new home.
3420 *
3421 * Validity has two independent legs, and both must hold:
3422 * 1. The SIGNER is a party to the move: the old actor announcing its own move
3423 * (push mode) or the new actor doing it (pull mode). A third party
3424 * narrating someone else's move is refused — without this, any signed
3425 * stranger could re-point our follows.
3426 * 2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
3427 * cross-side proof: the mover controls both ends. Without it, whoever
3428 * holds ONE end could hijack the other end's followers.
3429 *
3430 * Effect: every local site following the old actor unfollows it and follows
3431 * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
3432 * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
3433 * feed subscription, and moving one is shaer-tge's gated decision, not a
3434 * side effect of an inbox event. We only log when a move touches one.
3435 *
3436 * Deps are injectable for tests (no network in node:test).
3437 */
3438export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
3439 const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
3440 const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
3441 if (!oldUri || !newUri || oldUri === newUri) return 400;
3442 if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
3443 console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
3444 return 401;
3445 }
3446 // Nobody here follows the old actor → nothing to move. This also makes
3447 // redelivery idempotent: after the first swap the rows are gone.
3448 let rows = [];
3449 try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
3450 if (!rows.length) return 202;
3451 // A blocked destination is declined outright: the old follow stays (it goes
3452 // stale on its own), and we will not open a door to a blocked house.
3453 if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
3454 const target = await (fetchActorFn || fetchActor)(newUri);
3455 const aka = [].concat((target && target.alsoKnownAs) || [])
3456 .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
3457 if (!target || !target.id || !aka.includes(oldUri)) {
3458 console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
3459 return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
3460 }
3461 // EERST de guardianship, DAARNA pas de follows. Die volgorde is geen netheid
3462 // maar de hele werking, en hij is met bloed geschreven: bij Robins verhuizing
3463 // op 13-8 stond het andersom en het log liet precies zien wat er dan gebeurt.
3464 //
3465 // [AP] outgoing Follow beta → .../robo (gated, awaiting guardians)
3466 //
3467 // Beta is zelf een ward. Zijn UITGAANDE follow naar de verhuisde guardian werd
3468 // gepoort (§5.3), want op dat moment stond het nieuwe adres nog niet in zijn
3469 // guardian-lijst: de code hieronder had de relatie nog niet bijgewerkt. En de
3470 // INKOMENDE kant heeft hetzelfde probleem, want de ward gate't een Follow van
3471 // een onbekende. Dus beide richtingen bleven hangen op goedkeuring die niemand
3472 // hoefde te geven, omdat het om een guardian ging die er al was.
3473 //
3474 // Met de relatie eerst is de verhuisde actor al een erkende guardian als de
3475 // follows langskomen, en gaat de auto-acceptatie gewoon door.
3476 //
3477 // Een Move is een Move: de guardian is dezelfde guardian, het kind is hetzelfde
3478 // kind, alleen het adres is nieuw. Zelfde bescherming als de re-follow: alleen
3479 // na een geverifieerde Move, en niet naar een geblokkeerde bestemming (daar
3480 // zijn we hierboven al uitgestapt). De twee harde randen van shaer-tge staan
3481 // hier LOS van: weigeren te verhuizen naar een instance die shaer:guardians
3482 // niet kan dragen is een controle aan de UITGAANDE kant, en het
3483 // terugkeren-zonder-set is een alsoKnownAs-kwestie.
3484 try {
3485 const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
3486 if (g.length) {
3487 const r = db.prepare('UPDATE ap_guardianships SET other_uri = ? WHERE other_uri = ? AND status = ?').run(newUri, oldUri, 'accepted');
3488 console.log('[AP] guardianship moved:', oldUri, '→', newUri, `(${r.changes}x)`, g.map((x) => `${x.role}:${x.slug}`).join(', '));
3489 }
3490 } catch (e) { console.warn('[AP] guardianship move failed:', e && e.message); }
3491
3492 for (const row of rows) {
3493 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
3494 if (!site) continue;
3495 try {
3496 await (unfollowFn || unfollowActor)(site, oldUri);
3497 const already = fwStmts().one.get(row.slug, newUri);
3498 if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
3499 console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
3500 } catch (e) {
3501 console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
3502 }
3503 }
3504 return 202;
3505}
3506
3507/**
3508 * Slice 2 van shaer-0j2 (FEP-7628, DRAFT): de UITGAANDE helft — deze Klonkt
3509 * is het oude huis en kondigt het vertrek aan. Twee eisen voordat er iets
3510 * de deur uit gaat:
3511 * 1. Geen guardians: een warded account verhuizen zonder de guardianship
3512 * te hertargeten zou het vangnet van het kind stil breken; dat is
3513 * shaer-tge's gated beslissing, dus tot die er is weigert een bewaakt
3514 * account de verhuizing.
3515 * 2. De NIEUWE actor claimt ons in alsoKnownAs — dezelfde back-reference
3516 * die elke ontvangende server (onze eigen slice 1 incluis) eist. Zonder
3517 * die claim is de Move overal dood bij aankomst.
3518 * De Move gaat duurzaam naar elke volger-inbox; hun servers doen de
3519 * re-follow. `moved_to` wordt hier vastgelegd; het SERVEREN ervan op de
3520 * actor (en het beleid van de oude site) is slice 3.
3521 * Deps injecteerbaar voor tests (geen netwerk in node:test).
3522 */
3523export async function moveAccount(site, targetRaw, { fetchActorFn = null, deliverFn = null } = {}) {
3524 // Al verhuisd? Dan eerst het slot eraf (moved_to leegmaken). Anders stapel je
3525 // wegwijzers op elkaar en weet niemand meer waar de keten eindigt.
3526 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3527 if (!base || !site || !site.slug) return { error: 'config' };
3528 if (movedLock(site).locked) return { error: 'already_moved', movedTo: movedLock(site).movedTo };
3529 try {
3530 // Was een harde weigering voor elk bewaakt account (shaer-tge); sinds 8-8
3531 // een GATE met dezelfde standaard: de automatiek weigert voor een ward,
3532 // maar de guardians kunnen shaer:accountMove expliciet openzetten -- en
3533 // expliciet dichtzetten geldt dan ook voor een account dat net geen ward
3534 // meer is, net als bij de embeds.
3535 const isWard = Guardianship.listGuardians(site.slug).length > 0;
3536 if (!Guardianship.wardGateAllowed(site.gate_account_move, isWard)) {
3537 console.warn('[AP] move refused: gated (shaer-tge):', site.slug, '→', String(targetRaw || ''));
3538 return { error: 'guarded_account' };
3539 }
3540 } catch { /* geen guardianship-tabellen = geen guardians */ }
3541 const s = String(targetRaw || '').trim();
3542 let targetUri = null;
3543 if (/^https?:\/\//i.test(s)) targetUri = safeUrl(s);
3544 else if (s.includes('@')) targetUri = await webfingerResolve(s);
3545 if (!targetUri) return { error: 'not_found' };
3546 const me = actorId(base, site.slug);
3547 if (targetUri === me) return { error: 'self' };
3548 const target = await (fetchActorFn ? fetchActorFn(targetUri) : signedGetJson(site.slug, targetUri));
3549 if (!target || !target.id || !target.inbox) return { error: 'unreachable' };
3550 const aka = [].concat(target.alsoKnownAs || [])
3551 .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
3552 if (!aka.includes(me)) return { error: 'no_backreference' };
3553 db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(target.id, site.slug);
3554 const keys = getOrCreateKeys(site.slug);
3555 const move = {
3556 '@context': AP_CONTEXT,
3557 id: `${me}#move-${Date.now()}-${rid()}`,
3558 type: 'Move',
3559 actor: me,
3560 object: me,
3561 target: target.id,
3562 to: [`${me}/followers`],
3563 };
3564 // FEP-7628: after setting movedTo, notify the followers with an Update of
3565 // the actor, so their servers hold the signpost even if the Move itself is
3566 // lost. Built from the FRESH row: `site` still carries the pre-move values.
3567 const movedSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(site.slug) || { ...site, moved_to: target.id };
3568 const update = {
3569 '@context': AP_CONTEXT,
3570 id: `${me}#update-${Date.now()}-${rid()}`,
3571 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
3572 object: buildActor(base, movedSite),
3573 published: new Date().toISOString(),
3574 };
3575 const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
3576 const send = deliverFn || deliverWithRetry;
3577 for (const inbox of inboxes) {
3578 await send(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
3579 await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
3580 }
3581 console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
3582 return { ok: true, target: target.id, inboxes: inboxes.length };
3583}
3584
3585// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
3586/**
3587 * Who is reading this outbox, and what may they see (30-7)?
3588 * - 'blocked': a verified caller this instance blocks. They get an EMPTY
3589 * collection, not even the public set (Robins eis): a block is a closed
3590 * door, and a signed fetch is the caller knocking with their name on it.
3591 * - 'friend': the owner (bearer) or a verified accepted follower or
3592 * guardian: the fan-only history rides along.
3593 * - 'public': everyone else: the public set.
3594 */
3595export function outboxAudience(slug, { bearerSlug = null, verifiedActor = null } = {}) {
3596 if (bearerSlug && bearerSlug === slug) return 'friend';
3597 if (!verifiedActor) return 'public';
3598 if (isBlockedAny(verifiedActor)) return 'blocked';
3599 // FEP-1580, Source Instance: wie ondertekend vraagt namens de actor waar wij
3600 // NAARTOE verhuisd zijn, moet behandeld worden alsof wij het zelf vragen.
3601 // Anders kan de nieuwe instantie alleen het publieke deel ophalen en verhuist
3602 // je fan-only geschiedenis niet mee.
3603 if (isMoveTarget(slug, verifiedActor)) return 'friend';
3604 try {
3605 if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
3606 } catch { /* table absent on fresh init */ }
3607 if (isWardGuardian(slug, verifiedActor)) return 'friend';
3608 return 'public';
3609}
3610
3611/**
3612 * FEP-1580, de hele autorisatie van de bronkant in één predicaat.
3613 *
3614 * De spec zegt: behandel een verzoek dat namens de DOEL-actor getekend is alsof
3615 * de BRON-actor het deed, voor zichtbaarheid en toegang. Wij hangen dat aan
3616 * `moved_to`, en dat mag omdat moveAccount() `no_backreference` weigert: het
3617 * veld komt er alleen te staan als de doel-actor ons al in `alsoKnownAs` had.
3618 * Dus staat er iets, dan heeft iemand met beheer op BEIDE kanten dat gewild.
3619 * Een typefout kan hier niet binnenkomen, want die haalt de move zelf niet.
3620 *
3621 * Dat dit veilig is leunt op de keyId-binding in verifyRequest (shaer-xd8i):
3622 * zonder die controle kon een actor tekenen met de sleutel van een buurman op
3623 * dezelfde host, en dan is "wie tekende dit" te zacht om je hele geschiedenis
3624 * aan af te geven.
3625 */
3626export function isMoveTarget(slug, actorUri) {
3627 if (!slug || !actorUri) return false;
3628 try {
3629 const row = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
3630 return !!(row && row.moved_to && row.moved_to === actorUri);
3631 } catch { return false; }
3632}
3633
3634// guardian of the local ward `wardSlug` — so a signed GET from it may read the
3635// ward's non-public history without the guardian appearing as a follower.
3636export function isWardGuardian(wardSlug, actorUri) {
3637 try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
3638}
3639
3640// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
3641// Accept to the follower and record them, so delivery (incl. followers-only)
3642// begins. `pending` is a row from ap_pending_follows.
3643/**
3644 * FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
3645 *
3646 * A ward's OWN follow waited for nobody: it went straight out and the guardians
3647 * got a note afterwards (1a2f206). That is informing, not gating — the door is
3648 * already open when the message lands. Now it waits, with two exceptions that
3649 * are not favours but the same decision already taken:
3650 *
3651 * - the target is one of the ward's own guardians. Following the adult who
3652 * watches over you is not a question anyone needs to answer.
3653 * - the target already follows the ward THROUGH THE GATE. A guardian
3654 * approved that person by name; asking again about the same person only
3655 * teaches everyone to stop reading the question.
3656 *
3657 * Returns the held request, or null when the follow may go out now.
3658 * Deliberately not a boolean: a held follow must be distinguishable from a sent
3659 * one all the way up to the app, which is the lesson the error path already
3660 * learned (Robins melding, 31-7).
3661 */
3662export async function gateOutgoingFollow(site, targetUri) {
3663 const slug = site && site.slug;
3664 if (!slug || !targetUri) return null;
3665 const guardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
3666 if (!guardians.length) return null; // not a ward: nothing to gate
3667 // shaer:following (shaer-p729) — its own gate, apart from shaer:follows,
3668 // which governs the OTHER direction. §5.3 fixes the inbound one on: a Follow
3669 // aimed at a ward MUST pass the guardians. About this direction the FEP says
3670 // nothing, so it is ours to set and ours to let go of, and the guardians can
3671 // relax it for a child who has grown into it. Undecided means gated for a
3672 // ward, the same automatiek as the rest of the family.
3673 const gateRow = db.prepare('SELECT gate_following FROM sites WHERE slug = ?').get(slug);
3674 if (Guardianship.wardGateAllowed(gateRow && gateRow.gate_following, true)) return null;
3675 if (guardians.includes(targetUri)) return null; // your own guardian
3676 if (Guardianship.outgoing.isMutual(slug, targetUri)) return null; // already vetted by name
3677
3678 const seen = Guardianship.outgoing.findFor(slug, targetUri);
3679 if (seen && seen.status === 'approved') return null; // the guardians said yes already
3680 if (seen && (seen.status === 'pending' || seen.status === 'denied')) return seen;
3681
3682 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3683 const wardActor = actorId(base, slug);
3684 const target = await fetchActor(targetUri).catch(() => null);
3685 const ti = actorInfo(target, targetUri);
3686 const id = `${wardActor}#outfollow-${Date.now()}-${rid()}`;
3687 const held = Guardianship.outgoing.recordPending(slug, {
3688 id, target: targetUri,
3689 inbox: target && ((target.endpoints && target.endpoints.sharedInbox) || target.inbox),
3690 name: ti.name, handle: ti.handle, icon: ti.icon,
3691 });
3692
3693 // Same routing as the inbound gate: a guardian on this instance gets a push
3694 // and reads /guardian; one elsewhere gets an Offer delivered so its own
3695 // server holds a copy to answer from.
3696 const wardKeys = getOrCreateKeys(slug);
3697 const followObj = { id, type: 'Follow', actor: wardActor, object: targetUri };
3698 for (const g of guardians) {
3699 try { Guardianship.availability.recordRequest(slug, g, id, Date.now()); } catch { /* never load-bearing */ }
3700 }
3701 for (const g of guardians) {
3702 const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
3703 const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
3704 if (isLocal) {
3705 const L = pushLang(gslug);
3706 // De andere richting, en dus andere woorden: hier vraagt het kind of het
3707 // iemand mag volgen. Met dezelfde tekst als hierboven kon een guardian
3708 // op zijn telefoon niet zien wie er nu eigenlijk om wie vroeg.
3709 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folout_t'), body: i18nT(L, 'push.n_guard_folout_b', { who: ti.name || ti.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
3710 } else {
3711 fetchActor(g).then((ga) => {
3712 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
3713 if (!inbox) return;
3714 // Zou DIT antwoord het besluit afmaken (shaer-8vt)? Bij twee guardians is de
3715 // drempel 1, dus de EERSTE ja beslist -- en dat is precies wat de
3716 // beantwoorder niet kon weten.
3717 const beslissend = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
3718 const offer = { '@context': AP_CONTEXT, id: `${wardActor}#outfollowoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:direction': 'outgoing', 'shaer:decisive': beslissend };
3719 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
3720 }).catch(() => {});
3721 }
3722 }
3723 console.log('[AP] outgoing Follow', slug, '→', targetUri, '(gated, awaiting guardians)');
3724 return held || { id, ward_slug: slug, target_uri: targetUri, status: 'pending' };
3725}
3726
3727/**
3728 * The guardians said yes: send the ward's Follow for real (§5.3, shaer-p729).
3729 *
3730 * The row stays behind as `approved` rather than being deleted. It is the
3731 * record that these guardians vetted this target, so an unfollow-and-refollow
3732 * later does not put the same question in front of them again.
3733 */
3734export async function performApprovedFollow(pending) {
3735 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(pending.ward_slug);
3736 if (!site) return { error: 'no_such_ward' };
3737 const r = await followActor(site, pending.target_uri, false, { approved: true });
3738 if (r && r.error) return { error: r.error };
3739 console.log('[AP] outgoing Follow approved', pending.ward_slug, '→', pending.target_uri);
3740 return { ok: true };
3741}
3742
3743export async function acceptGatedFollow(pending) {
3744 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3745 const slug = pending.ward_slug;
3746 const me = actorId(base, slug);
3747 const keys = getOrCreateKeys(slug);
3748 fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
3749 // This follower came through the §5.3 gate: a guardian said yes to this
3750 // person by name. That is precisely what lets the ward follow them back later
3751 // without asking the same guardians the same question twice (shaer-p729).
3752 db.prepare('UPDATE ap_followers SET gate_approved = 1 WHERE slug = ? AND actor_uri = ?').run(slug, pending.follower_uri);
3753 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
3754 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
3755 await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
3756 const filled = pending.follower_shared_inbox &&
3757 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1').get(slug, pending.follower_shared_inbox, pending.follower_uri);
3758 if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
3759 console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
3760 return { ok: true };
3761}
3762
3763// The guardians denied the follow: send a Reject so the follower's server clears
3764// its pending state, then the caller drops the record.
3765export async function rejectGatedFollow(pending) {
3766 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3767 const slug = pending.ward_slug;
3768 const me = actorId(base, slug);
3769 const keys = getOrCreateKeys(slug);
3770 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
3771 const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
3772 if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
3773 console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
3774 return { ok: true };
3775}
3776
3777// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
3778// offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
3779// 2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
3780async function handleFollowApprovalInbox(act, slugParam) {
3781 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3782 const type = Array.isArray(act.type) ? act.type[0] : act.type;
3783 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
3784
3785 // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
3786 // signed by the ward, so act.actor is the ward.
3787 if (type === 'Offer') {
3788 const fo = (act.object && typeof act.object === 'object') ? act.object : null;
3789 const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
3790 if (!fo || foType !== 'Follow') return false;
3791 const followId = fo.id;
3792 const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
3793 const wardUri = actorUri;
3794 if (!followId || !follower || !wardUri) return false;
3795 const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
3796 if (slugParam) recips.push(actorId(base, slugParam));
3797 let stored = false;
3798 for (const r of new Set(recips)) {
3799 const gslug = slugFromActorUrl(r);
3800 if (!gslug) continue;
3801 if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue; // must actually guard this ward
3802 const wardDoc = await fetchActor(wardUri).catch(() => null);
3803 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
3804 // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
3805 // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
3806 // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
3807 // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
3808 // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
3809 const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
3810 const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
3811 const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
3812 Guardianship.follows.recordReview(gslug, {
3813 id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
3814 follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
3815 direction: uitgaand ? 'outgoing' : 'incoming',
3816 target: doel || null, targetHandle: dai ? dai.handle : null,
3817 });
3818 const L = pushLang(gslug);
3819 // `uitgaand` staat hier al, drie regels hoger, en werd voor de melding
3820 // weer weggegooid: elke richting kreeg dezelfde tekst, geleend van
3821 // offer_for_ward. Op de telefoon las een volgverzoek dus als een
3822 // adoptie-aanvraag, en beide richtingen als elkaar.
3823 const wardNaam = (wardDoc && (wardDoc.preferredUsername || wardDoc.name)) || slugFromActorUrl(wardUri) || wardUri;
3824 const anderNaam = uitgaand
3825 ? ((dai && (dai.name || dai.handle)) || i18nT(L, 'notif.someone'))
3826 : (fai.name || fai.handle || i18nT(L, 'notif.someone'));
3827 pushEvent(gslug, {
3828 type: 'guardian',
3829 title: i18nT(L, uitgaand ? 'push.n_guard_folout_t' : 'push.n_guard_folin_t'),
3830 body: i18nT(L, uitgaand ? 'push.n_guard_folout_b' : 'push.n_guard_folin_b', { who: anderNaam, ward: wardNaam }),
3831 url: `${pushPrefix(gslug)}/guardian`,
3832 });
3833 stored = true;
3834 }
3835 return stored;
3836 }
3837
3838 // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
3839 const fo = act.object;
3840 const followId = typeof fo === 'string' ? fo : (fo && fo.id);
3841 if (!followId) return false;
3842 const pending = Guardianship.follows.getPending(followId);
3843 if (!pending) return false;
3844 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
3845 if (!allGuardians.includes(actorUri)) return false; // only a real guardian of this ward decides
3846 const decision = type === 'Reject' ? 'reject' : 'approve';
3847 // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
3848 // restored by the one-answer rule when its activity arrived, so answering
3849 // is exactly what counts a guardian back in.
3850 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
3851 const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
3852 try {
3853 if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
3854 else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
3855 } catch { /* delivery is retried */ }
3856 return true;
3857}
3858
3859// Leg 3: a guardian in /guardian decides on a forwarded follow; send the
3860// Accept/Reject back to the ward's inbox (signed by the guardian).
3861export async function sendFollowDecision(guardianSite, review, decision) {
3862 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3863 const me = actorId(base, guardianSite.slug);
3864 const keys = getOrCreateKeys(guardianSite.slug);
3865 const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
3866 const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true };
3867 if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
3868 return { ok: true };
3869}
3870
3871// Send a Like or Announce (boost) on a remote note FROM this site.
3872export async function sendInteraction(site, kind, targetNoteId, authorUri) {
3873 const _mv = movedRefusal(site, `interaction:${kind}`); if (_mv) return _mv;
3874 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3875 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
3876 const me = actorId(base, site.slug);
3877 const keys = getOrCreateKeys(site.slug);
3878 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
3879 // reblog (matched on actor+object — no record of the original Announce needed).
3880 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
3881 const followersCol = `${me}/followers`;
3882 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
3883 // attributes the boost to their post and notifies them — without this, a shared-inbox
3884 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
3885 // stamp keep us aligned with what Mastodon emits.
3886 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
3887 let act;
3888 if (kind === 'unboost' || kind === 'unlike') {
3889 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
3890 // no record of the original activity needed — Mastodon honours both).
3891 const inner = kind === 'unboost' ? 'Announce' : 'Like';
3892 act = {
3893 '@context': AP_CONTEXT,
3894 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
3895 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
3896 };
3897 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
3898 } else {
3899 const type = kind === 'boost' ? 'Announce' : 'Like';
3900 act = {
3901 '@context': AP_CONTEXT,
3902 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
3903 type, actor: me, object: targetNoteId,
3904 };
3905 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
3906 }
3907 const inboxes = new Set();
3908 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
3909 // routes the Announce/Like to the right post unambiguously.
3910 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
3911 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
3912 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
3913 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
3914 // silently lose the boost — same durability a new post (deliverCreate) already gets.
3915 let queued = 0;
3916 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
3917 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
3918 return { ok: true, delivered: queued };
3919}
3920
3921// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
3922export function getNotifications(slug, limit) {
3923 // Per-source cap scales with the requested limit so Messages can page deep
3924 // (Load more). Bounded so a huge offset can't ask for unbounded rows.
3925 const L = Math.min(1000, Math.max(80, limit || 60));
3926 const out = [];
3927 try {
3928 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
3929 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
3930 }
3931 } catch { /* ignore */ }
3932 try {
3933 const rows = db.prepare(`
3934 SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
3935 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
3936 p.slug AS post_slug, p.title AS post_title
3937 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
3938 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
3939 ORDER BY i.created_at DESC LIMIT ?
3940 `).all(slug, L);
3941 for (const r of rows) out.push({
3942 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
3943 // Waar een antwoord uit de draad heen moet: het id is de parent voor
3944 // deliverReply, de uri het adres voor een direct bericht.
3945 interactionId: r.interaction_id, actorUri: r.actor_uri,
3946 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
3947 // When the post was written, for display. created_at (when it reached us)
3948 // stays the sort key and the unread watermark: a note that federated late
3949 // is still new to you.
3950 published: r.published,
3951 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (messages render)
3952 media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json, // rendered like a Krant post
3953 // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
3954 visibility: r.visibility || 'public',
3955 });
3956 } catch { /* ignore */ }
3957 try {
3958 for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
3959 // The reported objects: our own notes resolve to post links so the owner
3960 // sees WHICH post the report is about; other URIs (e.g. the actor itself)
3961 // are skipped — the report row already names the account.
3962 const about = [];
3963 try {
3964 for (const u of JSON.parse(r.objects || '[]')) {
3965 const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
3966 if (!m) continue;
3967 const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
3968 if (p) about.push({ slug: p.slug, title: p.title || p.slug });
3969 }
3970 } catch { /* malformed objects json → no links */ }
3971 out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, objects: about, created_at: r.created_at });
3972 }
3973 } catch { /* ignore */ }
3974 try {
3975 for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at, published,
3976 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
3977 FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
3978 out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at, published: r.published,
3979 // Same trimmings a Krant row has, so Berichten renders the post identically.
3980 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
3981 }
3982 } catch { /* ignore */ }
3983 // Your own polls that have closed → a "results are in" item, derived read-time
3984 // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
3985 try {
3986 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
3987 if (site) {
3988 const polls = db.prepare(`
3989 SELECT id, slug, title, poll_json FROM posts
3990 WHERE site_id = ? AND poll_json IS NOT NULL
3991 AND json_extract(poll_json, '$.closed') = 1
3992 AND json_extract(poll_json, '$.endTime') IS NOT NULL
3993 ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
3994 for (const p of polls) {
3995 const view = ownPollView(p);
3996 if (!view) continue;
3997 let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
3998 out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
3999 }
4000 }
4001 } catch { /* ignore */ }
4002 // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
4003 // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
4004 // between likes, which also broke Messages' like-grouping).
4005 out.sort((a, b) => _msgTs(b) - _msgTs(a));
4006 return out.slice(0, limit || 60);
4007}
4008function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
4009
4010// ── Blocking / defederation ───────────────────────────────────────
4011// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
4012// Orbit"). Thin delegations keep every existing caller working.
4013export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
4014
4015// True if an actor (or its whole domain) is blocked anywhere on this instance.
4016
4017// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
4018// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
4019// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
4020// author is resolved + included) OR pass actorUri to report an account directly.
4021export async function sendReport(site, { objectUri, actorUri, reason }) {
4022 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4023 if (!base || !site || !site.slug) return { error: 'config' };
4024 let targetActor = actorUri || null;
4025 let noteUri = null;
4026 if (objectUri && /^https?:\/\//i.test(objectUri)) {
4027 const note = await apGetJson(objectUri).catch(() => null);
4028 if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
4029 else if (!targetActor) return { error: 'not_found' };
4030 }
4031 if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
4032 const actor = await fetchActor(targetActor).catch(() => null);
4033 const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
4034 if (!inbox) return { error: 'unreachable' };
4035 const me = actorId(base, site.slug);
4036 const keys = getOrCreateKeys(site.slug);
4037 const object = [targetActor];
4038 if (noteUri && noteUri !== targetActor) object.push(noteUri);
4039 const flag = {
4040 '@context': AP_CONTEXT,
4041 id: `${me}#report-${Date.now()}-${rid()}`,
4042 type: 'Flag',
4043 actor: me,
4044 content: String(reason == null ? '' : reason).slice(0, 3000),
4045 object, // [account, status?] — Mastodon's Flag shape
4046 to: [targetActor],
4047 };
4048 deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
4049 return { ok: true };
4050}
4051
4052export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
4053
4054// Block an actor (@handle or actor URL) or a whole domain; purges their content.
4055// The handle resolver is ours; the storage/purge lives in BlocklistService.
4056//
4057// De BEZORGING hoort ook hier: BlocklistService kent de database, niet het
4058// afleveren. Een Block gaat naar de inbox van wie je blokkeert, een
4059// Undo(Block) bij het opheffen -- zonder retry-wachtrij, want een blokkade
4060// wacht niet op een server die even plat ligt (en bij opheffen komt de ander
4061// vanzelf weer langs).
4062async function bezorgBlokkade(site, target, undo) {
4063 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4064 const me = actorId(base, site.slug);
4065 const blok = { id: `${me}#block-${Date.now()}-${rid()}`, type: 'Block', actor: me, object: target, to: [target] };
4066 const activiteit = undo
4067 ? { '@context': AP_CONTEXT, id: `${me}#unblock-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: blok, to: [target] }
4068 : { '@context': AP_CONTEXT, ...blok };
4069 const r = await deliverToActor(site, target, activiteit);
4070 console.log('[AP]', undo ? 'Undo(Block)' : 'Block', site.slug, '→', target, r && r.delivered ? 'bezorgd' : 'niet bezorgd');
4071}
4072
4073export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve, bezorgBlokkade); }
4074
4075export function unblock(site, target) { return Blocklist.unblock(site, target, bezorgBlokkade); }
4076
4077// ── Guardianship module wiring (src/services/guardianship/) ────────
4078// The module owns FEP-633c (context, relations, handshake, queues, the
4079// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
4080// imports us back.
4081function selfActorId(slug) {
4082 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4083 return actorId(base, slug);
4084}
4085// Deliver one activity to one actor's inbox, signed; queued + retried on any
4086// hiccup so a slow or briefly-down ward server never loses the offer. Returns
4087// { delivered, inbox }: delivered=false means the account could not be
4088// resolved at all (a bad handle) — the offer stays recorded regardless.
4089export async function deliverToActor(site, actorUri, activity) {
4090 const me = selfActorId(site.slug);
4091 const keys = getOrCreateKeys(site.slug);
4092 const payload = { '@context': AP_CONTEXT, ...activity };
4093 // Co-location is a TRANSPORT detail, never a decision path (Robins regel,
4094 // 29-7). An inbox on this machine is not reachable over HTTP from this
4095 // machine, and should not be, so a local recipient is handed the activity
4096 // straight into the same inbox handler the wire would reach. Everything
4097 // above this line therefore behaves as if every Klonkt were remote: one code
4098 // path, exercised by every deployment, including the checks. Two bugs in one
4099 // day came from having a second, local-only path that hid a broken remote
4100 // one.
4101 const localSlug = localSlugOf(actorUri);
4102 if (localSlug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(localSlug)) {
4103 const host = (() => { try { return new URL(selfActorId(site.slug)).host; } catch { return ''; } })();
4104 const req = { body: payload, ip: 'loopback', protocol: 'https', get: () => host, headers: {} };
4105 // The signer is us, and we say so: the actor-versus-signer check runs
4106 // exactly as it does over the wire, so a mismatch fails here too.
4107 const status = await handleInbox(req, localSlug, { id: me }).catch(() => 500);
4108 const ok = status >= 200 && status < 300;
4109 console.log('[AP]', activity.type, ok ? 'delivered (loopback) →' : `got ${status} (loopback) from`, actorUri);
4110 return { delivered: ok, inbox: `${actorUri}/inbox`, loopback: true, status };
4111 }
4112 const a = await fetchActor(actorUri).catch(() => null);
4113 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
4114 if (!inbox) {
4115 console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
4116 return { delivered: false, inbox: null };
4117 }
4118 try {
4119 const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
4120 if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
4121 console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
4122 } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
4123 enqueueDelivery(site.slug, inbox, payload);
4124 return { delivered: true, inbox }; // queued: the retry worker gets it there
4125}
4126Guardianship.wireDelivery({
4127 actorId, fetchActor, localActor, deliverTo: deliverToActor, deriveHandle, escHtml, linkUrls, linkHashtags,
4128 getOutboxRow: (id) => iStmts().getO.get(id),
4129 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
4130 // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
4131 // een antwoord uit Berichten door precies één poort gaat.
4132 sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
4133 htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
4134});
4135/**
4136 * The actor document of a site WE host, read straight from the database.
4137 * Same shape fetchActor returns for anyone else, plus `local: true` so the
4138 * caller can take the loopback instead of a POST to our own hostname.
4139 * Null for an actor we do not host: that one really is fetched.
4140 */
4141function localActor(actorUri) {
4142 const slug = localSlugOf(actorUri);
4143 if (!slug) return null;
4144 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4145 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
4146 if (!site) return null;
4147 // primary_slug is what buildActor uses to pick '/' over '/user/<slug>'; the
4148 // actor route sets it the same way before building.
4149 const p = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get();
4150 try { return { ...buildActor(base, { ...site, primary_slug: p && p.slug }), local: true }; } catch { return null; }
4151}
4152// Which local site (if any) hosts this actor URI — used by the handshake to
4153// apply the local side of a commit and to derive a ward's existing guardians.
4154export function localSlugOf(actorUri) {
4155 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4156 if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
4157 const slug = slugFromActorUrl(actorUri);
4158 if (!slug) return null;
4159 try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
4160 catch { return null; }
4161}
4162Guardianship.wireHandshake({
4163 selfId: selfActorId,
4164 localSlug: localSlugOf,
4165 deliverTo: deliverToActor,
4166 deriveHandle,
4167 fetchActor,
4168 // Guardian PWA / Berichten push. The kid answers an incoming offer in its
4169 // own Berichten; an existing guardian and a commit land in the PWA.
4170 //
4171 // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
4172 // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
4173 onEvent: (slug, ev) => onGuardianshipEvent(slug, ev),
4174});
4175
4176/**
4177 * Wat er gebeurt als de guardianship-module iets uitzendt.
4178 *
4179 * TWEE VERSCHILLENDE VRAGEN, en ze horen niet dezelfde te zijn: wie maak je
4180 * WAKKER (push kiest bewust een handvol soorten), en wat moet een scherm dat
4181 * openstaat WETEN (alles). Het paneel werd daarom voorheen niet gewekt door de
4182 * tien soorten zonder pushtekst -- die zag je pas bij de volgende tik.
4183 *
4184 * Apart en met een naam, zodat een toets erbij kan. Verstopt in de deps-literal
4185 * was hij onbereikbaar, en een mutatie die het wekken weghaalde bleef groen.
4186 */
4187/**
4188 * Hoeveel er van bewaard blijft. Een logboek dat oneindig groeit is een
4189 * logboek dat niemand meer opent, en dit is geschiedenis, geen archief: wat
4190 * ertoe doet staat vooraan.
4191 */
4192export const GUARDIAN_EVENT_KEEP = 200;
4193
4194/**
4195 * Leg de gebeurtenis vast VOOR de melding.
4196 *
4197 * De meldingstabel beslist wie er wakker van wordt, en dat is terecht een korte
4198 * lijst -- maar hij besliste daarmee ook wat er onthouden werd, en dat was niet
4199 * de bedoeling. Elf van de achttien soorten verdwenen spoorloos, met hun inhoud:
4200 * een geweigerd aanbod droeg de REDEN mee tot hier en niet verder, terwijl §4.2
4201 * eist dat de ward en zijn guardians die te horen krijgen.
4202 *
4203 * Vastleggen en melden zijn nu twee dingen. Alles komt in het logboek; alleen
4204 * wat een mens moet wekken gaat ook als push de deur uit.
4205 */
4206export function recordGuardianEvent(slug, ev) {
4207 if (!slug || !ev || !ev.kind) return;
4208 try {
4209 db.prepare('INSERT INTO ap_guardian_events (slug, kind, payload, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)')
4210 .run(slug, String(ev.kind), JSON.stringify(ev));
4211 db.prepare(`DELETE FROM ap_guardian_events WHERE slug = ? AND id NOT IN
4212 (SELECT id FROM ap_guardian_events WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
4213 .run(slug, slug, GUARDIAN_EVENT_KEEP);
4214 } catch { /* een logboek mag nooit de gebeurtenis zelf breken */ }
4215}
4216
4217/** De laatste gebeurtenissen voor dit account, nieuwste eerst. */
4218export function listGuardianEvents(slug, limit = 50) {
4219 try {
4220 return db.prepare('SELECT id, kind, payload, created_at FROM ap_guardian_events WHERE slug = ? ORDER BY id DESC LIMIT ?')
4221 .all(slug, Math.max(1, Math.min(Number(limit) || 50, GUARDIAN_EVENT_KEEP)))
4222 .map((r) => ({ id: r.id, kind: r.kind, created: r.created_at, ...safeJson(r.payload) }));
4223 } catch { return []; }
4224}
4225
4226function safeJson(s) { try { return JSON.parse(s) || {}; } catch { return {}; } }
4227
4228export function onGuardianshipEvent(slug, ev) {
4229 recordGuardianEvent(slug, ev);
4230 wakeGuardian(slug);
4231 const p = guardianEventPush(slug, ev);
4232 if (p) pushEvent(slug, p);
4233 return p;
4234}
4235
4236/**
4237 * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
4238 *
4239 * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
4240 * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
4241 * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
4242 * erbij te halen.
4243 */
4244export function guardianEventPush(slug, ev) {
4245 const L = pushLang(slug);
4246 const texts = {
4247 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward
4248 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward
4249 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
4250 // §3.2: a guardian ended the relation. The ward hears that someone who
4251 // was looking after them has gone; a co-guardian hears they are one fewer.
4252 guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
4253 coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
4254 // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
4255 // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
4256 // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
4257 // geen drempel.
4258 gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'], // jij moet antwoorden
4259 gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'], // er is besloten
4260 }[ev.kind];
4261 if (!texts) return null;
4262 const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
4263 // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
4264 // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
4265 const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
4266 const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
4267 const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
4268 const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
4269 return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
4270}
4271
4272// Van een gated feature naar het woord dat het Guardian-paneel er al voor
4273// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
4274// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
4275const GATE_LABEL = {
4276 'shaer:externalEmbeds': 'guardian.prop_embeds',
4277 'shaer:externalPlayback': 'guardian.prop_play',
4278};
4279const GATE_OUTCOME = {
4280 accepted: 'guardian.prop_st_accepted',
4281 rejected: 'guardian.prop_st_rejected',
4282 expired: 'guardian.prop_st_expired',
4283};
4284
4285// The notification duty of FEP-633c 3.6.2, wired once for every place a
4286// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
4287// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
4288// one-answer rule is worthless to someone who does not know an answer is
4289// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
4290// is the same door this delivery knocks on; both attempts are logged.
4291Guardianship.wireAvailability({
4292 onDormant: (wardSlug, guardianUri) => {
4293 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4294 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
4295 if (!base || !site) return;
4296 const me = selfActorId(wardSlug);
4297 const note = {
4298 id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
4299 type: 'Note', attributedTo: me, to: [guardianUri],
4300 'shaer:dormant': true,
4301 content: '<p>You have been observed dormant as a guardian. Nothing is wrong and nothing is held against you: one answer restores everything (FEP-633c 3.6.2).</p>',
4302 };
4303 deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
4304 .catch(() => { /* retried by the queue */ });
4305 console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
4306 },
4307});
4308
4309// De C2S-inname zijn werktuigen geven (stap 4, shaer-drc). Onderaan, zodat
4310// elke const hierboven al bestaat; een verzoek kan pas na deze evaluatie
4311// binnenkomen, dus de koppeling is altijd eerder dan de eerste aanroep.
4312wireC2S({
4313 proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
4314 postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
4315 followActor, unfollowActor, blockTarget, unblock, deliverDelete,
4316 deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
4317 deliverCreate,
4318});
4319// En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen
4320// sinds stap 6 uit ap-reactions, maar de koppeling blijft HIER lopen -- twee
4321// zustermodules die elkaar importeren zou een kring zijn.
4322wireTimeline({ getReactionsFor });
4323// Het reactiecluster zijn ene werktuig (stap 6): de verhuisgrendel (FEP-7628).
4324wireReactions({ movedLock });
4325// De volgwinkel zijn zes werktuigen (stap 7): de verhuisweigering, de
4326// §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers.
4327wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor });
4328// De peilingen hun vier werktuigen (stap 8): de Update-bezorging voor de
4329// telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
4330wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
4331// De schakelkast (stap 9): de lijst is bewust lang -- hij is de kaart van wat
4332// de inbox aanraakt, en elke naam die eraf gaat is een cluster dat zelf
4333// verhuisd is.
4334wireInbox({
4335 actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
4336 belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
4337 findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
4338 isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
4339 localPostExists, localSlugOf, mediaFromNote, noteVisibility,
4340 postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
4341 resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
4342 storeAuthorEmoji, timelineFields, wakeGuardian,
4343});
4344
4345export default {
4346 movedLock,
4347 // FEP-1580 bronkant. Vergeet je hem hier, dan werpt elke route die hem
4348 // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
4349 // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
4350 isMoveTarget, signedGetJson, signedGetHeaders,
4351 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
4352 deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
4353 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
4354 channelUrls, channelCategory, timelineFields, guessMediaType,
4355 siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection, buildTrackCreate, trackHostPosts,
4356 buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
4357 buildPostTrackCollection, uitgavePost,
4358 buildLibrary, libraryId,
4359 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverObjectDelete, deliverTrackDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
4360 feedCursor, feedChangesSince, waitForFeedChange,
4361 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
4362 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
4363 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
4364 acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
4365 gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
4366 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, previewCard,
4367 autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
4368 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
4369 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
4370 sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
4371 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
4372 noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
4373 getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor, getReplyMessages, onNews, wakeNews,
4374};
Note: See TracBrowser for help on using the repository browser.