source: Klonkt/src/services/ActivityPubService.js@ 31680c3

main
Last change on this file since 31680c3 was 76bd53c, checked in by Robin <roboburr@…>, 7 hours ago

Outbox serves the same Note as /ap/notes/:id — no more column list

The hub could not find soundfabrics' hashtags. Fetched on its own
(/ap/notes/:id) a post carries #kawaii; read from the outbox, it does not.
outboxSlice loaded posts with a hand-kept column list, and that list fails
silently: a forgotten column is undefined, buildNote decides without it,
and nothing errors. tags was missing — and so were poll_json (a poll left
the outbox as a plain note), quote_uri/quote_actor (a quote post lost its
quote), cover_alt and language.

This was the fourth time. fan_only once sent a friends-only post out as
public; paid once put the full text of a paid post in the outbox. Each fix
added a name to the list and a warning above it. The note route has always
used SELECT *, so the outbox now does the same: the two paths can no longer
drift apart.

The new test compares the WHOLE object, outbox against the single Note,
rather than a handful of fields — a column added later that the outbox
doesn't carry fails it immediately. Verified it goes red on the old code.

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

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