source: Klonkt/src/services/ActivityPubService.js@ 61d52ed

main
Last change on this file since 61d52ed was 61d52ed, checked in by Bart <bart@…>, 6 hours ago

Eén note ophalen, voor wie een gesprek naar boven volgt

De thread-route (shaer-tqz) geeft wat ONDER een post hangt. Een lezer die een
antwoord kreeg en de ouder niet, kon die ouder bij een server in secure mode
niet halen: daar telt alleen een ondertekend verzoek, en de sleutel woont hier.
R9999 haalt ouders voortaan op zodra een antwoord binnenkomt, en heeft daar
deze route voor nodig -- zonder anonieme terugval, om dezelfde reden als bij de
thread.

GET /ap/users/:slug/object?uri= -- bearer-only en niets bewaren, dezelfde
weigering-of-storing (404/502) en dezelfde poorten als de thread. Een
geblokkeerde auteur is er niet. Voor een ward met een dichte kring is een note
van buiten de kring een 404 'outside the circle', met shaer:hidden erbij in
plaats van verzwegen.

inReplyTo alleen als de bron hem gaf. De thread vult een ontbrekende inReplyTo
aan met de post waaronder het antwoord in de replies-collectie stond, en daar is
dat bewijs. Voor een losse note is er geen bewijs, en een verzonnen ouder is
erger dan geen.

De vorm van één note (readerNote) en de poorten (readerGates) zijn uit de
thread getild, zodat de twee niet uit elkaar kunnen lopen. De thread-route
gedraagt zich hetzelfde.

Tests: getObject (de ouder van de bron, geen verzonnen ouder voor een wortel,
blokkade, weigering) en de route (zonder bearer, andermans bearer, alleen
https, weigering is 404).

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

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