source: Klonkt/src/services/ActivityPubService.js@ 5d0d3f35

main
Last change on this file since 5d0d3f35 was b1d5f3f, checked in by Bart <bart@…>, 26 hours ago

Identiteitsbewijzen (FEP-c390): een sleutel van elders zegt dat dit account van hem is

Een Ed25519-sleutel die niet op deze server staat -- in Shaer op papier, alleen
ingetypt als hij nodig is -- ondertekent een VerifiableIdentityStatement:
"did:key:... is ook deze actor". Klonkt ondertekent niets. Hij controleert de
verklaring en zet hem onder attachment op de actor, waar iedereen hem zelf
kan nalopen.

Wat dat bewijst: dat dezelfde entiteit achter sleutel en account zit. Verhuist
het account, dan draagt het nieuwe dezelfde did:key, en dat bewijst de
continuiteit ook als de oude server weg of vijandig is -- een Move kan dat niet,
want die moet door de OUDE server getekend worden.

eddsa-jcs-2022, omdat Mitra en streams dat controleren. IdentityProof.js is
puur: JCS (RFC 8785, in JavaScript precies een gesorteerde JSON.stringify),
base58btc, did:key en de proof zelf. De controle volgt c390 regel voor regel,
met een eigen reden per weigering; verificationMethod moet PRECIES de subject
zijn, zonder het #-fragment dat de W3C-voorbeelden gebruiken.

Getoetst tegen de W3C-testvectoren (vc-di-eddsa B.3): dezelfde did:key, dezelfde
canonieke vorm, dezelfde hashes en byte voor byte dezelfde handtekening. Een
implementatie die het alleen met zichzelf eens is, bewijst niets.

POST /ap/users/:slug/identityProofs (alleen de eigenaar) controleert en
publiceert, één per did:key, hoogstens drie; DELETE ?subject= haalt hem weg.
Beide sturen een Update van de actor naar de volgers. De actor noemt het
eindpunt als endpoints.shaer:identityProofs. Elke verklaring draagt haar eigen
@context, zodat de actor er geen bij hoeft -- did/v1 en data-integrity/v1 hebben
beschermde termen die op actorniveau met onze inline context konden botsen.

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

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