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

Last change on this file since b4aaadd was 495b019, checked in by Bart <bart@…>, 3 days ago

Lay-outthema's: een pakket, de beheerder kiest, en de zijbalk als eerste

Een lay-out is nu een pakket: een map in src/themes/ met een theme.json (id,
naam, versie, contract, stylesheet) en de stylesheet zelf. De zijbalk is het
eerste ingebouwde thema; een thema van buiten installeren komt later en gaat
door dezelfde lader, zodat er één vorm is.

Dezelfde markup, een ander stelsel: een thema verandert geen template. Het
contract (1) is de afspraak over de ids en klassen waar het op mag bouwen; een
thema op een ander contract valt terug op de gewone kop in plaats van half te
werken. Elke regel staat onder html[data-layout="<id>"], en een toets bewaakt
dat, zodat een thema niets raakt zolang het niet gekozen is.

WIE KIEST: de beheerder, in het sitebeheer (sites.layout). Een bezoeker niet
-- de server zet data-layout zelf, het bezoekersscript met localStorage is
weg, en ?layout= werkt alleen als voorproefje voor wie de site beheert. Het
beheer houdt altijd de gewone kop, ook bij navigeren via htmx (de lay-out reist
mee in pcmsNav): een kapot thema mag je nooit buitensluiten van de instelling
waarmee je het uitzet.

Geserveerd op /themes/<id>/<versie>/<bestand>, met de versie in het pad als
cache-buster; alleen wat het pakket zelf noemt komt eruit. De actor noemt het
gekozen thema als shaer:theme, zodat Shaer het van zijn eigen site kan halen
in plaats van er zelf een te kiezen.

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

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