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

main
Last change on this file since f3a58a4 was 72ec6a4, checked in by Robin <roboburr@…>, 6 weeks ago

Hub-modus en guardian-lite eruit

Robins besluit (31-7): een instance is een eigenaar. Twee dingen weg.

HUB-MODUS was al dood: getTenancy() gaf sinds 24-6 hardcoded 'solo'
terug, dus elke tenancy === 'hub'-tak was onbereikbaar. Nu ook echt
verwijderd: getTenancy/setTenancy zelf, de /user/:slug-routing in
resolveSite, de hub-takken in admin, zoeken, audio, posts (neighbours
en related over alle sites), download, en de push-prefix. In de views
verdwijnen de hub-tagline, de hub-navigatie, de sites- en
users-tabellen (die kwamen alleen in hub-modus gevuld en verwezen nu
naar locals die niemand meer meegeeft), de eigenaar-toewijzing bij een
site, de /user/-slugprefix, het hub-brandblok en de hub-thuisknop.

GUARDIAN-LITE was de laatste multi-user-rest: /guardian/invite gaf een
link waarmee iemand via /guardian/join een echte user plus een site met
guardian_only=1 aanmaakte. Dat zette andermans wachtwoordhash, sessie
en PRIVATE actor-sleutel in jouw database, waardoor een verhuizing of
export nooit netjes kon (shaer-qw6q). Routes, formulier, kolom en
uitnodigingstabel zijn weg. Het guardian-DASHBOARD blijft: dat is
FEP-633c en werkt voor guardians met een eigen Klonkt. Bestaande
installaties houden kolom en tabel ongebruikt; nieuwe krijgen ze niet.

Changed files:
src/services/SettingsService.js

  • getTenancy/setTenancy verwijderd; kop herschreven

src/middleware/site.js, src/middleware/render.js

  • /user/:slug-routing weg; tenancy en hubTitle uit de locals

src/routes/admin.js, admin-settings.js, audio.js, download.js,
src/routes/posts.js, search.js

  • hub-takken en hub-queries weg; postNeighbors zonder isHub

src/services/ActivityPubService.js

  • pushPrefix is nu gewoon ; getTenancy-import weg

src/routes/guardian.js

  • /invite en /join verwijderd (dashboard blijft), imports opgeschoond

src/config/database.js

  • guardian_only-kolom en ap_guardian_invites-tabel niet meer aangemaakt

src/views/pages/admin.ejs, admin-users.ejs, admin-site-edit.ejs,
src/views/pages/guardian.ejs, partials/topnav.ejs, chrome.ejs, bottom-tab.ejs

  • alle hub-takken en de uitnodigingsknop weg

remarks: 320 regels weg, 63 erbij. Suite 372 groen; alle 85 templates
compileren; en met een wegwerp-kopie van de database daadwerkelijk
gedraaid en ingelogd: /, /admin, /admin/users, /admin/sites,
/admin/settings, /admin/sites/demo/edit, /admin/media en /guardian
geven alle 200 zonder fouten in het log, en /guardian/invite en
/guardian/join geven nu 404.

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

  • Property mode set to 100644
File size: 256.0 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import fs from 'fs';
20import path from 'path';
21import dns from 'dns';
22import net from 'net';
23import db from '../config/database.js';
24import HtmlSanitizerService from './HtmlSanitizerService.js';
25import AudioEmbedService from './AudioEmbedService.js';
26import EmbedResolver from './EmbedResolver.js';
27import Push from './PushService.js';
28import { t as i18nT } from './i18n.js';
29import Blocklist from './BlocklistService.js';
30import * as Guardianship from './guardianship/index.js';
31
32const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
33// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
34// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
35// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
36// This is the same context shape Mastodon publishes, so Mastodon sees no change.
37const AP_CONTEXT = [
38 'https://www.w3.org/ns/activitystreams',
39 'https://w3id.org/security/v1',
40 {
41 toot: 'http://joinmastodon.org/ns#',
42 schema: 'http://schema.org#',
43 sensitive: 'as:sensitive',
44 Hashtag: 'as:Hashtag',
45 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
46 discoverable: 'toot:discoverable',
47 // FEP-7628 (account moves): same term declaration Mastodon ships.
48 alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
49 movedTo: { '@id': 'as:movedTo', '@type': '@id' },
50 featured: { '@id': 'toot:featured', '@type': '@id' },
51 PropertyValue: 'schema:PropertyValue',
52 value: 'schema:value',
53 embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
54 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
55 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
56 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
57 votersCount: 'toot:votersCount',
58 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
59 // module (src/services/guardianship/).
60 ...Guardianship.SHAER_CONTEXT,
61 },
62];
63
64// Short random suffix so two activity ids minted in the same millisecond (e.g.
65// parallel saves) don't collide and get deduped by a receiver.
66const rid = () => crypto.randomBytes(4).toString('hex');
67
68// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
69// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
70const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
71
72// ── SSRF guard for outbound fetches ───────────────────────────────
73// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
74// every outbound fetch must refuse hosts that resolve to private/loopback ranges
75// (cloud metadata, internal services) — on the initial host AND each redirect hop.
76function isBlockedIp(ip) {
77 if (!ip) return true;
78 const v = net.isIP(ip);
79 if (v === 4) {
80 const o = ip.split('.').map(Number);
81 return o[0] === 127 || o[0] === 10 || o[0] === 0
82 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
83 || (o[0] === 192 && o[1] === 168)
84 || (o[0] === 169 && o[1] === 254)
85 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
86 }
87 if (v === 6) {
88 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
89 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
90 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
91 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
92 }
93 return true; // not an IP literal we recognise → refuse
94}
95async function assertPublicHost(hostname) {
96 if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
97 const addrs = await dns.promises.lookup(hostname, { all: true });
98 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
99}
100// One honest name on ALL outbound federation traffic (Robins vraag, 31-7):
101// safeFetch went out with the bare Node default before, and polite fediverse
102// citizens say who they are (some instances even refuse anonymous UAs). A
103// caller-provided User-Agent (the EmbedResolver) still wins.
104let _uaVer = '1.0';
105try { _uaVer = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url))).version || _uaVer; } catch { /* keep default */ }
106const KLONKT_UA = `Klonkt/${_uaVer} (+https://klonkt.com)`;
107
108export async function safeFetch(url, opts = {}, maxRedirects = 3) {
109 let target = url;
110 for (let hop = 0; ; hop++) {
111 const u = new URL(target); // throws on malformed → caller's catch
112 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
113 await assertPublicHost(u.hostname);
114 const r = await fetch(target, {
115 ...opts,
116 headers: { 'User-Agent': KLONKT_UA, ...(opts.headers || {}) },
117 redirect: 'manual',
118 signal: AbortSignal.timeout(8000),
119 });
120 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
121 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
122 return r;
123 }
124}
125const MAX_OUTBOX = 20;
126// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
127// (square) player card. Bump this whenever the twitter:player card dimensions change.
128const FEDI_CARD_VER = '2';
129
130// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
131// Prepared lazily (NOT at module load) — the ap_keys table is created in
132// initializeDatabase(), which runs after this module is imported.
133let _sel, _ins;
134function keyStmts() {
135 if (!_sel) {
136 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
137 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
138 }
139 return { sel: _sel, ins: _ins };
140}
141
142export function getOrCreateKeys(slug) {
143 const { sel, ins } = keyStmts();
144 const row = sel.get(slug);
145 if (row) return row;
146 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
147 modulusLength: 2048,
148 publicKeyEncoding: { type: 'spki', format: 'pem' },
149 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
150 });
151 ins.run(slug, publicKey, privateKey);
152 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
153}
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';
164export function sendAP(res, obj, cacheControl) {
165 res.type(AP_CONTENT_TYPE);
166 // A per-caller (e.g. guardian-widened) view must not be publicly cached.
167 res.set('Cache-Control', cacheControl || 'public, max-age=120');
168 res.send(JSON.stringify(obj));
169}
170
171// ── document builders ─────────────────────────────────────────────
172export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
173export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
174
175export function buildActor(base, site) {
176 const id = actorId(base, site.slug);
177 const keys = getOrCreateKeys(site.slug);
178 // FEP-633c §5.3: a ward's follows are gated (guardians approve), so the actor
179 // MUST advertise manuallyApprovesFollowers:true — otherwise a follower's server
180 // (Mastodon) assumes auto-accept and shows "Following" while we hold it pending.
181 const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
182 const actor = {
183 '@context': AP_CONTEXT,
184 id,
185 type: 'Person',
186 preferredUsername: site.slug,
187 name: site.title || site.slug,
188 summary: site.tagline || site.description || '',
189 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
190 manuallyApprovesFollowers: isWard,
191 discoverable: true,
192 inbox: `${id}/inbox`,
193 outbox: `${id}/outbox`,
194 followers: `${id}/followers`,
195 following: `${id}/following`,
196 featured: `${id}/featured`,
197 // AP §5.6: the private blocked collection (owner-only GET). The server
198 // list is the source of truth for Shaer's "in Orbit"; clients keep no
199 // separate state.
200 blocked: `${id}/blocked`,
201 // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
202 // (guardianship module owns these).
203 ...Guardianship.guardianshipActorProps(id, site.slug),
204 // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
205 // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
206 // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
207 endpoints: {
208 sharedInbox: `${base}/ap/inbox`,
209 oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
210 oauthTokenEndpoint: `${base}/oauth/token`,
211 uploadMedia: `${id}/uploadMedia`,
212 },
213 publicKey: {
214 id: `${id}#main-key`,
215 owner: id,
216 publicKeyPem: keys.public_pem,
217 },
218 };
219 if (site.profile_photo) {
220 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
221 actor.icon = { type: 'Image', url: u };
222 }
223 // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
224 if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
225 // FEP-7628: former identities this account claims. The OLD server checks for
226 // exactly this back-reference before it will move followers here, so the
227 // list must be on the public actor, not tucked away in settings.
228 try {
229 const aka = JSON.parse(site.ap_aliases || '[]');
230 if (Array.isArray(aka)) {
231 const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
232 if (clean.length) actor.alsoKnownAs = clean;
233 }
234 } catch { /* skip malformed ap_aliases */ }
235 // FEP-7628 slice 3: this account moved. The old actor stays online AS A
236 // SIGNPOST — that is the whole point of keeping it: whoever missed the Move
237 // activity (offline server, later visitor) still learns where we went by
238 // fetching us. Per the FEP the moved actor "should be considered inactive",
239 // and publishers should stop delivering here.
240 if (site.moved_to && /^https?:\/\//i.test(String(site.moved_to))) actor.movedTo = String(site.moved_to);
241 // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
242 // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
243 try {
244 const links = JSON.parse(site.profile_links || '[]');
245 if (Array.isArray(links) && links.length) {
246 const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
247 const rows = links
248 .filter((l) => l && l.url && /^https?:/i.test(l.url))
249 .map((l) => ({
250 type: 'PropertyValue',
251 name: esc(l.platform || 'Link'),
252 value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
253 }));
254 if (rows.length) actor.attachment = rows;
255 }
256 } catch { /* skip malformed profile_links */ }
257 return actor;
258}
259
260// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
261// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
262// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
263export function hasPlayableAudio(content, siteId) {
264 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
265 try {
266 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; }
267 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; }
268 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; }
269 } catch { /* non-fatal */ }
270 return false;
271}
272
273// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
274export function buildNote(base, site, post, opts = {}) {
275 // Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
276 // (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
277 // machinery, addressed to the parent actor + thread. This early branch keeps that output
278 // byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
279 // this branch collapses and replies flow through the full post pipeline below. `post` here
280 // is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
281 if (opts.isReply) {
282 const meR = actorId(base, site.slug);
283 // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
284 // attachment array with absolute URLs and the matching object type.
285 let replyAtt;
286 try {
287 const list = post.attachments ? JSON.parse(post.attachments) : [];
288 if (Array.isArray(list) && list.length) {
289 replyAtt = list.map((a) => ({
290 type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
291 mediaType: a.mediaType,
292 url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
293 name: a.name || undefined,
294 }));
295 }
296 } catch { /* malformed attachments never block the Note */ }
297 return {
298 id: noteId(base, post.id),
299 type: 'Note',
300 attributedTo: meR,
301 inReplyTo: post.in_reply_to || undefined,
302 content: post.content,
303 // Reply language (rich replies): the AS2 language map next to `content`.
304 contentMap: post.language ? { [post.language]: post.content } : undefined,
305 attachment: replyAtt,
306 url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
307 published: toISO(post.created_at),
308 // A direct note (private mention, shaer-tqc) addresses ONLY its
309 // recipients: no Public anywhere, so it cannot be boosted and never
310 // shows in public timelines (the Mastodon DM model).
311 to: post.visibility === 'direct'
312 ? (JSON.parse(post.to_actors || '[]'))
313 : (post.to_actor ? [post.to_actor] : [PUBLIC]),
314 // Followers-only reply ('friends', shaer detail-view Reply): the parent
315 // author (in `to`) + our followers, but NO Public — it does not federate
316 // into open discovery. Default reply stays quiet-public (Public in cc).
317 cc: post.visibility === 'direct' ? []
318 : post.visibility === 'friends' ? [`${meR}/followers`]
319 : [PUBLIC, `${meR}/followers`],
320 // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
321 ...Guardianship.helpRequestProps(post),
322 ...Guardianship.waveProps(post),
323 ...Guardianship.awayProps(post),
324 // FEP-633c §2.2: object hint that the author is a ward.
325 ...Guardianship.hasGuardiansProps(site.slug),
326 tag: [
327 ...mentionTags(post.content),
328 ...hashtagTags(base, post.content),
329 ],
330 };
331 }
332 const id = noteId(base, post.id);
333 const aId = actorId(base, site.slug);
334 const human = `${base}/${encodeURIComponent(post.slug)}`;
335 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
336 // first line) — the standard blog→fediverse convention. post.content is
337 // already sanitized HTML; the title is plain text, so escape it.
338 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
339 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
340
341 // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
342 // content, so nothing leaks past the paywall. No media attachments either.
343 if (post.paid) {
344 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
345 const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
346 const rawTeaser = String(post.excerpt || '').trim()
347 || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
348 return {
349 '@context': AP_CONTEXT,
350 id,
351 type: 'Note',
352 attributedTo: aId,
353 content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
354 url: human,
355 published: toISO(post.published_at || post.created_at || Date.now()),
356 to: [PUBLIC],
357 cc: [`${aId}/followers`],
358 tag: [...hashtagTags(base, post.content)],
359 replies: `${id}/replies`,
360 ...Guardianship.hasGuardiansProps(site.slug),
361 };
362 }
363
364 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
365 // the cover + any inline <img>, make absolute, then strip <img> from the content
366 // to avoid duplicate rendering on clients that DO keep them.
367 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
368 const mediaType = (u) => {
369 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
370 return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' })[(e || '').toLowerCase()] || 'image/jpeg';
371 };
372 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
373 const playable = hasPlayableAudio(post.content || '', site && site.id);
374 // A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
375 // Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
376 // card, never both — so when the post has an embed link we skip the image attachments so the
377 // card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
378 const hasEmbed = (() => {
379 const c = post.content || '';
380 if (/\[\[embed:/i.test(c)) return true;
381 for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
382 return false;
383 })();
384 // Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
385 // so we federate them — Mastodon cards the first (its player), the rest show as clickable links
386 // — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
387 const trackEmbedLinks = (() => {
388 if (playable) return [];
389 const out = [];
390 try {
391 for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
392 const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
393 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);
394 }
395 } catch { /* non-fatal */ }
396 return [...new Set(out)].slice(0, 6);
397 })();
398 const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
399 const urls = [];
400 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
401 // the player CARD (twitter:player) instead of the cover — media attachment and
402 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
403 // keeps its cover (no player card to show).
404 // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
405 // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
406 // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
407 // Media a C2S composer attached (shaer-j3uh): federate with their REAL
408 // mediaType, because the extension map below knows no audio and would call
409 // an m4a an Image. Pushed BEFORE the covers: a C2S video doubles as the
410 // cover video, and the URL-dedupe keeps the FIRST entry, which must be the
411 // one that knows its type and poster. Images also live inline in the
412 // content, so the dedupe keeps those single too.
413 try {
414 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
415 if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType, poster: a.poster ? abs(a.poster) : null });
416 }
417 } catch { /* malformed never blocks the Note */ }
418 if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
419 else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
420 let body = post.content || '';
421 // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
422 // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
423 // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
424 // as the attachment description.
425 if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
426 const tag = m[0];
427 const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
428 if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
429 const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
430 urls.push({ url: abs(src), name: alt });
431 }
432 body = body.replace(/<img\b[^>]*>/gi, '');
433 // Video and audio tags leave the federated content the same way (30-7):
434 // they ride as AS2 attachments (c2s_attachments), and the tag itself
435 // carries a RELATIVE /media src that is dead everywhere but our own web.
436 // Leaving it in showed every remote reader a broken player above the
437 // working one. The web keeps its tags: this strip is federation-only.
438 body = body.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, '').replace(/<video\b[^>]*\/?>/gi, '');
439 body = body.replace(/<audio\b[^>]*>[\s\S]*?<\/audio>/gi, '').replace(/<audio\b[^>]*\/?>/gi, '');
440 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
441 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
442 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
443 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
444 // a click-through to the protected player (discovery without leaking the file).
445 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
446 const audioLabels = [];
447 try {
448 for (const m of body.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT title FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.title) audioLabels.push(r.title); }
449 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
450 } catch { /* non-fatal */ }
451 // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
452 // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
453 // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
454 // later body mutation can't affect it.
455 const openAudio = [];
456 if (hadAudio) {
457 const seenA = new Set();
458 const addRow = (r) => {
459 const fn = r.filename || (r.storage_path || '').split('/').pop();
460 if (!fn || seenA.has(fn)) return; seenA.add(fn);
461 const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
462 // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
463 // Mastodon renders it as the artwork thumbnail on its native audio player.
464 const art = abs(r.cover_url || post.cover_image_url || null);
465 if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
466 openAudio.push(a);
467 };
468 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 ';
469 try {
470 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); }
471 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);
472 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);
473 } catch { /* non-fatal */ }
474 }
475 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
476 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
477 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
478 // of federating the raw shortcode text.
479 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
480 const u = esc(raw.trim().replace(/&amp;/g, '&'));
481 return `<p><a href="${u}">${u}</a></p>`;
482 });
483 if (hadAudio) {
484 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
485 if (trackEmbedLinks.length) {
486 // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
487 // player), the rest render as clickable links — the fediverse-native "embed + links".
488 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong>` : ''}</p>`;
489 for (const u of trackEmbedLinks) { const eu = esc(u); body += `<p><a href="${eu}">${eu}</a></p>`; }
490 } else {
491 // For playable posts, append a version param to the listen-link so Mastodon
492 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
493 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
494 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
495 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
496 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
497 }
498 }
499 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
500 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
501 // so convert newlines to <br> for the federated copy (content already made with
502 // shift+enter uses <br> and has no \n → this is a no-op there).
503 body = body.replace(/\r?\n/g, '<br>');
504 body = linkHashtags(base, body); // link inline #hashtags in the post body too
505 body = linkUrls(body); // bare URLs → clickable links on the federated copy
506 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
507 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
508 // multi-word tags; skip any already present inline in the body.
509 {
510 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
511 const addSeen = new Set();
512 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
513 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
514 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
515 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
516 }
517 const seen = new Set();
518 const attachment = urls.filter((x) => x && x.url)
519 .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
520 .map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback
521 const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
522 const a = { type: ty, mediaType: mt, url: x.url };
523 if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
524 if (x.poster) a.icon = { type: 'Image', url: x.poster }; // the video's still (shaer-zowq)
525 return a; });
526 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
527
528 // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
529 // present when the content was already mention-linked (deliverCreate/Update resolve them
530 // at send time); a plain buildNote (outbox/notes) yields none.
531 const _mentionTags = mentionTags(body);
532 const _mentionCc = _mentionTags.map((t) => t.href);
533
534 const note = {
535 id,
536 type: 'Note',
537 attributedTo: aId,
538 content: titleHtml + body,
539 url: human,
540 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
541 // fan_only = "fans only" → followers-only visibility (delivered to your followers
542 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
543 to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
544 // Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
545 // so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
546 cc: [...new Set([
547 ...(post.ap_visibility === 'quiet' ? [PUBLIC] : []), // quiet public: Public in cc, not to
548 ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
549 ..._mentionCc])],
550 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags],
551 replies: `${id}/replies`,
552 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
553 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
554 sensitive: !!post.nsfw,
555 };
556 // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
557 Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
558 // FEP-044f: this post quotes a fediverse object. Emit it the way the network
559 // actually reads it, and address the quoted author so they get told.
560 applyQuoteProps(note, post.quote_uri, post.quote_actor);
561 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
562 if (attachment.length) note.attachment = attachment;
563 // When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
564 // so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
565 // card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
566 // `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
567 if (post.cover_image_url && noImages) {
568 const cov = abs(post.cover_image_url);
569 if (cov) { note.image = { type: 'Image', mediaType: mediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
570 }
571 // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
572 // (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
573 // getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
574 // standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
575 // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
576 if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
577 // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
578 // language from its key for the timeline language filter + the translate button. Emitted
579 // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
580 if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
581 // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
582 // reuses the note's content/addressing/tags, then swaps the type and strips media.
583 const ownPoll = parseOwnPoll(post.poll_json);
584 if (ownPoll) applyPollToNote(note, post.id, ownPoll);
585 return note;
586}
587
588// All reply note URIs on a local post (inbound fediverse replies + our own
589// outbound replies) — backs the Note's `replies` Collection so remote servers
590// can fetch the whole thread.
591export function getReplyUris(base, postId) {
592 const out = [];
593 try {
594 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);
595 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}`);
596 } catch { /* non-fatal */ }
597 return out;
598}
599
600// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
601export function markNotificationsSeen(slug) {
602 try {
603 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")
604 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
605 } catch { /* non-fatal */ }
606}
607export function countUnseenNotifications(slug) {
608 try {
609 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
610 const seen = row ? Date.parse(row.value) : 0;
611 let n = 0;
612 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
613 return n;
614 } catch { return 0; }
615}
616// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
617// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
618export function notificationsSeenAt(slug) {
619 try {
620 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
621 return row ? (Date.parse(row.value) || 0) : 0;
622 } catch { return 0; }
623}
624
625// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
626// every notification PLUS your own outbound replies ('sent', with edit/delete via their
627// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
628// one grouped item (actors list + count) so activity doesn't drown out conversations.
629export function getMessages(slug, limit, offset) {
630 const off = Math.max(0, offset || 0);
631 const lim = limit || 60;
632 // The stream is grouped (consecutive likes/boosts collapse), so paging is done by
633 // recomputing the whole stream top-down and slicing [off, off+lim] — stable across
634 // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
635 const need = off + lim + 100;
636 const items = getNotifications(slug, need);
637 try {
638 for (const m of listOutbox(slug).slice(0, need)) {
639 items.push({
640 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
641 content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
642 });
643 }
644 } catch { /* ignore */ }
645 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
646 const out = [];
647 for (const it of items) {
648 const prev = out[out.length - 1];
649 if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
650 && prev.post_slug === it.post_slug) {
651 prev.actors = prev.actors || [prev.name || prev.handle || '?'];
652 prev.actors.push(it.name || it.handle || '?');
653 prev.count = (prev.count || 1) + 1;
654 continue;
655 }
656 out.push(it);
657 }
658 return out.slice(off, off + lim);
659}
660
661export function buildCreate(base, site, post) {
662 const note = buildNote(base, site, post);
663 return {
664 '@context': AP_CONTEXT,
665 id: note.id + '#create',
666 type: 'Create',
667 actor: actorId(base, site.slug),
668 published: note.published,
669 to: note.to,
670 cc: note.cc,
671 object: note,
672 };
673}
674
675export function buildOutbox(base, site, posts) {
676 const id = `${actorId(base, site.slug)}/outbox`;
677 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
678 return {
679 '@context': AP_CONTEXT,
680 id,
681 type: 'OrderedCollection',
682 totalItems: items.length,
683 orderedItems: items,
684 };
685}
686
687// Public callers get a count-only collection (privacy). The authenticated
688// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
689// `items`, so their own client can build a friends list.
690export function buildFollowers(base, site, count, items = null) {
691 const id = `${actorId(base, site.slug)}/followers`;
692 return {
693 '@context': AP_CONTEXT,
694 id,
695 type: 'OrderedCollection',
696 totalItems: items ? items.length : (count || 0),
697 orderedItems: items || [], // count-only for the public; full for the owner
698 };
699}
700
701// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
702// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
703export function buildFollowing(base, site, count, items = null) {
704 const id = `${actorId(base, site.slug)}/following`;
705 return {
706 '@context': AP_CONTEXT,
707 id,
708 type: 'OrderedCollection',
709 totalItems: items ? items.length : (count || 0),
710 orderedItems: items || [], // count-only for the public; full for the owner
711 };
712}
713
714// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
715// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
716// rank; embedded as full Notes so a remote server doesn't need extra fetches.
717export function buildFeatured(base, site, posts) {
718 const id = `${actorId(base, site.slug)}/featured`;
719 const items = (posts || []).map((p) => buildNote(base, site, p));
720 return {
721 '@context': AP_CONTEXT,
722 id,
723 type: 'OrderedCollection',
724 totalItems: items.length,
725 orderedItems: items,
726 };
727}
728
729// ── followers store (lazy stmts) ──────────────────────────────────
730let _insF, _updFDisp, _delF, _listF, _cntF;
731function fStmts() {
732 if (!_insF) {
733 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
734 _updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
735 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
736 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
737 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
738 }
739 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
740}
741export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
742
743// Followers with delivery health, for the management list. Never-delivered accounts
744// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
745export function listFollowers(slug) {
746 return db.prepare(
747 `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
748 FROM ap_followers WHERE slug = ?
749 ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
750 ).all(slug);
751}
752// Manually drop a follower after a check (a still-live account would have to re-follow).
753export function removeFollower(slug, id) {
754 const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
755 return info.changes > 0;
756}
757
758// Best cached display for an actor URI, across the caches Klonkt already fills:
759// followers (now with name/icon), following, interactions, timeline, mentions.
760// Falls back to a handle derived from the URI. Display info is not sensitive.
761export function actorDisplay(slug, uri) {
762 const ok = (r) => r && (r.name || r.icon);
763 try {
764 let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
765 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
766 r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
767 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
768 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);
769 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
770 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);
771 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
772 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);
773 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
774 } catch { /* ignore */ }
775 return { name: null, handle: deriveHandle(uri), icon: null };
776}
777
778// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
779// Pure and testable; the route sets the response headers around it.
780export function prefersEnriched(preferHeader) {
781 return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
782}
783
784// AS2 actor reference with display, for the owner C2S followers/following view.
785// preferredUsername = the local part of the handle; name = the set display name.
786export function buildActorRef(slug, uri) {
787 const d = actorDisplay(slug, uri);
788 const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
789 const out = { id: uri, type: 'Person' };
790 if (d.name) out.name = d.name;
791 if (user) out.preferredUsername = user;
792 if (d.icon) out.icon = { type: 'Image', url: d.icon };
793 return out;
794}
795
796// The site's OWN display info in the same shape as `shaer:author` on timeline
797// entries. The owner's app reads its own posts from the outbox, which carried
798// no author info, so every card but your own had a byline (Robins melding,
799// 30-7: geen header van self op eigen posts).
800export function selfAuthor(base, site) {
801 const out = {
802 name: site.title || site.slug,
803 handle: `@${site.slug}@${String(base).replace(/^https?:\/\//, '')}`,
804 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
805 };
806 if (site.profile_photo) {
807 out.icon = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
808 }
809 return out;
810}
811
812// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
813// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
814// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
815// `unreachable` flag (never delivered, or last attempt failed after the last success) so
816// the view can split dead connections into their own section. Powers the Connect page.
817export function listConnections(slug) {
818 const byUri = new Map();
819 for (const f of listFollowing(slug)) {
820 byUri.set(f.actor_uri, {
821 actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
822 icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
823 status: f.status || null, following: true, follower: false,
824 last_delivery_at: null, last_error_at: null, follower_id: null,
825 });
826 }
827 for (const fo of listFollowers(slug)) {
828 const e = byUri.get(fo.actor_uri);
829 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; }
830 else byUri.set(fo.actor_uri, {
831 actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
832 auto_boost: 0, status: null, following: false, follower: true,
833 last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
834 });
835 }
836 return [...byUri.values()].map((e) => {
837 e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
838 e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
839 return e;
840 });
841}
842
843// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
844let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
845// ── moderation tombstones (ap_rejected_objects) ───────────────────
846// A reply the owner removed stays removed: its object URI is tombstoned and
847// checked at ingest AND by the thread-crawler (else thread-filling would
848// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
849// private notes that authorize_interaction can't fetch (401/404).
850let _insRj, _hasRj;
851function rjStmts() {
852 if (!_insRj) {
853 _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
854 _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
855 }
856 return { ins: _insRj, has: _hasRj };
857}
858export function isRejectedObject(uri) {
859 if (!uri) return false;
860 try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
861}
862// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
863// interaction's post must belong to the caller's site.
864export function rejectInteraction(site, interactionId, reason) {
865 if (!site || !site.slug) return { error: 'forbidden' };
866 const row = iStmts().getI.get(interactionId);
867 if (!row) return { error: 'not_found' };
868 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
869 .get(row.post_id, site.slug);
870 if (!owns) return { error: 'forbidden' };
871 if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
872 db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
873 console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
874 return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
875}
876// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
877// from the local copy (works for private notes; no remote fetch needed to target).
878export function interactionReportTarget(site, interactionId) {
879 if (!site || !site.slug) return null;
880 const row = iStmts().getI.get(interactionId);
881 if (!row) return null;
882 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
883 .get(row.post_id, site.slug);
884 if (!owns) return null;
885 return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
886}
887
888// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
889// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
890// followers-collectie zonder Public = followers-only, anders direct (DM). Public
891// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
892export function noteVisibility(o) {
893 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
894 const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
895 const to = arr(o && o.to).map(String);
896 const cc = arr(o && o.cc).map(String);
897 if (to.some(isPub)) return 'public';
898 if (cc.some(isPub)) return 'unlisted';
899 if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
900 return 'direct';
901}
902
903/**
904 * Does this note belong in the home timeline (de Krant)?
905 *
906 * Only if it is a POST. A direct note is addressed to named people, so it is a
907 * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
908 * guardian's wave. Those are stored as mentions instead and surface in
909 * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
910 */
911export function belongsInTimeline(o) {
912 if (!o || !o.id || o.inReplyTo) return false;
913 return noteVisibility(o) !== 'direct';
914}
915
916function iStmts() {
917 if (!_insI) {
918 _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, visibility, emoji_json, actor_emoji_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
919 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
920 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
921 _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');
922 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
923 _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
924 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
925 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
926 }
927 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
928}
929
930export function getInteractionById(id) { return iStmts().getI.get(id); }
931export function setInteractionBoosted(id, on) {
932 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
933}
934export function setInteractionLiked(id, on) {
935 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
936}
937// Your like/boost state on a REMOTE post (interact page toggles).
938export function setMyReaction(slug, uri, kind, on) {
939 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
940 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
941}
942export function getMyReactions(slug, uri) {
943 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
944 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
945}
946
947const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
948// Extract our local post id from a note URL, but only if it's ours (base match).
949function postIdFromNoteUrl(url, base) {
950 const s = String(url || '');
951 if (base && !s.startsWith(base)) return null;
952 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
953 return m ? decodeURIComponent(m[1]) : null;
954}
955function deriveHandle(actorUri) {
956 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
957}
958function actorInfo(doc, actorUri) {
959 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
960 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
961 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
962 const name = (doc && (doc.name || doc.preferredUsername)) || handle;
963 return {
964 name,
965 handle,
966 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
967 icon: safeUrl(icon) || null,
968 // FEP-9098 custom emojis in the display name (":shortcode:"), so the byline
969 // renders them. Only computed when the name actually has a shortcode.
970 emojis: /:[A-Za-z0-9_+-]+:/.test(name) ? actorNameEmojis(doc) : undefined,
971 };
972}
973
974// Map ":shortcode:" → image url from an actor doc's Emoji tags (for a custom-
975// emoji display name). Undefined when there are none.
976function actorNameEmojis(doc) {
977 const arr = doc && Array.isArray(doc.tag) ? doc.tag : (doc && doc.tag ? [doc.tag] : []);
978 const out = {};
979 for (const t of arr) {
980 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Emoji' || typeof t.name !== 'string' || !t.icon) continue;
981 const u = t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url);
982 if (u) out[t.name] = u;
983 }
984 return Object.keys(out).length ? out : undefined;
985}
986
987// Given an inReplyTo note URL, find which local post the thread belongs to + the
988// note being replied to (parent), so a reply-to-a-comment can be nested.
989function findThreadTarget(inReplyTo, base) {
990 if (!inReplyTo) return null;
991 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
992 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
993 if (seg) {
994 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 */ }
995 }
996 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 */ }
997 return null;
998}
999
1000// Drop the leading @mention(s) a federated reply carries (the person being replied to),
1001// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
1002// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
1003export function stripLeadingMentions(html) {
1004 if (!html) return html;
1005 let s = String(html);
1006 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
1007 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
1008 return s;
1009}
1010
1011// View-ready threaded view of a post's fediverse activity (inbound replies +
1012// our outbound replies, nested), plus like/boost counts.
1013export function getInteractions(postId, base, site) {
1014 const s = iStmts();
1015 // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
1016 // public web, so it must NOT render in the public thread. It still reaches the owner
1017 // via notifications (post context + reference included there). Legacy rows without a
1018 // visibility value are treated as public. Likes/boosts stay counted (count-only).
1019 const rows = s.list.all(postId).filter((r) =>
1020 r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
1021 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1022 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
1023 // Our own (outbound) replies show the SITE identity for everyone (not "You").
1024 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
1025 const siteName = (site && (site.title || site.slug)) || '';
1026 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
1027 const siteUrl = baseClean ? `${baseClean}/` : '';
1028 const siteIcon = (site && site.profile_photo) || null;
1029
1030 const nodes = [];
1031 for (const r of rows) {
1032 if (r.kind !== 'reply') continue;
1033 nodes.push({
1034 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
1035 actor_uri: r.actor_uri,
1036 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
1037 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
1038 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (thread render)
1039 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
1040 children: [],
1041 });
1042 }
1043 for (const o of s.listO.all(postId)) {
1044 nodes.push({
1045 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
1046 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
1047 media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
1048 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
1049 children: [],
1050 });
1051 }
1052
1053 const byId = new Map(nodes.map((n) => [n.noteId, n]));
1054 // Conversation partners per node (u02, the reply editor's mentions bar): the
1055 // node's author plus the ancestor authors up the chain. Our own nodes are
1056 // skipped (we do not mention ourselves), deduped by actor, capped at 8.
1057 for (const n of nodes) {
1058 const seen = new Set();
1059 const list = [];
1060 let cur = n, guard = 0;
1061 while (cur && guard++ < 12 && list.length < 8) {
1062 if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
1063 seen.add(cur.actor_uri);
1064 list.push({
1065 uri: cur.actor_uri,
1066 url: cur.actor_url || cur.actor_uri,
1067 handle: cur.actor_handle || deriveHandle(cur.actor_uri),
1068 });
1069 }
1070 cur = cur.parent ? byId.get(cur.parent) : null;
1071 }
1072 n.participants = list;
1073 }
1074 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
1075 const tops = [];
1076 for (const n of nodes) {
1077 if (isTop(n)) { tops.push(n); continue; }
1078 let anc = n, guard = 0;
1079 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
1080 anc.children.push(n);
1081 }
1082 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
1083 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
1084
1085 return {
1086 thread: tops,
1087 likeCount: rows.filter((r) => r.kind === 'like').length,
1088 announceCount: rows.filter((r) => r.kind === 'announce').length,
1089 total: nodes.length,
1090 };
1091}
1092
1093// ── HTTP Signatures + delivery ────────────────────────────────────
1094const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
1095// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
1096// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
1097// an existing site. Deduped.
1098export function localMentionSlugs(tags, base) {
1099 if (!base) return [];
1100 const out = [], seen = new Set();
1101 for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
1102 if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
1103 if (!t.href.startsWith(base + '/ap/users/')) continue;
1104 const slug = slugFromActorUrl(t.href);
1105 if (!slug || seen.has(slug)) continue; seen.add(slug);
1106 try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
1107 }
1108 return out;
1109}
1110
1111// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
1112export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
1113 const body = JSON.stringify(bodyObj);
1114 const u = new URL(inboxUrl);
1115 const date = new Date().toUTCString();
1116 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
1117 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
1118 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
1119 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
1120 const r = await safeFetch(inboxUrl, {
1121 method: 'POST',
1122 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
1123 body,
1124 });
1125 return r.status;
1126}
1127
1128export async function fetchActor(url) {
1129 try {
1130 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
1131 if (!r.ok) return null;
1132 const len = Number(r.headers.get('content-length') || 0);
1133 if (len > 2_000_000) return null; // refuse oversized actor docs
1134 return await r.json();
1135 } catch { return null; }
1136}
1137
1138// ── Delivery queue with retries ───────────────────────────────────
1139// Outbound deliveries are tried immediately; on failure (down server, timeout,
1140// non-2xx) they're queued and retried with backoff so a briefly-offline follower
1141// doesn't silently miss the post. The signing key is NOT stored — the worker
1142// re-derives it from the actor slug at send time.
1143const DELIVERY_MAX_ATTEMPTS = 6;
1144const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
1145let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
1146function deliveryStmts() {
1147 if (!_insDeliv) {
1148 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
1149 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
1150 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
1151 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
1152 }
1153 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
1154}
1155export function enqueueDelivery(slug, inbox, activity) {
1156 if (!slug || !inbox || !activity) return;
1157 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
1158}
1159// Record delivery health per follower so the followers list can flag dead accounts.
1160// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
1161// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
1162let _fDelivOk, _fDelivErr;
1163function markFollowerDelivery(slug, inbox, ok) {
1164 if (!slug || !inbox) return;
1165 try {
1166 if (!_fDelivOk) {
1167 _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
1168 _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
1169 }
1170 (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
1171 } catch { /* health tracking is non-fatal */ }
1172}
1173// Deliver now; queue for retry if it fails.
1174export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
1175 if (!inbox) return;
1176 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
1177 enqueueDelivery(slug, inbox, activity);
1178}
1179let _processingDeliv = false;
1180export async function processDeliveryQueue() {
1181 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
1182 _processingDeliv = true;
1183 try {
1184 let rows;
1185 try { rows = deliveryStmts().due.all(); } catch { return; }
1186 if (!rows || !rows.length) return;
1187 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1188 for (const row of rows) {
1189 let ok = false;
1190 try {
1191 const keys = getOrCreateKeys(row.slug);
1192 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
1193 ok = st >= 200 && st < 300;
1194 } catch { ok = false; }
1195 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
1196 const attempts = row.attempts + 1;
1197 if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
1198 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
1199 // retry uses the 1-min tier instead of skipping it.
1200 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
1201 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
1202 }
1203 } finally { _processingDeliv = false; }
1204}
1205let _delivTimer = null;
1206export function startDeliveryWorker() {
1207 if (_delivTimer) return;
1208 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
1209 if (_delivTimer.unref) _delivTimer.unref();
1210}
1211
1212// Best-effort verification of an incoming signed request. Returns the sender's
1213// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
1214// Max clock skew for the signed Date header (replay window). Generous default to tolerate
1215// federating servers with drifting clocks; an operator can widen it via env.
1216const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
1217export async function verifyRequest(req) {
1218 const sigH = req.headers['signature'];
1219 if (!sigH) return null;
1220 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
1221 if (!p.keyId || !p.signature) return null;
1222 const actor = await fetchActor(p.keyId.split('#')[0]);
1223 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
1224 if (!pem) return null;
1225 const hs = (p.headers || '(request-target) host date').split(/\s+/);
1226 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
1227 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
1228 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
1229 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
1230 // against any. An attacker can't forge a match (no private key), so this only rescues the
1231 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
1232 let _pubHost = null;
1233 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
1234 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
1235 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
1236 const _sig = Buffer.from(p.signature, 'base64');
1237 let ok = false;
1238 for (const _h of _hosts) {
1239 const line = hs.map((x) => x === '(request-target)'
1240 ? `(request-target): ${_target}`
1241 : x === 'host' ? `host: ${_h}`
1242 : `${x}: ${req.headers[x] || ''}`).join('\n');
1243 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
1244 }
1245 // Replay defence: the Date header must be signed and recent. A captured signed request
1246 // replayed later (or with a swapped body) is rejected.
1247 if (ok) {
1248 if (!hs.includes('date')) ok = false;
1249 else {
1250 const t = Date.parse(req.headers['date'] || '');
1251 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
1252 }
1253 }
1254 // Digest is MANDATORY when the request carries a body: without a signed digest the body
1255 // isn't covered by the signature and could be swapped on a replay.
1256 if (ok && req.rawBody && req.rawBody.length) {
1257 if (!hs.includes('digest')) ok = false;
1258 else {
1259 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
1260 if (req.headers['digest'] !== exp) ok = false;
1261 }
1262 }
1263 return ok ? actor : null;
1264}
1265
1266// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
1267// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
1268// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
1269function parsePoll(o) {
1270 if (!o || o.type !== 'Question') return null;
1271 const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
1272 if (!raw || !raw.length) return null;
1273 const options = raw.slice(0, 12).map((opt) => ({
1274 name: String((opt && opt.name) || '').slice(0, 300),
1275 count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
1276 })).filter((x) => x.name);
1277 if (!options.length) return null;
1278 const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
1279 const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
1280 return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
1281}
1282
1283// ── Polls WE host (a local post with a poll) ──────────────────────
1284// Parse the poll definition stored on our own post (posts.poll_json). Counts are
1285// NOT stored here — they're derived from the poll_votes ballots so a re-render always
1286// reflects the authoritative tally.
1287export function parseOwnPoll(pollJson) {
1288 if (!pollJson) return null;
1289 let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
1290 if (!d || !Array.isArray(d.options)) return null;
1291 const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
1292 if (options.length < 2) return null;
1293 const endTime = d.endTime || null;
1294 const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
1295 return { multiple: !!d.multiple, options, endTime, closed };
1296}
1297
1298// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
1299export function pollTally(postId) {
1300 const counts = {}; let voters = 0;
1301 try {
1302 for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
1303 voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
1304 } catch { /* table may not exist yet */ }
1305 return { counts, voters };
1306}
1307
1308// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
1309// Voting is fediverse-only, so this is display-only on the site.
1310export function ownPollView(post) {
1311 const poll = parseOwnPoll(post && post.poll_json);
1312 if (!poll) return null;
1313 const { counts, voters } = pollTally(post.id);
1314 const total = Object.values(counts).reduce((a, b) => a + b, 0);
1315 const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
1316 const options = poll.options.map((o) => {
1317 const count = counts[o.name] || 0;
1318 return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
1319 });
1320 return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
1321}
1322
1323// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
1324// status with either media OR a poll (never both), so a poll federates as content +
1325// options with no media attachment. oneOf = single choice, anyOf = multiple.
1326function applyPollToNote(note, postId, poll) {
1327 const { counts, voters } = pollTally(postId);
1328 const opts = poll.options.map((o) => ({
1329 type: 'Note',
1330 name: o.name,
1331 replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
1332 }));
1333 note.type = 'Question';
1334 note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
1335 if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
1336 // Once closed, Mastodon expects a `closed` timestamp (the effective end).
1337 if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
1338 note.votersCount = voters;
1339 delete note.attachment; // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
1340 // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
1341 // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
1342 // Deleting it stripped the cover off every boosted poll.
1343 return note;
1344}
1345
1346// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
1347// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
1348// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
1349// caller must NOT also store it as a reply), false means "not a poll, fall through".
1350function recordPollBallot(postId, actorUri, rawChoice) {
1351 const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
1352 if (!choice) return { handled: false };
1353 let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
1354 const poll = post && parseOwnPoll(post.poll_json);
1355 if (!poll) return { handled: false }; // not a poll → let the reply logic handle it
1356 if (poll.closed) return { handled: true }; // voting closed → drop
1357 if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
1358 try {
1359 // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
1360 // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
1361 if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
1362 db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
1363 } catch { return { handled: true }; }
1364 schedulePollUpdate(postId);
1365 return { handled: true };
1366}
1367
1368// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
1369// refresh ~15s out; further votes in that window ride the same pending update (which carries
1370// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
1371const _pollUpdTimers = new Map();
1372function schedulePollUpdate(postId) {
1373 if (_pollUpdTimers.has(postId)) return;
1374 const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
1375 if (t.unref) t.unref();
1376 _pollUpdTimers.set(postId, t);
1377}
1378
1379// Push the fresh poll tally (or closed state) to followers as Update(Question).
1380export async function deliverPollUpdate(postId) {
1381 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1382 if (!base || !postId) return;
1383 let post, site;
1384 try {
1385 post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
1386 if (!post || !post.poll_json) return;
1387 site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
1388 } catch { return; }
1389 if (site) await deliverUpdate(site, post);
1390}
1391
1392// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
1393// Fire-and-forget: a notification must never block or break inbox processing.
1394function pushEvent(slug, event) {
1395 try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
1396 wakeNews(slug); // long-poll waiters (Robins verzoek, 31-7): same moments as push
1397}
1398
1399// ── Long-poll on news (Robins verzoek, 31-7) ─────────────────────
1400// The app holds GET /ap/users/:slug/inbox/wait open; the moment anything
1401// push-worthy lands for that account (a message, a reply, a wave, a help
1402// request) every waiter is woken and the app re-reads its feed. In-process
1403// on purpose: one Klonkt is one process, and a waiter is one callback.
1404const _newsWaiters = new Map(); // slug -> Set<cb>
1405export function onNews(slug, cb) {
1406 let set = _newsWaiters.get(slug);
1407 if (!set) { set = new Set(); _newsWaiters.set(slug, set); }
1408 set.add(cb);
1409 return () => { set.delete(cb); if (!set.size) _newsWaiters.delete(slug); };
1410}
1411export function wakeNews(slug) {
1412 const set = _newsWaiters.get(slug);
1413 if (!set || !set.size) return;
1414 const cbs = [...set];
1415 set.clear();
1416 _newsWaiters.delete(slug);
1417 for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } }
1418}
1419// Path prefix for a site's pages. One instance is one owner, so the site
1420// lives at the root; kept as a function because the push URLs read like
1421// `${pushPrefix(slug)}/messages` all over this file.
1422function pushPrefix() { return ''; }
1423// Notification language: the site's content language (fallback: instance default).
1424function pushLang(slug) {
1425 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'; }
1426}
1427// Site slug, target URL and title for a post-scoped notification.
1428function pushPostCtx(postId) {
1429 try {
1430 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);
1431 if (!r) return null;
1432 return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
1433 } catch { return null; }
1434}
1435
1436// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
1437export async function handleInbox(req, slugParam, preVerified = null) {
1438 const act = req.body || {};
1439 const type = act.type;
1440 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
1441 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
1442 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
1443 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1444 // preVerified is the loopback (see deliverToActor): a delivery between two
1445 // actors on THIS instance never crosses a socket, so there is no signature to
1446 // check — but we do know who signed, because we signed it. Handing that in
1447 // keeps everything below identical, including the actor-versus-signer check,
1448 // which is exactly the check that must not be skipped for being local.
1449 const verified = preVerified || await verifyRequest(req).catch(() => null);
1450
1451 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
1452 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
1453 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
1454 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1455 // Blocked actor/domain → silently drop (202, don't reveal the block).
1456 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
1457 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
1458 if (GATED.includes(type)) {
1459 if (!verified || !claimedActor || verified.id !== claimedActor) {
1460 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
1461 return 401;
1462 }
1463 // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
1464 // from an actor that guards someone here restores it to active for those
1465 // wards and cancels any lapse running against it, before the activity is
1466 // even looked at. Signature-gated on purpose: an unverified claim of
1467 // being gran must not wake gran up.
1468 try {
1469 const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
1470 if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
1471 for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
1472 } catch { /* availability is never load-bearing for delivery */ }
1473 }
1474
1475 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
1476 // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
1477 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
1478 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
1479 }
1480
1481 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
1482 // Accept/Reject answers an offer a local guardian sent. Anything the
1483 // guardianship module does not recognize falls through to the old paths.
1484 // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
1485 // must be seen BEFORE the generic Undo branch below, which only knows about
1486 // Follow/Like/Announce and would swallow it with a 202.
1487 if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
1488 // Every LOCAL party this activity is addressed to gets its own copy of the
1489 // handshake (a ward and a co-guardian may both live here). Gather candidate
1490 // local slugs from the inbox owner, the `to` list, and the ward.
1491 const cand = new Set();
1492 if (slugParam) cand.add(slugParam);
1493 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
1494 if (typeof t === 'string') { const s = slugFromActorUrl(t); if (s) cand.add(s); }
1495 }
1496 if (type === 'Offer' || type === 'Undo') {
1497 const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
1498 if (rel) { const s = slugFromActorUrl(rel.ward); if (s) cand.add(s); }
1499 }
1500 let consumed = false;
1501 for (const slug of cand) {
1502 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1503 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
1504 }
1505 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
1506 }
1507
1508 // A moderation report (Flag) about our content — store it for the targeted site's owner
1509 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
1510 if (type === 'Flag') {
1511 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
1512 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
1513 let targetSlug = null;
1514 const noteIds = [];
1515 for (const u of objectUris) {
1516 const s = slugFromActorUrl(u); // one of our actors?
1517 if (s) { targetSlug = targetSlug || s; continue; }
1518 const pid = postIdFromNoteUrl(u, base); // one of our notes?
1519 if (pid) noteIds.push(pid);
1520 }
1521 if (!targetSlug && noteIds.length) {
1522 try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
1523 }
1524 if (!targetSlug) return 202; // not about us / can't tell → drop
1525 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
1526 const ai = actorInfo(verified || null, claimedActor);
1527 try {
1528 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
1529 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
1530 console.log('[AP] report received for', targetSlug, 'from', claimedActor);
1531 } catch { /* ignore */ }
1532 return 202;
1533 }
1534
1535 // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
1536 // a Move often arrives seconds before the new actor's re-Follow wave, and the
1537 // swap below must not race our own outgoing Follow of the target.
1538 if (type === 'Move') {
1539 return handleMoveInbox(act, { verifiedActor: claimedActor });
1540 }
1541
1542 if (type === 'Follow') {
1543 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1544 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
1545 if (!who || !slug) return 400;
1546 const remote = await fetchActor(who);
1547 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
1548 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
1549 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)
1550 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
1551 // follow is gated. A committed guardian's own Follow is auto-accepted
1552 // (it needs no gate); anyone else is held pending for guardian approval.
1553 // Free actors / normal sites have no guardians → fall through, unchanged.
1554 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
1555 if (wardGuardians.length && !wardGuardians.includes(who)) {
1556 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
1557 Guardianship.follows.recordPending(slug, {
1558 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
1559 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
1560 });
1561 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
1562 // gated follow to its guardians for approval. A LOCAL guardian gets a
1563 // push and reads /guardian directly; a REMOTE guardian gets an
1564 // Offer(Follow) delivered so its instance stores a copy (same distributed
1565 // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
1566 const wardActor = actorId(base, slug);
1567 const wardKeys = getOrCreateKeys(slug);
1568 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
1569 // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
1570 // every guardian. The ONLY admissible evidence is a request like this
1571 // one going unanswered; recordRequest itself skips a declared absence.
1572 for (const g of wardGuardians) {
1573 try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
1574 }
1575 for (const g of wardGuardians) {
1576 // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
1577 // ignores the host (an /ap/users/x path on a remote host is someone
1578 // else's actor), so also require our base + an existing local site.
1579 const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
1580 const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
1581 if (isLocal) {
1582 const L = pushLang(gslug);
1583 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
1584 } else {
1585 fetchActor(g).then((ga) => {
1586 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
1587 if (!inbox) return;
1588 const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true };
1589 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
1590 }).catch(() => {});
1591 }
1592 }
1593 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
1594 return 202;
1595 }
1596 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
1597 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
1598 { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
1599 const me = actorId(base, slug);
1600 const keys = getOrCreateKeys(slug);
1601 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
1602 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
1603 // Auto-backfill: send our recent posts as Create so the instance has our history
1604 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
1605 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
1606 // a follower of ours is wasted work (and won't re-populate the new follower's
1607 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
1608 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
1609 const instanceFilled = sharedInbox &&
1610 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
1611 .get(slug, sharedInbox, who);
1612 if (!instanceFilled) {
1613 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
1614 }
1615 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
1616 return 202;
1617 }
1618 if (type === 'Undo' && act.object) {
1619 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1620 const ot = act.object.type;
1621 if (ot === 'Follow') {
1622 const obj = act.object.object;
1623 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
1624 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
1625 return 202;
1626 }
1627 if (ot === 'Like' || ot === 'Announce') {
1628 const tgt = act.object.object;
1629 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
1630 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
1631 return 202;
1632 }
1633 return 202;
1634 }
1635
1636 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1637 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
1638 // Our OWN activity is already stored via ap_outbox: don't store it twice.
1639 // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
1640 // machine". The old reading dropped every activity between two sites on one
1641 // instance, so a note from a co-located guardian to its ward was accepted
1642 // with a 202 and then quietly thrown away: no mention, no away, no help
1643 // request. Neighbours are not us (Robins regel, 29-7: on this machine
1644 // everything behaves as if every Klonkt were somewhere else).
1645 const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
1646
1647 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
1648 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1649 const o = act.object;
1650 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
1651 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
1652 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
1653 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
1654 const seg = postIdFromNoteUrl(o.inReplyTo, base);
1655 if (seg && localPostExists(seg)) {
1656 const rec = recordPollBallot(seg, actorUri, o.name);
1657 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
1658 }
1659 }
1660 const tgt = findThreadTarget(o.inReplyTo, base);
1661 if (tgt && actorUri && !isLocalActor) {
1662 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1663 const html = HtmlSanitizerService.sanitize(o.content || '');
1664 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
1665 iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
1666 console.log('[AP] reply', actorUri, '→', tgt.post_id);
1667 // A reply is a post too: Berichten renders it the way de Krant renders a
1668 // timeline row, so it needs the same media and the same quote/preview card.
1669 {
1670 const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
1671 const key = ['reply', tgt.post_id, actorUri, o.id || ''];
1672 const mj = mediaFromNote(o);
1673 if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
1674 resolveCard(o).then((c) => {
1675 if (!c) return;
1676 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
1677 try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
1678 }).catch(() => { /* best-effort */ });
1679 }
1680 {
1681 // Private (followers/direct) replies push as a DM ping WITHOUT content
1682 // (the push service should never carry private text, design decision);
1683 // public replies carry a short snippet.
1684 const ctx = pushPostCtx(tgt.post_id);
1685 const vis = noteVisibility(o);
1686 const priv = vis === 'direct' || vis === 'followers';
1687 if (ctx) {
1688 const L = pushLang(ctx.site);
1689 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1690 if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
1691 else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
1692 }
1693 }
1694 return 202;
1695 }
1696 // Home timeline (client): a top-level post from an account we follow.
1697 if (actorUri && !isLocalActor && belongsInTimeline(o)) {
1698 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
1699 if (subs.length) {
1700 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1701 const html = HtmlSanitizerService.sanitize(o.content || '');
1702 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1703 // Fallback cover: a Note's `image` (set when the attachment was suppressed
1704 // for a player-card post, e.g. hosted-audio posts).
1705 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
1706 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
1707 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
1708 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
1709 }
1710 const media = JSON.stringify(_atts);
1711 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
1712 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
1713 // incoming posts to the fediverse — that flooded followers. Boosting to the
1714 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
1715 // the timeline).
1716 for (const s of subs) {
1717 tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
1718 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
1719 if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
1720 // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
1721 { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
1722 storeAuthorEmoji(o.id, s.slug, ai); // custom-emoji display name for the byline
1723
1724 // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
1725 { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
1726 if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
1727 }
1728 // FEP-044f embedded quote card: resolve the quoted post out of band so
1729 // the inbox response is not blocked on a remote fetch. Best-effort.
1730 if (quoteHrefOf(o)) {
1731 const slugs = subs.map((s) => s.slug);
1732 resolveQuote(o).then((qj) => {
1733 if (!qj) return;
1734 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
1735 }).catch(() => { /* best-effort */ });
1736 } else {
1737 // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
1738 // thumbnail-only. Also out of band, and stored for everyone; the gate
1739 // that decides who may SEE it is applied at serve time (§5.3-style
1740 // gated feature, see the inbox read).
1741 const slugs = subs.map((s) => s.slug);
1742 resolveExternalEmbed(o.content).then((ej) => {
1743 if (!ej) return;
1744 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
1745 }).catch(() => { /* best-effort */ });
1746 }
1747 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
1748 }
1749 }
1750 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
1751 // above): store a mention notification for each of our actors named in the Mention tags.
1752 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
1753 // someone else's actor, not ours.
1754 if (actorUri && !isLocalActor && o.id) {
1755 const slugs = localMentionSlugs(o.tag, base);
1756 if (slugs.length) {
1757 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1758 const html = HtmlSanitizerService.sanitize(o.content || '');
1759 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
1760 // flag is stored so the Guardian PWA's message centre can list it.
1761 const help = Guardianship.isHelpRequest(o);
1762 const wave = Guardianship.isWave(o);
1763 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only
1764 // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
1765 // same direct note the mention below stores (so the kid also reads it
1766 // as an ordinary message). Recorded only from an actual guardian of
1767 // the addressed ward, and only with an end: an absence without an end
1768 // is logged and dropped, never guessed.
1769 if (Guardianship.availability.isAway(o)) {
1770 const until = Guardianship.availability.parseEndTime(o.endTime);
1771 for (const slug of slugs) {
1772 const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
1773 if (!isG) continue;
1774 if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
1775 Guardianship.availability.declareAway(slug, actorUri, until);
1776 console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
1777 }
1778 }
1779 for (const slug of slugs) {
1780 try {
1781 const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
1782 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
1783 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
1784 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
1785 if (r.changes) {
1786 // The quote / link-preview card resolves out of band (a remote
1787 // fetch), exactly as it does for a timeline post, so the inbox
1788 // answer is never blocked on it.
1789 resolveCard(o).then((c) => {
1790 if (!c) return;
1791 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
1792 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
1793 }).catch(() => { /* best-effort */ });
1794 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
1795 const vis = noteVisibility(o);
1796 const priv = vis === 'direct' || vis === 'followers';
1797 const L = pushLang(slug);
1798 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1799 // Same privacy rule as replies: private mentions push without content.
1800 // A help request pushes as its own alert type, aimed at the
1801 // Guardian PWA's message centre.
1802 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
1803 else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
1804 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
1805 }
1806 } catch { /* ignore */ }
1807 }
1808 }
1809 }
1810 return 202;
1811 }
1812 // A remote post we cached was edited upstream → refresh our cached copy. This is the
1813 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
1814 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
1815 // edit A's note (the signature gate guarantees claimedActor == the verified signer).
1816 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1817 const o = act.object;
1818 if (o.id && claimedActor) {
1819 const html = HtmlSanitizerService.sanitize(o.content || '');
1820 const media = mediaFromNote(o);
1821 try {
1822 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
1823 // rename keeps the same AP id but changes the human url, so without this the cached
1824 // post would keep linking to the old, now-dead URL.
1825 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
1826 .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
1827 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
1828 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
1829 // site keeps its own `voted` state while the counts/closed update to the new totals.
1830 const poll = parsePoll(o);
1831 if (poll) {
1832 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
1833 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
1834 for (const rw of rows) {
1835 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
1836 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
1837 }
1838 }
1839 } catch { /* ignore */ }
1840 // If this note is a cached fediverse reply on one of our posts, refresh its text too.
1841 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
1842 }
1843 return 202;
1844 }
1845 if (type === 'Like' || type === 'Announce') {
1846 const tgt = act.object;
1847 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
1848 const pid = postIdFromNoteUrl(objUrl, base);
1849 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
1850 // A boost/like of a non-public post is dropped, not stored: nobody
1851 // outside the audience should even hold it (shaer-tqc hardening).
1852 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
1853 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
1854 console.log('[AP] dropped', type, 'on non-public post', pid);
1855 return;
1856 }
1857 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1858 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
1859 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
1860 {
1861 const ctx = pushPostCtx(pid);
1862 if (ctx) {
1863 const L = pushLang(ctx.site);
1864 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1865 if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
1866 else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
1867 }
1868 }
1869 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
1870 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
1871 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
1872 // re-announcing an incoming Announce would cascade boosts across the network).
1873 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
1874 if (subs.length) {
1875 const bn = await fetchNoteAP(objUrl);
1876 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
1877 const origUri = actorUriOf(bn.attributedTo);
1878 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
1879 // author is blocked — otherwise a block is bypassed via someone else's boost.
1880 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
1881 const oai = actorInfo(await resolveActor(origUri), origUri);
1882 const html = HtmlSanitizerService.sanitize(bn.content || '');
1883 const media = mediaFromNote(bn);
1884 const booster = actorInfo(await resolveActor(actorUri), actorUri);
1885 for (const s of subs) {
1886 // published = now → the boost shows as fresh activity at the top (Mastodon shows
1887 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
1888 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
1889 let inserted = false;
1890 try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, bn.summary || null); inserted = r.changes > 0; } catch { /* ignore */ }
1891 if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
1892 storeAuthorEmoji(bn.id, s.slug, oai); // custom-emoji display name for the byline
1893 // A boost carries the same renderable tags as a Create: capture the
1894 // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
1895 // 044f) so boosted posts render like any other, not as raw shortcodes.
1896 { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }
1897 { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }
1898 }
1899 // FEP-044f: resolve the embedded quote card for a boosted post too
1900 // (out of band, best-effort, so it does not block the inbox response).
1901 if (quoteHrefOf(bn)) {
1902 const slugs = subs.map((s) => s.slug);
1903 resolveQuote(bn).then((qj) => {
1904 if (!qj) return;
1905 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }
1906 }).catch(() => { /* best-effort */ });
1907 }
1908 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
1909 }
1910 }
1911 }
1912 return 202;
1913 }
1914 if (type === 'Delete') {
1915 // A remote note was deleted upstream → drop it from replies AND the timeline.
1916 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
1917 // signature gate guarantees claimedActor == the verified signer here).
1918 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1919 if (oid && claimedActor) {
1920 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1921 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1922 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
1923 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
1924 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
1925 // to A's posts).
1926 try {
1927 let sameHost = false;
1928 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
1929 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
1930 } catch { /* ignore */ }
1931 }
1932 return 202;
1933 }
1934 // Accept/Reject of a Follow WE sent (client side).
1935 if (type === 'Accept' && act.object) {
1936 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1937 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
1938 console.log('[AP] follow accepted', actorUri);
1939 // The moment a friendship exists is the moment the history comes along
1940 // (Robins besluit, 30-7): delivery cannot reach into the past, so the
1941 // fresh follower pulls the outbox, signed, and the other side now serves
1942 // the friends-only posts too.
1943 if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
1944 return 202;
1945 }
1946 if (type === 'Reject' && act.object) {
1947 const who = actorUri;
1948 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
1949 return 202;
1950 }
1951
1952 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
1953 return 202;
1954}
1955
1956// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
1957// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
1958export async function deliverCreate(site, post) {
1959 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1960 if (!base || !site || !site.slug) return;
1961 // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
1962 // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
1963 const mres = await resolveMentionsInText(base, post.content || '');
1964 let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1965 // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
1966 // and remember it on the post, so buildNote (sync, also used by the outbox)
1967 // never has to fetch. The quoted author's inbox joins the delivery set: that
1968 // IS the notification.
1969 const quoteInboxes = [];
1970 if (post2.quote_uri === undefined || post2.quote_uri === null) {
1971 const q = await resolveOwnQuote(post2.content || '');
1972 if (q) {
1973 try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
1974 post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
1975 }
1976 }
1977 if (post2.quote_actor) {
1978 const a = await fetchActor(post2.quote_actor).catch(() => null);
1979 const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1980 if (inbox) quoteInboxes.push(inbox);
1981 }
1982 const followers = fStmts().list.all(site.slug);
1983 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
1984 if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
1985 const keys = getOrCreateKeys(site.slug);
1986 const keyId = `${actorId(base, site.slug)}#main-key`;
1987 const create = buildCreate(base, site, post2);
1988 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
1989}
1990
1991// On a new Follow, send that follower our most recent posts as Create so their
1992// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
1993// so they sort into the follower's timeline at their original dates.
1994async function backfillNewFollower(base, slug, inbox) {
1995 if (!base || !slug || !inbox) return;
1996 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1997 if (!site) return;
1998 const recent = db.prepare(
1999 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
2000 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
2001 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
2002 ).all(site.id).reverse();
2003 if (!recent.length) return;
2004 const keys = getOrCreateKeys(slug);
2005 const keyId = `${actorId(base, slug)}#main-key`;
2006 for (const p of recent) {
2007 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
2008 await new Promise((r) => setTimeout(r, 150));
2009 }
2010 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
2011}
2012
2013// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
2014export async function deliverDelete(site, post) {
2015 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2016 if (!base || !site || !site.slug || !post || !post.id) return;
2017 const followers = fStmts().list.all(site.slug);
2018 if (!followers.length) return;
2019 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
2020 const keys = getOrCreateKeys(site.slug);
2021 const me = actorId(base, site.slug);
2022 const nid = noteId(base, post.id);
2023 const del = {
2024 '@context': AP_CONTEXT,
2025 id: `${nid}#delete-${Date.now()}-${rid()}`,
2026 type: 'Delete',
2027 actor: me,
2028 to: [PUBLIC],
2029 object: { id: nid, type: 'Tombstone' },
2030 };
2031 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
2032}
2033
2034// Tell followers an already-published post changed (Update + edited Note) so
2035// Mastodon refreshes the cached copy (e.g. after fixing content).
2036export async function deliverUpdate(site, post) {
2037 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2038 if (!base || !site || !site.slug || !post || !post.id) return;
2039 const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
2040 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
2041 const followers = fStmts().list.all(site.slug);
2042 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
2043 if (!inboxes.length) return;
2044 const keys = getOrCreateKeys(site.slug);
2045 const me = actorId(base, site.slug);
2046 const note = buildNote(base, site, post2);
2047 note.updated = new Date().toISOString();
2048 const update = {
2049 '@context': AP_CONTEXT,
2050 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
2051 type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
2052 object: note,
2053 };
2054 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
2055}
2056
2057// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
2058// account AND re-fetches the featured (pinned) collection — there is no standard
2059// "featured changed" activity, so this is how a pin/unpin propagates promptly.
2060export async function deliverActorUpdate(site) {
2061 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2062 if (!base || !site || !site.slug) return;
2063 const followers = fStmts().list.all(site.slug);
2064 if (!followers.length) return;
2065 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
2066 const keys = getOrCreateKeys(site.slug);
2067 const me = actorId(base, site.slug);
2068 const update = {
2069 '@context': AP_CONTEXT,
2070 id: `${me}#update-${Date.now()}-${rid()}`,
2071 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
2072 object: buildActor(base, site),
2073 };
2074 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
2075}
2076
2077// Reliably set the pinned order on followers' instances via Add/Remove activities
2078// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
2079// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
2080// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
2081// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
2082// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
2083// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
2084// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
2085// resync already in flight for a site coalesces later requests into ONE rerun after it
2086// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
2087const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
2088export function resyncFeaturedPins(site, alsoRemove = []) {
2089 if (!site || !site.slug) return Promise.resolve();
2090 const slug = site.slug;
2091 const running = _pinResync.get(slug);
2092 if (running) {
2093 running.pending = true;
2094 running.site = site; // use the latest site object on the rerun
2095 for (const id of alsoRemove) running.pendingRemove.add(id);
2096 return running.promise;
2097 }
2098 const state = { promise: null, pending: false, pendingRemove: new Set(), site };
2099 state.promise = (async () => {
2100 let extra = alsoRemove;
2101 for (;;) {
2102 try { await doResyncFeaturedPins(state.site, extra); }
2103 catch (e) { console.warn('[AP] pin resync failed:', e.message); }
2104 if (!state.pending) break;
2105 state.pending = false;
2106 extra = [...state.pendingRemove];
2107 state.pendingRemove = new Set();
2108 }
2109 _pinResync.delete(slug);
2110 })();
2111 _pinResync.set(slug, state);
2112 return state.promise;
2113}
2114
2115// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
2116// it stays serialized per site.
2117async function doResyncFeaturedPins(site, alsoRemove = []) {
2118 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2119 if (!base || !site || !site.slug) return;
2120 const followers = fStmts().list.all(site.slug);
2121 if (!followers.length) return;
2122 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
2123 const keys = getOrCreateKeys(site.slug);
2124 const me = actorId(base, site.slug);
2125 const keyId = `${me}#main-key`;
2126 const featured = `${me}/featured`;
2127 const note = (id) => noteId(base, id);
2128 const pinned = db.prepare(
2129 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
2130 AND pinned IS NOT NULL AND pinned > 0
2131 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
2132 ).all(site.id);
2133 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
2134 // 1. Remove every current pin so Mastodon can recreate them in order.
2135 for (const id of removeIds) {
2136 const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
2137 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
2138 }
2139 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
2140 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
2141 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
2142 for (const p of pinned) {
2143 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`] };
2144 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
2145 await new Promise((r) => setTimeout(r, 2000));
2146 }
2147 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
2148}
2149
2150// ── outbound replies (Klonkt → fediverse) ─────────────────────────
2151const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
2152const 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(); };
2153
2154// Build one of OUR outbound reply Notes from an ap_outbox row.
2155// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
2156function linkHashtags(base, html) {
2157 // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
2158 // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
2159 return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
2160 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
2161}
2162// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
2163// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
2164// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
2165// outside the link (Mastodon-style).
2166function linkUrls(html) {
2167 const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
2168 for (let i = 0; i < parts.length; i++) {
2169 if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
2170 parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
2171 (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
2172 }
2173 return parts.join('');
2174}
2175// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
2176// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
2177// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
2178// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
2179// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
2180export function linkifyBody(base, html) {
2181 const withTags = String(html || '')
2182 .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
2183 .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
2184 .join('');
2185 return linkUrls(withTags);
2186}
2187
2188// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
2189// at save and cached in posts.content_rendered, so page views serve it statically instead of
2190// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
2191// resolve @mentions here too (webfinger once at save instead of per page view).
2192export function bakePostContent(source) {
2193 return linkifyBody('', source || '');
2194}
2195
2196// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
2197// same resolver the federated copy uses) and bakes the profile links into content_rendered,
2198// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
2199// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
2200// the save response so the request never blocks on a slow/dead remote server.
2201export async function bakePostContentWithMentions(source) {
2202 const withHashUrls = bakePostContent(source);
2203 try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
2204 catch { return withHashUrls; }
2205}
2206
2207// Extract the AP Hashtag tag objects from already-linked reply content.
2208function hashtagTags(base, content) {
2209 const tags = [], seen = new Set();
2210 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
2211 let m;
2212 while ((m = re.exec(content || ''))) {
2213 const k = m[1].toLowerCase();
2214 if (seen.has(k)) continue; seen.add(k);
2215 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
2216 }
2217 return tags;
2218}
2219
2220// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
2221function normalizeTags(t) {
2222 if (Array.isArray(t)) return t;
2223 if (typeof t === 'string') {
2224 const s = t.trim(); if (!s) return [];
2225 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
2226 return s.split(',').map((x) => x.trim()).filter(Boolean);
2227 }
2228 return [];
2229}
2230// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
2231// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
2232// slug/href stays lowercase ("livemusic").
2233function tagParts(raw) {
2234 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
2235 if (!words.length) return null;
2236 const slug = words.join('').toLowerCase();
2237 if (!slug) return null;
2238 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
2239 return { label, slug };
2240}
2241// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
2242// Hashtag tag list (with hrefs to our /tag page).
2243function buildHashtagList(base, tagsField, content) {
2244 const out = [], seen = new Set();
2245 for (const t of normalizeTags(tagsField)) {
2246 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
2247 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
2248 }
2249 for (const h of hashtagTags(base, content)) {
2250 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
2251 out.push(h);
2252 }
2253 return out;
2254}
2255
2256// Extract Mention tag objects from already-linked content (class="u-url mention").
2257function mentionTags(content) {
2258 const tags = [], seen = new Set();
2259 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
2260 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
2261 let m;
2262 while ((m = re.exec(content || ''))) {
2263 const href = m[1];
2264 if (seen.has(href)) continue; seen.add(href);
2265 tags.push({ type: 'Mention', href, name: '@' + m[2] });
2266 }
2267 return tags;
2268}
2269// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
2270// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
2271async function resolveMentionsInText(base, html) {
2272 const inboxes = [];
2273 const handles = new Set();
2274 // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
2275 // miss: a bracketed mention federated as plain text and its target was never notified).
2276 const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
2277 let m;
2278 while ((m = re.exec(html || ''))) handles.add(m[2]);
2279 let out = String(html || '');
2280 for (const h of handles) {
2281 let actorUri = null;
2282 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
2283 if (!actorUri) continue;
2284 const actor = await fetchActor(actorUri).catch(() => null);
2285 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
2286 if (inbox) inboxes.push(inbox);
2287 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
2288 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2289 out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
2290 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
2291 }
2292 return { html: out, inboxes };
2293}
2294
2295export function buildReplyNote(base, site, row) {
2296 // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
2297 return buildNote(base, site, row, { isReply: true });
2298}
2299
2300// The account's own outbound notes (replies and direct messages) as AS2
2301// Notes, newest first. The C2S inbox read serves these alongside the
2302// timeline: without them your own reply existed everywhere EXCEPT in your
2303// own app (Robins melding, 30-7: "replyen werkt nog niet"; het antwoord
2304// stond op de server maar de app kreeg het nooit terug, dus je probeerde
2305// het opnieuw en liep in de duplicate-guard).
2306export function getSentNotes(base, site, limit = 60) {
2307 return db.prepare('SELECT * FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC LIMIT ?')
2308 .all(site.slug, limit)
2309 .map((row) => buildReplyNote(base, site, row));
2310}
2311
2312// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
2313export function getOutboxNote(base, id) {
2314 const row = iStmts().getO.get(id);
2315 if (!row) return null;
2316 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
2317 if (!site) return null;
2318 return buildReplyNote(base, site, row);
2319}
2320
2321// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
2322// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
2323// activity here and we translate it onto the SAME delivery machinery the web UI
2324// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
2325// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
2326const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
2327
2328export async function ingestOutboxActivity(site, user, activity) {
2329 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2330 if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
2331
2332 // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
2333 let type = activity.type;
2334 let object = activity.object;
2335 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
2336 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
2337
2338 // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
2339 // Relationship) belongs to the guardianship module; anything else falls
2340 // through to the switch below.
2341 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
2342 const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
2343 if (g) return g;
2344 }
2345
2346 try {
2347 switch (type) {
2348 case 'Create': {
2349 if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
2350 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
2351 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
2352 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
2353 // A picture (or a recording) can be the whole message: media-only
2354 // notes pass here; c2sCreatePost validates the attachments themselves.
2355 if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) {
2356 return { status: 400, error: 'empty_note' };
2357 }
2358 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
2359 // outbox machinery to the addressed inboxes only; shows under Messages.
2360 if (c2sVisibility(object) === 'direct') {
2361 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
2362 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
2363 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
2364 if (!recipients.length) return { status: 400, error: 'no_recipients' };
2365 // AS2 attachments (e.g. the help-buoy capture, uploaded via
2366 // uploadMedia): normalize our own absolute /media/ URLs to relative
2367 // so the deliverReply-style validation applies unchanged.
2368 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
2369 .map((a) => a && typeof a === 'object' ? {
2370 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
2371 mediaType: String(a.mediaType || ''),
2372 name: String(a.name || '').slice(0, 120),
2373 } : null)
2374 .filter(Boolean);
2375 const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
2376 // FEP-633c 3.6.1: a guardian here declaring itself away to its
2377 // wards. An away without a (future) end fails loudly, exactly as
2378 // the daemon refuses it: stored quietly it would be a nominal
2379 // guardian holding a seat.
2380 let awayUntil = null;
2381 if (Guardianship.availability.isAway(object)) {
2382 awayUntil = Guardianship.availability.parseEndTime(object.endTime);
2383 if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
2384 // No local shortcut here: the note below reaches a ward on this
2385 // instance through the loopback, and its inbox handler applies the
2386 // absence like it does for a ward anywhere else. One path.
2387 }
2388 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil });
2389 if (!r || !r.id) return { status: 502, error: 'direct_failed' };
2390 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2391 }
2392 if (object.inReplyTo) {
2393 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
2394 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
2395 // The attachments ride along (Robins melding, 30-7: "502
2396 // reply_failed" op een reply met een foto): deliverReply validates
2397 // them itself (own /media only, image|audio|video, max 4) and a
2398 // media-only reply is a valid reply there. Dropping them here made
2399 // a photo reply arrive naked, and a photo-ONLY reply fail outright.
2400 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
2401 .map((a) => a && typeof a === 'object' ? {
2402 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
2403 mediaType: String(a.mediaType || ''),
2404 name: String(a.name || '').slice(0, 120),
2405 } : null)
2406 .filter(Boolean);
2407 // Honour the client's visibility for the reply: 'friends' (followers-
2408 // only, the Shaer detail-view Reply) drops Public; anything else stays
2409 // quiet-public. 'direct' was already handled above.
2410 const r = await deliverReply(site, {
2411 postId: parent.localPostId || '', postSlug: null, parent, text: plain,
2412 html: object.content || null, attachments: atts,
2413 language: object.language || null, visibility: c2sVisibility(object),
2414 });
2415 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
2416 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2417 }
2418 return await c2sCreatePost(base, site, user, object);
2419 }
2420 case 'Like':
2421 case 'Announce': {
2422 const targetUri = c2sIdOf(object);
2423 if (!targetUri) return { status: 400, error: 'missing_object' };
2424 // A non-public local note cannot be boosted or liked into the open
2425 // (shaer-tqc hardening; the Mastodon 422 equivalent).
2426 const localPid = postIdFromNoteUrl(targetUri, base);
2427 if (localPid) {
2428 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
2429 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
2430 return { status: 403, error: 'not_public' };
2431 }
2432 }
2433 const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
2434 const objUri = (note && note.object_uri) || targetUri;
2435 const authorUri = note && note.actor_uri;
2436 const kind = type === 'Announce' ? 'boost' : 'like';
2437 await sendInteraction(site, kind, objUri, authorUri);
2438 setMyReaction(site.slug, targetUri, kind, true);
2439 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
2440 return { status: 202, url: objUri };
2441 }
2442 case 'Follow': {
2443 const actorUri = c2sIdOf(object);
2444 if (!actorUri) return { status: 400, error: 'missing_object' };
2445 // The error REACHES the app (Robins melding, 31-7): swallowing it
2446 // made a failed follow look exactly like a successful one.
2447 const r = await followActor(site, actorUri);
2448 if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
2449 return { status: 202, url: actorUri };
2450 }
2451 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
2452 // ap_blocks, shows in the Block tab, and purges the actor's cached
2453 // content. Client-side filtering becomes a cache of this state.
2454 case 'Block': {
2455 const targetUri = c2sIdOf(object);
2456 if (!targetUri) return { status: 400, error: 'missing_object' };
2457 const r = await blockTarget(site, targetUri);
2458 if (r && r.error) return { status: 400, error: r.error };
2459 return { status: 202, url: targetUri };
2460 }
2461 case 'Undo': {
2462 const inner = object && typeof object === 'object' ? object : null;
2463 let innerType = inner && inner.type;
2464 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
2465 const innerTarget = c2sIdOf(inner && inner.object);
2466 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
2467 if (innerType === 'Block') {
2468 if (!innerTarget) return { status: 400, error: 'missing_object' };
2469 unblock(site, innerTarget); // release from Orbit
2470 return { status: 202, url: innerTarget };
2471 }
2472 if (innerType === 'Like' || innerType === 'Announce') {
2473 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
2474 const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
2475 const objUri = (note && note.object_uri) || innerTarget;
2476 await sendInteraction(site, kind, objUri, note && note.actor_uri);
2477 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
2478 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
2479 return { status: 202, url: objUri };
2480 }
2481 return { status: 400, error: 'unsupported_undo' };
2482 }
2483 // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
2484 // app). Scope stays narrow: this account's posts and outbound replies,
2485 // nothing else. The web delete route is the model: Tombstone to the
2486 // followers first, then the cascade, so nobody keeps a live copy of a
2487 // post the child took back.
2488 case 'Delete': {
2489 const targetUri = c2sIdOf(object);
2490 if (!targetUri) return { status: 400, error: 'missing_object' };
2491 const pid = postIdFromNoteUrl(targetUri, base);
2492 if (pid) {
2493 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
2494 if (post) {
2495 if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
2496 if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
2497 db.transaction(() => {
2498 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
2499 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
2500 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
2501 })();
2502 return { status: 202, url: targetUri };
2503 }
2504 // Same /ap/notes/ namespace: one of our outbound replies/messages.
2505 // deliverOutboxDelete checks the site itself and tombstones too.
2506 if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
2507 }
2508 return { status: 404, error: 'not_your_note' };
2509 }
2510 // Update of arbitrary objects needs the post-edit pipeline; tracked
2511 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
2512 default:
2513 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
2514 }
2515 } catch (e) {
2516 console.warn('[AP] C2S ingest failed:', e && e.message);
2517 return { status: 500, error: 'ingest_error' };
2518 }
2519}
2520
2521// Create a top-level microblog post from a C2S Note and federate it. Minimal
2522// sibling of the /posts/create route: sanitized HTML content, no title/cover.
2523async function c2sCreatePost(base, site, user, object) {
2524 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
2525 // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
2526 // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
2527 // to be silently dropped here, so a photo post from the app arrived naked.
2528 const media = (Array.isArray(object.attachment) ? object.attachment : [])
2529 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
2530 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
2531 .slice(0, 4)
2532 .map((a) => {
2533 const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
2534 // The poster the upload leg made, when it did: a video's still frame
2535 // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
2536 // .poster.png). Rides along so the tag, the federated attachment and
2537 // the apps all have something to show instead of a bare box.
2538 const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
2539 : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
2540 if (posterExt) {
2541 try {
2542 const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
2543 const rel = entry.url.replace(/^\/media\//, '');
2544 if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
2545 } catch { /* no poster is fine */ }
2546 }
2547 return entry;
2548 });
2549 if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
2550 // The web reads the post's content, so the media goes IN it (we build these
2551 // tags ourselves from validated paths, after the sanitizer). buildNote
2552 // strips <img> back out into AS2 attachments; audio/video tags stay for the
2553 // web player and federate via c2s_attachments below.
2554 const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
2555 const mediaHtml = media.map((a) => {
2556 if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
2557 // data-poster: <audio> has no poster attribute, but the tile derivation
2558 // reads this one to show the waveform (post-tile/post-card).
2559 if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
2560 const poster = a.poster ? ` poster="${a.poster}"` : '';
2561 return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
2562 }).join('');
2563 const postId = crypto.randomUUID();
2564 const slug = 'n-' + postId.slice(0, 8);
2565 const now = new Date().toISOString();
2566 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
2567 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
2568 // (rides the existing fan_only pipeline: followers-only AP delivery + web
2569 // gating), neither = participants-only (kept local until mention addressing
2570 // lands; still followers-gated on the web).
2571 const vis = c2sVisibility(object);
2572 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
2573 // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
2574 // content, and a cover next to it showed the same video twice on the post
2575 // page. The tiles derive their picture from the content instead.
2576 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
2577 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
2578 .run(postId, site.id, slug, user.id, '', html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
2579 if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
2580 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
2581 bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
2582 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
2583 if (vis !== 'direct') {
2584 deliverCreate(site, { id: postId, slug, title: '', content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
2585 }
2586 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
2587}
2588
2589// The direct-note leg (ward call-for-help) lives in the guardianship module
2590// (src/services/guardianship/delivery.js); wired with our AP helpers at the
2591// bottom of this file. Re-exported so every existing caller keeps working.
2592export const c2sVisibility = Guardianship.c2sVisibility;
2593export const deliverDirectNote = Guardianship.deliverDirectNote;
2594
2595// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
2596// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
2597export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions, visibility }) {
2598 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2599 // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
2600 // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
2601 const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
2602 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2603 // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
2604 // upload route is the sole producer), image/audio/video only, max 4.
2605 const media = (Array.isArray(attachments) ? attachments : [])
2606 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
2607 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
2608 .slice(0, 4)
2609 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
2610 // A media-only reply (no text) is a valid reply.
2611 if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
2612 const me = actorId(base, site.slug);
2613 // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
2614 // parent author). An ARRAY (possibly empty) = the kept conversation partners
2615 // exactly as the bar shows them; the mention prefix, the Mention tags (via
2616 // mentionTags over the content) and the delivery targets all follow it.
2617 const kept = Array.isArray(mentions)
2618 ? mentions
2619 .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
2620 .slice(0, 8)
2621 .map((m) => ({
2622 uri: m.uri,
2623 url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
2624 handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
2625 }))
2626 : null;
2627 const mentionAnchor = (uri, url, h) => {
2628 const disp = h && h[0] === '@' ? h : '@' + (h || '');
2629 return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
2630 };
2631 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
2632 const mention = kept
2633 ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
2634 : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
2635 // Who the stored reply is "to": the parent when kept, else the first kept chip.
2636 const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
2637 const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
2638 const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
2639 let content;
2640 let mres;
2641 if (rich) {
2642 // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
2643 // sanitized editor HTML. The parent mention goes inline into the first
2644 // paragraph (Mastodon convention), or becomes its own leading one.
2645 mres = await resolveMentionsInText(base, rich);
2646 const processed = linkUrls(linkHashtags(base, mres.html));
2647 if (processed.startsWith('<p>')) {
2648 content = processed.replace('<p>', `<p>${mention}`); // inline in the first paragraph
2649 } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
2650 content = `<p>${mention}</p>${processed}`; // block content: own leading paragraph
2651 } else {
2652 content = `<p>${mention}${processed}</p>`; // bare inline text: one paragraph together
2653 }
2654 } else {
2655 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
2656 mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
2657 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2658 }
2659 const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
2660 // Dedup: skip if the exact same reply was already sent (double-submit guard).
2661 // Attachments count toward "the same": two media-only replies share content.
2662 const mediaJson = media.length ? JSON.stringify(media) : null;
2663 // A duplicate is idempotent success, not an error: it answers with the
2664 // EXISTING id. Returning without one made the C2S ingest say 502
2665 // reply_failed on a double-submit (Robins schermafdruk, 30-7), so a retry
2666 // of a reply the app never showed looked like the reply itself failing.
2667 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')
2668 .get(site.slug, parent.object_uri || '', content, mediaJson);
2669 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, id: dup.id, delivered: 0 }; }
2670 const id = crypto.randomUUID();
2671 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
2672 // Followers-only reply (shaer detail-view): mark the row so buildNote drops
2673 // Public from cc. Default (undefined/'public'/'quiet') stays quiet-public.
2674 if (visibility === 'friends') { try { db.prepare('UPDATE ap_outbox SET visibility = ? WHERE id = ?').run('friends', id); } catch { /* ignore */ } }
2675 const row = iStmts().getO.get(id);
2676 const note = buildReplyNote(base, site, row);
2677 const create = {
2678 '@context': AP_CONTEXT,
2679 id: note.id + '#create', type: 'Create', actor: me,
2680 published: note.published, to: note.to, cc: note.cc, object: note,
2681 };
2682 const keys = getOrCreateKeys(site.slug);
2683 const keyId = `${me}#main-key`;
2684 const inboxes = new Set();
2685 // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
2686 const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
2687 for (const uri of mentionTargets) {
2688 const a = await fetchActor(uri).catch(() => null);
2689 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
2690 }
2691 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
2692 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
2693 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2694 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
2695 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
2696 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
2697 let delivered = 0;
2698 for (const inbox of [...inboxes].filter(Boolean)) {
2699 let ok = false;
2700 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2701 if (ok) delivered++;
2702 else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
2703 }
2704 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
2705 return { id, content, delivered };
2706}
2707
2708// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
2709// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
2710function actorUriOf(att) {
2711 if (!att) return null;
2712 if (typeof att === 'string') return att;
2713 if (Array.isArray(att)) {
2714 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
2715 if (person) return person.id;
2716 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
2717 return null;
2718 }
2719 return att.id || null;
2720}
2721
2722// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
2723// Returns a parent-shaped object usable by deliverReply(), or null.
2724export async function resolveRemoteNote(url, opts = {}) {
2725 if (!/^https?:\/\//i.test(String(url || ''))) return null;
2726 // With `asSlug` the fetches are SIGNED as that local actor. An anonymous
2727 // GET can only read public notes; a friends-only note (Shaer's default!)
2728 // rightly refuses it, which made every reply to a friend's post fail while
2729 // a reply to your own public post worked (Robins melding, 30-7). Signed,
2730 // the other server sees WHO asks and serves what the friendship earns.
2731 const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null));
2732 const note = await get(url); // AP GET (content-negotiates)
2733 if (!note || !note.id) return null;
2734 const att = note.attributedTo;
2735 const actorUri = actorUriOf(att);
2736 if (!actorUri) return null;
2737 const actor = await get(actorUri);
2738 const ai = actorInfo(actor, actorUri);
2739 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
2740 // link our reply to that local post so it shows nested in the post thread.
2741 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
2742 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
2743 // and collect every ancestor author's inbox, so each participant's server —
2744 // including the original post's author — receives + threads our reply.
2745 const threadInboxes = [];
2746 const seenInbox = new Set();
2747 let cursor = note.inReplyTo, guard = 0;
2748 while (cursor && guard++ < 6) {
2749 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
2750 if (!url) break;
2751 const pn = await get(url);
2752 if (!pn) break;
2753 const pa = actorUriOf(pn.attributedTo);
2754 if (pa && pa !== actorUri) {
2755 const paDoc = await get(pa);
2756 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
2757 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
2758 }
2759 cursor = pn.inReplyTo; // climb to the next ancestor
2760 }
2761 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
2762 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
2763 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
2764 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
2765 const images = (Array.isArray(note.attachment) ? note.attachment : [])
2766 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
2767 .map((a) => safeUrl(a.url)).filter(Boolean);
2768 // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
2769 // shows the player card, not a loose image) and puts it in `image` instead.
2770 // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
2771 if (!images.length && note.image) {
2772 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2773 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2774 if (iu) images.push(iu);
2775 }
2776 return {
2777 object_uri: safeUrl(note.id) || note.id,
2778 actor_uri: actorUri,
2779 actor_url: ai.url,
2780 actor_handle: ai.handle,
2781 actor_name: ai.name,
2782 actor_icon: ai.icon,
2783 url: note.url || url,
2784 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
2785 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
2786 cw: note.summary || '',
2787 images,
2788 // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
2789 // image-only for the interact page preview; a boosted video-only post (Loops)
2790 // lost its media entirely because upsertBoostedNote only saw `images`.
2791 media: mediaFromNote(note),
2792 threadInboxes, // every ancestor author's inbox
2793 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
2794 poll: parsePoll(note), // a Question → its options/counts (else null)
2795 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
2796 };
2797}
2798
2799// List a site's own outbound fediverse replies (for the manage/delete view).
2800// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
2801// so the manage view can prefill an edit box; the mention is re-added on save.
2802function outboxEditableText(content) {
2803 return String(content || '')
2804 .replace(/<br\s*\/?>/gi, '\n')
2805 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
2806 .replace(/<[^>]+>/g, '')
2807 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
2808 .trim();
2809}
2810export function listOutbox(siteSlug) {
2811 return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
2812 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
2813}
2814
2815// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
2816export async function deliverOutboxDelete(site, outboxId) {
2817 const row = iStmts().getO.get(outboxId);
2818 if (!row || row.site_slug !== site.slug) return false;
2819 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2820 if (base) {
2821 const me = actorId(base, site.slug);
2822 const nid = noteId(base, row.id);
2823 const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
2824 const keys = getOrCreateKeys(site.slug);
2825 const inboxes = new Set();
2826 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
2827 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2828 for (const inbox of [...inboxes].filter(Boolean)) {
2829 try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
2830 enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
2831 }
2832 }
2833 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
2834 return true;
2835}
2836
2837// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
2838// re-linked) and send an Update(Note) so recipients refresh their cached copy.
2839export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
2840 const row = iStmts().getO.get(outboxId);
2841 if (!row || row.site_slug !== site.slug) return false;
2842 const text = String(newText || '').trim();
2843 // Rich edit: same sanitize + enrichment pipeline as deliverReply.
2844 const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
2845 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2846 if (!text && !rich) return false;
2847 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2848 if (!base) return false;
2849 const me = actorId(base, site.slug);
2850 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
2851 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
2852 const _h = row.to_handle || deriveHandle(row.to_actor);
2853 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
2854 // An edit must not drop co-mentions (u02): reuse the OLD content's leading
2855 // mention anchors (the bar's kept list at send time) when present; only fall
2856 // back to rebuilding the single to_actor mention for legacy rows.
2857 const oldPrefix = (String(row.content || '')
2858 .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
2859 const mention = oldPrefix || (row.to_actor
2860 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
2861 let content;
2862 let mres;
2863 if (rich) {
2864 mres = await resolveMentionsInText(base, rich);
2865 const processed = linkUrls(linkHashtags(base, mres.html));
2866 if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
2867 else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
2868 else content = `<p>${mention}${processed}</p>`;
2869 } else {
2870 mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
2871 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2872 }
2873 // Language may be updated with the edit; attachments always survive untouched.
2874 const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
2875 db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
2876 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
2877 note.updated = new Date().toISOString();
2878 const update = {
2879 '@context': AP_CONTEXT,
2880 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
2881 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
2882 };
2883 const keys = getOrCreateKeys(site.slug);
2884 const inboxes = new Set();
2885 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
2886 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2887 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
2888 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
2889 let delivered = 0;
2890 for (const inbox of [...inboxes].filter(Boolean)) {
2891 let ok = false;
2892 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2893 if (ok) delivered++;
2894 else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
2895 }
2896 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
2897 return { ok: true, content, delivered };
2898}
2899
2900// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
2901// Resolve an @user@domain handle to its actor URL via WebFinger.
2902export async function webfingerResolve(handle) {
2903 const h = String(handle || '').trim().replace(/^@/, '');
2904 const parts = h.split('@');
2905 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
2906 const acct = `${parts[0]}@${parts[1]}`;
2907 try {
2908 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
2909 { headers: { Accept: 'application/jrd+json, application/json' } });
2910 if (!r.ok) return null;
2911 const jrd = await r.json();
2912 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
2913 return safeUrl(link ? link.href : '') || null;
2914 } catch { return null; }
2915}
2916
2917let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
2918function fwStmts() {
2919 if (!_insFw) {
2920 _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, auto_boost, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
2921 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
2922 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
2923 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
2924 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
2925 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
2926 }
2927 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
2928}
2929export function listFollowing(slug) { return fwStmts().list.all(slug); }
2930
2931// Toggle auto-boost ("feature") on an account we already follow.
2932export function setAutoBoost(slug, actorUri, on) {
2933 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
2934 // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
2935 // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
2936 if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
2937 return { ok: true };
2938}
2939
2940let _insTl, _listTl, _delTl;
2941function tlStmts() {
2942 if (!_insTl) {
2943 _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
2944 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');
2945 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
2946 }
2947 return { ins: _insTl, list: _listTl, del: _delTl };
2948}
2949export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
2950
2951/**
2952 * The direct notes addressed to this account: a plain DM, a guardian's wave
2953 * (§5), a ward's 🛟 help request (§5.2.1). They live in ap_mentions and NOT in
2954 * the timeline, because a note addressed to named people is a message and not a
2955 * post (belongsInTimeline).
2956 *
2957 * A client that only reads the timeline therefore sees none of them, which is
2958 * exactly what happened to Shaer: Berichten showed your own replies (those come
2959 * from your outbox) and nothing that was said to you. The C2S inbox read serves
2960 * both, so the app has one door for everything that arrives.
2961 *
2962 * A public mention from someone you follow is stored in both tables; those are
2963 * skipped here and stay a post.
2964 */
2965// Inbound replies on YOUR posts, for the app's message stream. They live in
2966// ap_interactions (the web's comment machinery) and deliberately NOT in
2967// ap_mentions (the mention store returns early for replies-to-us), so the
2968// C2S read missed them entirely: a reply arrived at the other side
2969// everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
2970// binnen bij de ander").
2971export function getReplyMessages(slug, limit) {
2972 try {
2973 return db.prepare(`
2974 SELECT i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
2975 i.content, i.published, i.created_at, i.parent_uri, i.post_id,
2976 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json
2977 FROM ap_interactions i
2978 JOIN posts p ON p.id = i.post_id
2979 JOIN sites s ON s.id = p.site_id
2980 WHERE s.slug = ? AND i.kind = 'reply'
2981 ORDER BY COALESCE(i.published, i.created_at) DESC LIMIT ?`).all(slug, limit || 60);
2982 } catch { return []; }
2983}
2984
2985export function getDirectMessages(slug, limit) {
2986 try {
2987 return db.prepare(`
2988 SELECT m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
2989 m.content, m.published, m.created_at, m.wave, m.help_request,
2990 m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json
2991 FROM ap_mentions m
2992 WHERE m.slug = ?
2993 AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
2994 ORDER BY COALESCE(m.published, m.created_at) DESC LIMIT ?`).all(slug, limit || 60);
2995 } catch { return []; }
2996}
2997
2998/**
2999 * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
3000 * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
3001 * server two hours ahead that dated every message two hours early and put the
3002 * conversation in the wrong order. A `published` from the wire is already ISO
3003 * and passes through untouched.
3004 */
3005export function isoStamp(v) {
3006 if (!v) return undefined;
3007 const s = String(v);
3008 if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
3009 const t = Date.parse(s);
3010 return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
3011}
3012
3013// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
3014// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
3015// friend's images/audio/video natively, exactly like own outbox posts. The
3016// stored `type` is the mediaType and may be ''. Malformed JSON yields
3017// undefined and never blocks the item.
3018export function timelineAttachments(mediaJson) {
3019 try {
3020 const list = mediaJson ? JSON.parse(mediaJson) : [];
3021 const rows = (Array.isArray(list) ? list : [])
3022 .filter((m) => m && m.url)
3023 .map((m) => {
3024 const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
3025 if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
3026 return a;
3027 });
3028 return rows.length ? rows : undefined;
3029 } catch { return undefined; }
3030}
3031
3032// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
3033// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
3034// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
3035// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
3036export function extractEmojiTags(tag) {
3037 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
3038 const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
3039 && typeof t.name === 'string' && t.icon);
3040 return emojis.length ? JSON.stringify(emojis) : null;
3041}
3042export function timelineEmojis(emojiJson) {
3043 try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
3044 catch { return undefined; }
3045}
3046
3047// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
3048// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
3049// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
3050// them back and a client (Shaer) can render the quote/reference. Mirrors
3051// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
3052export function extractObjectLinkTags(tag) {
3053 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
3054 const links = arr.filter((t) => {
3055 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
3056 if (typeof t.href !== 'string' || !t.href) return false;
3057 const mt = String(t.mediaType || '').toLowerCase();
3058 return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
3059 || mt.startsWith('application/activity+json');
3060 });
3061 return links.length ? JSON.stringify(links) : null;
3062}
3063export function timelineObjectLinks(linkJson) {
3064 try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
3065 catch { return undefined; }
3066}
3067
3068// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
3069// object-level property. FEP-044f §"how to recognise" lists them all:
3070// `quote` (the FEP property, a string or an embedded Link/object), and the
3071// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
3072// This returns the quoted object's URL from whichever is present.
3073export function extractQuoteUrl(note) {
3074 if (!note || typeof note !== 'object') return null;
3075 const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
3076 if (!q) return null;
3077 if (typeof q === 'string') return q || null;
3078 if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
3079 return null;
3080}
3081
3082// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
3083// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
3084// _misskey_quote) so the client's single object-link path renders them all.
3085// Deduped by href. Returns the JSON to store (or null if the note has neither).
3086export function extractLinkJson(note) {
3087 const links = [];
3088 const fromTag = extractObjectLinkTags(note && note.tag);
3089 if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
3090 const qUrl = extractQuoteUrl(note);
3091 if (qUrl && !links.some((l) => l && l.href === qUrl)) {
3092 links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
3093 rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
3094 }
3095 return links.length ? JSON.stringify(links) : null;
3096}
3097
3098// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
3099// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
3100export function quoteHrefOf(note) {
3101 const direct = extractQuoteUrl(note);
3102 if (direct) return direct;
3103 const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
3104 for (const t of arr) {
3105 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
3106 const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
3107 if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
3108 }
3109 return null;
3110}
3111
3112// Turn the stored quote snapshot back into the object the C2S inbox read serves
3113// as `shaer:quote`, so the client can render the embedded quote card.
3114export function timelineQuote(quoteJson) {
3115 try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
3116 catch { return undefined; }
3117}
3118
3119// Store the author's display-name emoji map (from actorInfo().emojis) on a
3120// timeline row, so the byline can render a ":shortcode:" name. No-op when the
3121// name has no custom emoji (the common case).
3122function storeAuthorEmoji(id, slug, ai) {
3123 if (!ai || !ai.emojis || !Object.keys(ai.emojis).length) return;
3124 try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(ai.emojis), id, slug); } catch { /* ignore */ }
3125}
3126
3127// A display-name emoji map (actorInfo().emojis) → JSON to store, or null.
3128function emojiJsonOf(map) { return (map && Object.keys(map).length) ? JSON.stringify(map) : null; }
3129
3130// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
3131let _abCount, _cirkelPosts, _cirkelMembers;
3132export function autoBoostCount(slug) {
3133 try { if (!_abCount) _abCount = db.prepare('SELECT COUNT(*) AS n FROM ap_following WHERE slug = ? AND auto_boost = 1'); return _abCount.get(slug).n; } catch { return 0; }
3134}
3135export function getCirkelPosts(slug, limit, offset) {
3136 try {
3137 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
3138 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
3139 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
3140 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
3141 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
3142 FROM ap_timeline t
3143 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
3144 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
3145 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
3146 LIMIT ? OFFSET ?`);
3147 return _cirkelPosts.all(slug, limit || 60, offset || 0);
3148 } catch { return []; }
3149}
3150export function getCirkelMembers(slug) {
3151 try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
3152}
3153// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
3154let _markBoost, _unmarkBoost, _boostedCount;
3155export function markBoosted(slug, noteId) {
3156 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
3157}
3158export function unmarkBoosted(slug, noteId) {
3159 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
3160}
3161let _markLike, _unmarkLike;
3162export function markLiked(slug, noteId) {
3163 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
3164}
3165export function unmarkLiked(slug, noteId) {
3166 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
3167}
3168export function getTimelineReaction(slug, noteId) {
3169 try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
3170}
3171// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
3172// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
3173// the Cirkel with a Boost badge, then flag it boosted.
3174export function upsertBoostedNote(slug, note) {
3175 if (!slug || !note || !note.object_uri) return;
3176 const id = note.object_uri;
3177 // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
3178 // rendered a bare text tile); fall back to the image-only list for older callers.
3179 const media = (note.media && note.media !== '[]')
3180 ? note.media
3181 : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
3182 try {
3183 const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
3184 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
3185 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
3186 if (!r.changes) {
3187 // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
3188 // resolved note. Without this a row cached without its cover (or with
3189 // stale content) stayed stale forever — even boosting again didn't heal it.
3190 // Keep the CACHED media when the resolve yielded none: an empty re-resolve
3191 // used to clobber a good media_json (the followed copy had the video, the
3192 // boost wiped it to []).
3193 db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
3194 nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
3195 .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
3196 }
3197 } catch { /* ignore */ }
3198 markBoosted(slug, id);
3199}
3200export function boostedCount(slug) {
3201 try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
3202}
3203
3204// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
3205// /ap/users/<slug> (content negotiation; Location may be relative). Used by
3206// followActor for bare-domain follows.
3207// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
3208// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
3209// never throws anything into the fediverse automatically" (would surprise-Follow
3210// for some operators at scale). The dead circle_links table stays as harmless dead
3211// data; an operator restores an old cirkel by re-following in /following (their click).
3212async function resolveApActor(siteUrl) {
3213 try {
3214 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
3215 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
3216 if (r.ok) return siteUrl;
3217 } catch { /* unreachable */ }
3218 return null;
3219}
3220
3221// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
3222// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
3223// note and refreshes content + media (recovers covers/edits that were delivered
3224// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
3225// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
3226const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
3227async function fetchNoteAP(url) {
3228 try {
3229 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
3230 if (r.status === 404 || r.status === 410) return 404;
3231 if (r.ok) return await r.json();
3232 } catch { /* unreachable */ }
3233 return null;
3234}
3235function mediaFromNote(note) {
3236 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => {
3237 const m = { url: safeUrl(a && a.url), type: (a && a.mediaType) || '' };
3238 // A federated video may carry its poster as an AS2 icon (shaer-zowq).
3239 const iconUrl = a && a.icon && safeUrl(typeof a.icon === 'string' ? a.icon : a.icon.url);
3240 if (iconUrl && /^video\//i.test(m.type)) m.poster = iconUrl;
3241 return m;
3242 }).filter((m) => m.url);
3243 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
3244 const im = Array.isArray(note.image) ? note.image[0] : note.image;
3245 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
3246 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
3247 }
3248 return JSON.stringify(atts);
3249}
3250
3251// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
3252// own posts quotes a fediverse object, say so in the shapes the network really
3253// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
3254// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
3255// third form. All three point at the same object, which is what every reader
3256// expects. The quoted author goes in `cc`, because being quoted without being
3257// told is exactly the rudeness this FEP is trying to design away.
3258export function applyQuoteProps(note, quoteUri, quoteActor) {
3259 if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
3260 note.quote = quoteUri;
3261 note.quoteUrl = quoteUri;
3262 note['_misskey_quote'] = quoteUri;
3263 note.tag = [...(note.tag || []), {
3264 type: 'Link',
3265 mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
3266 href: quoteUri,
3267 rel: ['https://misskey-hub.net/ns#_misskey_quote'],
3268 name: quoteUri,
3269 }];
3270 if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
3271 note.cc = [...new Set([...(note.cc || []), quoteActor])];
3272 }
3273 return note;
3274}
3275
3276// The first external (non-fediverse) link in a note, resolved to the same card
3277// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
3278// third-party frame inside a kid-safe app is a hole you cannot close again, so
3279// the embed carries an image and a title and nothing executable.
3280// Returns the JSON to store, or null when there is nothing worth showing.
3281export async function resolveExternalEmbed(html) {
3282 const first = firstExternalUrl(html);
3283 if (!first) return null;
3284 const io = EmbedResolver.liveIO({
3285 safeFetch,
3286 detectProvider: (u) => AudioEmbedService.detectProvider(u),
3287 fetchActor,
3288 actorInfo,
3289 });
3290 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
3291 // 'ap' is handled by the quote path; a bare 'link' is not worth a card.
3292 if (!card || card.kind === 'ap' || card.kind === 'link') return null;
3293 const thumb = (card.media || []).find((m) => m && m.url);
3294 if (!thumb && !card.title) return null;
3295 // Title, provider and author name come from a third party. Store them as
3296 // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
3297 // be the one that remembers to escape. A card is a card, not an essay.
3298 const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
3299 return JSON.stringify({
3300 url: card.url,
3301 kind: card.kind, // 'provider' | 'oembed'
3302 provider: plain(card.provider),
3303 title: plain(card.title),
3304 author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
3305 media: thumb ? [thumb] : [], // thumbnail only, no html/iframe
3306 });
3307}
3308
3309/**
3310 * Does our own post link to a fediverse object? Returns { uri, actor } when the
3311 * first external link resolves to a quotable AP object, else null. Runs once at
3312 * publish time; the answer is stored on the post.
3313 */
3314export async function resolveOwnQuote(html) {
3315 const first = firstExternalUrl(html);
3316 if (!first) return null;
3317 const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
3318 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
3319 if (!card || card.kind !== 'ap' || !card.id) return null;
3320 return { uri: card.id, actor: card.attributedTo || null };
3321}
3322
3323/** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
3324export function firstExternalUrl(html) {
3325 if (!html || typeof html !== 'string') return null;
3326 for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) {
3327 const tag = m[0];
3328 if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue;
3329 const href = m[1];
3330 if (/^https?:\/\//i.test(href)) return href;
3331 }
3332 return null;
3333}
3334
3335/** The stored external-embed card, for the C2S read. */
3336export function timelineEmbed(embedJson, { playback = false } = {}) {
3337 try {
3338 const e = embedJson ? JSON.parse(embedJson) : null;
3339 if (!e || typeof e !== 'object' || !e.url) return undefined;
3340 // The player URL is served ONLY when the playback gate is open (FEP-633c
3341 // 5.6). Deciding it here keeps the provider knowledge in one place: the
3342 // client never needs a list of hosts, it just plays what it is handed.
3343 // Privacy-enhanced variants only: nocookie for YouTube, the instance's own
3344 // player for PeerTube. Without one the card stays a thumbnail.
3345 const player = playback ? playerUrlFor(e.url) : null;
3346 if (player) return { ...e, 'shaer:playerUrl': player };
3347 // The gate is shut and there IS something behind it. Saying so costs
3348 // nothing (the card already shows a video thumbnail) and saves the child
3349 // from tapping a card that will never answer: the app can explain instead
3350 // of doing nothing. It stays a statement of fact, never a way in.
3351 return playerUrlFor(e.url) ? { ...e, 'shaer:playable': true } : e;
3352 } catch { return undefined; }
3353}
3354
3355/** The embeddable player for a URL, or null when we will not frame it. */
3356export function playerUrlFor(url) {
3357 if (typeof url !== 'string') return null;
3358 let p = null;
3359 try { p = AudioEmbedService.detectProvider(url); } catch { p = null; }
3360 if (p && p.provider === 'youtube' && p.id) return `https://www.youtube-nocookie.com/embed/${p.id}?rel=0&modestbranding=1&playsinline=1`;
3361 if (p && p.provider === 'vimeo' && p.id) return `https://player.vimeo.com/video/${p.id}`;
3362 // PeerTube is decentralised, so it is matched by its watch-URL shape rather
3363 // than a provider list. Host chars are validated before it is inlined.
3364 const pt = url.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
3365 if (pt) return `https://${pt[1]}/videos/embed/${pt[2]}`;
3366 return null;
3367}
3368
3369// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
3370// snapshot { url, author{name,handle,icon}, content, published, media } so the
3371// client can render it as a nested card instead of a bare link. Best-effort and
3372// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
3373// to the object-link chip. The content goes through the same sanitiser as every
3374// other note, so the kid-safe guarantees hold.
3375async function resolveQuote(note) {
3376 const url = quoteHrefOf(note);
3377 if (!url) return null;
3378 const q = await apGetJson(url);
3379 if (!q || typeof q !== 'object') return null;
3380 const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
3381 : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
3382 const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
3383 // The quoted post's own FEP-9098 emojis, so :shortcode: renders in the card.
3384 const emojis = {};
3385 try {
3386 for (const e of JSON.parse(extractEmojiTags(q.tag) || '[]')) {
3387 const u = e.icon && (e.icon.url || (Array.isArray(e.icon) && e.icon[0] && e.icon[0].url));
3388 if (typeof e.name === 'string' && u) emojis[e.name] = u;
3389 }
3390 } catch { /* ignore */ }
3391 let media = []; try { media = JSON.parse(mediaFromNote(q)); } catch { /* ignore */ }
3392 const snapshot = {
3393 url: safeUrl(q.url || q.id || url) || url,
3394 author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
3395 content: HtmlSanitizerService.sanitize(q.content || ''),
3396 published: q.published || null,
3397 media,
3398 emojis: Object.keys(emojis).length ? emojis : undefined,
3399 };
3400 return JSON.stringify(snapshot);
3401}
3402
3403/**
3404 * The card under a post: a fediverse quote (FEP-044f) when the note has one,
3405 * otherwise an external link preview. Both render as the SAME card, so only one
3406 * of the two is ever stored. Returns {column, json} or null.
3407 *
3408 * Both halves reach out over the network, which is why every caller runs this
3409 * out of band: an inbox answer must never wait on a third party.
3410 */
3411async function resolveCard(o) {
3412 if (quoteHrefOf(o)) {
3413 const qj = await resolveQuote(o);
3414 return qj ? { column: 'quote_json', json: qj } : null;
3415 }
3416 const ej = await resolveExternalEmbed(o && o.content);
3417 return ej ? { column: 'embed_json', json: ej } : null;
3418}
3419
3420// A generic SSRF-safe AP GET (collections / pages).
3421/**
3422 * A signed GET as one of our local actors (friends-history, 30-7): the remote
3423 * server can then recognise the caller and serve what THAT caller may see,
3424 * exactly like the guardian's authorized fetch. The signature covers
3425 * (request-target) host date, the set verifyRequest checks.
3426 */
3427async function signedGetJson(slug, url) {
3428 try {
3429 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3430 if (!base || !slug) return apGetJson(url);
3431 const me = actorId(base, slug);
3432 const keys = getOrCreateKeys(slug);
3433 const u = new URL(url);
3434 const date = new Date().toUTCString();
3435 const target = `${u.pathname}${u.search || ''}`;
3436 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
3437 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
3438 const sig = `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`;
3439 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json', Date: date, Signature: sig } });
3440 if (!r.ok) return null;
3441 const len = Number(r.headers.get('content-length') || 0);
3442 if (len > 3_000_000) return null;
3443 return await r.json();
3444 } catch { return null; }
3445}
3446
3447async function apGetJson(url) {
3448 try {
3449 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
3450 if (!r.ok) return null;
3451 const len = Number(r.headers.get('content-length') || 0);
3452 if (len > 3_000_000) return null;
3453 return await r.json();
3454 } catch { return null; }
3455}
3456// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
3457// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
3458// history-from-before-you-followed or a delivery that was missed while you were down;
3459// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
3460export async function backfillFromOutbox(slug, actorUri, limit = 20) {
3461 try {
3462 if (!slug || !actorUri) return 0;
3463 const actor = await fetchActor(actorUri);
3464 if (!actor || !actor.outbox) return 0;
3465 // Signed as the follower (30-7): the serving side recognises an accepted
3466 // friend and hands the friends-only history along; an anonymous GET only
3467 // ever sees the public set. A server that ignores the signature behaves
3468 // exactly as before.
3469 let page = await signedGetJson(slug, typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
3470 let items = (page && (page.orderedItems || page.items)) || [];
3471 if (!items.length && page && page.first) {
3472 page = await signedGetJson(slug, typeof page.first === 'string' ? page.first : page.first.id);
3473 items = (page && (page.orderedItems || page.items)) || [];
3474 }
3475 if (!Array.isArray(items) || !items.length) return 0;
3476 const ai = actorInfo(actor, actorUri);
3477 let added = 0;
3478 for (const it of items.slice(0, limit)) {
3479 // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
3480 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
3481 if (!o || !o.id) continue;
3482 if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
3483 if (o.inReplyTo) continue; // top-level only
3484 const auth = actorUriOf(o.attributedTo);
3485 if (auth && auth !== actorUri) continue; // their OWN posts only
3486 const html = HtmlSanitizerService.sanitize(o.content || '');
3487 const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
3488 try {
3489 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, o.summary || null);
3490 if (r && r.changes > 0) added++;
3491 // FEP-9098: keep custom-emoji tags from backfilled posts too.
3492 { 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 */ } } }
3493 storeAuthorEmoji(o.id, slug, ai); // custom-emoji display name for the byline
3494 // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
3495 { 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 */ } } }
3496 // FEP-044f: resolve the embedded quote card for backfilled posts too.
3497 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 */ } } }
3498 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
3499 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 */ } }
3500 } catch { /* ignore */ }
3501 }
3502 if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
3503 return added;
3504 } catch { return 0; }
3505}
3506
3507// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
3508// Most replies reach us by delivery, but replies-to-replies that live on other servers and
3509// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
3510// we DO have, caching any newly-found ones in ap_interactions.
3511//
3512// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
3513// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
3514// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
3515// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
3516// never runs in a page request — the view renders from cache; a stale post kicks off a
3517// background refresh for the NEXT view.
3518const THREAD_TTL_MS = 15 * 60 * 1000; // don't re-crawl a post more than ~4×/hour
3519const THREAD_MAX_DEPTH = 1; // one hop per crawl (like Mastodon); deeper fills in over crawls
3520const THREAD_MAX_FETCHES = 30; // hard cap on remote GETs per crawl (be a good peer)
3521const _crawlingThreads = new Set(); // per-post in-flight lock (no stampede across views)
3522
3523function threadCrawlTs(postId) {
3524 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
3525 catch { return 0; }
3526}
3527function setThreadCrawlTs(postId, ts) {
3528 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)); }
3529 catch { /* ignore */ }
3530}
3531
3532// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
3533// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
3534async function collectReplyItems(repliesRef, maxPages, budget) {
3535 const uris = [];
3536 let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
3537 if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
3538 let pages = 0;
3539 while (node && pages++ < maxPages) {
3540 for (const it of (node.items || node.orderedItems || [])) {
3541 const u = typeof it === 'string' ? it : (it && it.id);
3542 if (u && /^https?:\/\//i.test(u)) uris.push(u);
3543 }
3544 if (!node.next) break;
3545 node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
3546 }
3547 return uris;
3548}
3549
3550async function crawlThread(postId) {
3551 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3552 if (!base) return;
3553 // Seed frontier = the remote reply note URIs we already have; also the dedup set.
3554 let known;
3555 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)); }
3556 catch { return; }
3557 const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
3558 if (!seeds.length) return; // nothing remote to expand
3559 // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
3560 // crawler never re-adds them via thread-filling (they're gone from the seeds
3561 // already because rejectInteraction deleted their ap_interactions row).
3562 try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
3563 catch { /* table always exists after boot migration */ }
3564
3565 let fetches = 0;
3566 const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
3567 const visited = new Set(); // notes whose replies collection we've already expanded
3568 let frontier = seeds.slice();
3569 let added = 0;
3570
3571 for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
3572 const nextFrontier = [];
3573 for (const noteUri of frontier) {
3574 if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
3575 visited.add(noteUri);
3576 const note = await budget.get(noteUri);
3577 if (!note || !note.replies) continue;
3578 const childUris = await collectReplyItems(note.replies, 2, budget);
3579 for (const cu of childUris) {
3580 if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
3581 known.add(cu);
3582 const child = await budget.get(cu);
3583 if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
3584 if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
3585 const actorUri = actorUriOf(child.attributedTo);
3586 if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
3587 const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
3588 const ai = actorInfo(actor, actorUri);
3589 const html = HtmlSanitizerService.sanitize(child.content || '');
3590 // The child replies to `note` by construction (it's in note's replies collection).
3591 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 */ }
3592 nextFrontier.push(child.id); // expand this reply's own replies next depth
3593 }
3594 }
3595 frontier = nextFrontier;
3596 }
3597 if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
3598}
3599
3600// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
3601// fires a background crawl only if this post hasn't been crawled within the TTL.
3602export function maybeCrawlThread(postId) {
3603 if (!postId || _crawlingThreads.has(postId)) return;
3604 if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
3605 _crawlingThreads.add(postId);
3606 setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
3607 crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
3608}
3609
3610let _selfHealing = false;
3611export async function selfHealTimeline() {
3612 if (_selfHealing) return; _selfHealing = true;
3613 try {
3614 let cur = 0;
3615 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; }
3616 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
3617 // v21: direct notes used to land in the timeline as if they were posts, so a
3618 // ward's 🛟 help request showed up in the guardian's Krant. The insert now
3619 // refuses them; drop the ones already cached. Scoped to the two kinds we can
3620 // still recognise afterwards (help request, wave) — a plain public mention
3621 // from someone you follow IS a timeline post and must stay.
3622 try {
3623 const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
3624 SELECT 1 FROM ap_mentions m
3625 WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
3626 AND (m.help_request = 1 OR m.wave = 1))`).run();
3627 if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
3628 } catch { /* table may predate the columns */ }
3629 let rows = [];
3630 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 */ }
3631 let healed = 0, failed = 0;
3632 for (const r of rows) {
3633 // Link previews first, and deliberately BEFORE the note re-fetch. A
3634 // preview is resolved from the content we already hold, so hanging it
3635 // behind a remote fetch meant one unreachable origin skipped the whole
3636 // row (`continue` below) and the card never appeared. It needs nothing
3637 // from the origin, so it must not depend on it.
3638 if (!r.quote_json && !r.embed_json) {
3639 try {
3640 const ej = await resolveExternalEmbed(r.content);
3641 if (ej) db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id);
3642 } catch { /* best-effort, never blocks the heal */ }
3643 }
3644 try {
3645 const note = await fetchNoteAP(r.id);
3646 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
3647 if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
3648 const html = HtmlSanitizerService.sanitize(note.content || '');
3649 const media = mediaFromNote(note);
3650 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
3651 const cw = note.summary || null;
3652 const url = note.url || null; // re-sync the human url (catches a remote slug rename)
3653 const emoji = extractEmojiTags(note.tag); // FEP-9098: re-capture custom-emoji tags (v8)
3654 const link = extractLinkJson(note); // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
3655 // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
3656 // cached snapshot if the quoted post is momentarily unreachable now.
3657 const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
3658 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 || '')) {
3659 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);
3660 healed++;
3661 }
3662 // v13: a custom-emoji display name needs the author's emoji map. Fetch
3663 // the actor once, only for rows whose name has a shortcode and no map yet.
3664 if (/:[A-Za-z0-9_+-]+:/.test(r.author_name || '') && !r.author_emoji_json && r.author_uri) {
3665 const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
3666 if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
3667 }
3668 // v14: same for the booster's display name ("X boosted"). The row stores
3669 // no booster URI, so resolve it from the handle via webfinger. Scoped to
3670 // this exact row (slug) since a note can be boosted by different people.
3671 if (/:[A-Za-z0-9_+-]+:/.test(r.reblog_name || '') && !r.reblog_emoji_json && r.reblog_handle) {
3672 const bUri = await webfingerResolve(r.reblog_handle);
3673 const em = bUri ? actorNameEmojis(await fetchActor(bUri)) : undefined;
3674 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 */ } }
3675 }
3676 } catch { failed++; /* per-note best-effort */ }
3677 }
3678 // Only mark this version DONE after a clean pass. Some origins are briefly
3679 // offline exactly when we heal (phone-hosted instances!): skipping them and
3680 // consuming the version would leave those rows stale forever. Instead retry
3681 // on the next boots, giving up after a few attempts (permanently-dead
3682 // origins answer 404/410 and are deleted above, so they don't loop).
3683 const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
3684 let attempts = 0;
3685 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 */ }
3686 if (failed === 0 || attempts >= 4) {
3687 setSetting('selfheal_version', SELFHEAL_VERSION);
3688 setSetting('selfheal_attempts', 0);
3689 } else {
3690 setSetting('selfheal_attempts', attempts + 1);
3691 }
3692 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
3693 } catch { /* never block boot */ } finally { _selfHealing = false; }
3694}
3695
3696// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
3697export async function followActor(site, handle, autoBoost = false) {
3698 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3699 if (!base || !site || !site.slug) return { error: 'config' };
3700 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
3701 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
3702 // resolves to its AP actor, so you can follow a site by just its domain.
3703 const s = String(handle || '').trim();
3704 let actorUrl;
3705 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
3706 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
3707 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
3708 else actorUrl = null;
3709 if (!actorUrl) return { error: 'not_found' };
3710 // SIGNED, as this actor: an authorized-fetch instance refuses an anonymous
3711 // GET of the actor doc, which made following from a boost silently fail
3712 // (Robins melding, 31-7). Signed, the other side sees who asks.
3713 const actor = await signedGetJson(site.slug, actorUrl);
3714 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
3715 const ai = actorInfo(actor, actor.id);
3716 const me = actorId(base, site.slug);
3717 const keys = getOrCreateKeys(site.slug);
3718 const followId = `${me}#follow-${Date.now()}-${rid()}`;
3719 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
3720 const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
3721 // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
3722 // timeout, transient 5xx) is retried with backoff instead of staying stuck on
3723 // 'pending' forever — the Accept can only come back once the Follow lands.
3724 await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
3725 console.log('[AP] follow', site.slug, '→', actor.id);
3726 // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
3727 if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
3728 // A ward's guardians are TOLD about a new follow (Robins verzoek, 31-7):
3729 // a follow brings new content into the child's feed, and the village
3730 // should know the door opened. A direct note per guardian, best-effort;
3731 // FEP-633c 5.3 gates inbound follows, the outbound notice is Shaer policy
3732 // for now (bead: spec-vraag).
3733 try {
3734 const guardians = Guardianship.listGuardians(site.slug);
3735 if (guardians.length) {
3736 const meRef = actorId(base, site.slug);
3737 const esc = (t) => String(t).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
3738 const label = esc(ai.name || ai.handle || actor.id);
3739 for (const g of guardians) {
3740 const note = {
3741 id: `${meRef}/follow-notice/${Date.now().toString(36)}${rid()}`,
3742 type: 'Note', attributedTo: meRef, to: [g.other_uri],
3743 tag: [{ type: 'Mention', href: g.other_uri }],
3744 content: `<p>👀 ${esc(site.title || site.slug)} is now following ${label}.</p>`,
3745 };
3746 deliverToActor(site, g.other_uri, { id: `${note.id}#create`, type: 'Create', actor: meRef, to: [g.other_uri], object: note })
3747 .catch(() => { /* retried by the queue */ });
3748 }
3749 console.log('[AP] follow notice →', guardians.length, 'guardian(s) of', site.slug);
3750 }
3751 } catch { /* geen guardians is geen fout */ }
3752 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
3753}
3754
3755// Resolve a profile URL or @handle to a followable remote actor (for the
3756// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
3757// when it isn't a reachable actor (e.g. the input was a post, not a profile).
3758export async function resolveRemoteActor(input) {
3759 const s = String(input || '').trim();
3760 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
3761 if (!actorUrl) return null;
3762 const actor = await fetchActor(actorUrl).catch(() => null);
3763 if (!actor || !actor.id || !actor.inbox) return null;
3764 const ai = actorInfo(actor, actor.id);
3765 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
3766}
3767
3768export async function unfollowActor(site, actorUri) {
3769 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3770 const me = actorId(base, site.slug);
3771 const keys = getOrCreateKeys(site.slug);
3772 const row = fwStmts().one.get(site.slug, actorUri);
3773 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
3774 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
3775 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
3776 // rather than send an unmatchable one. Deliver durably via the retry queue.
3777 if (row && row.inbox && row.follow_id) {
3778 const undo = { '@context': AP_CONTEXT, id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id, type: 'Follow', actor: me, object: actorUri } };
3779 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
3780 } else if (row && row.inbox) {
3781 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
3782 }
3783 fwStmts().del.run(site.slug, actorUri);
3784 return { ok: true };
3785}
3786
3787/**
3788 * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
3789 * still change): an account our sites follow says it moved to a new home.
3790 *
3791 * Validity has two independent legs, and both must hold:
3792 * 1. The SIGNER is a party to the move: the old actor announcing its own move
3793 * (push mode) or the new actor doing it (pull mode). A third party
3794 * narrating someone else's move is refused — without this, any signed
3795 * stranger could re-point our follows.
3796 * 2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
3797 * cross-side proof: the mover controls both ends. Without it, whoever
3798 * holds ONE end could hijack the other end's followers.
3799 *
3800 * Effect: every local site following the old actor unfollows it and follows
3801 * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
3802 * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
3803 * feed subscription, and moving one is shaer-tge's gated decision, not a
3804 * side effect of an inbox event. We only log when a move touches one.
3805 *
3806 * Deps are injectable for tests (no network in node:test).
3807 */
3808export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
3809 const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
3810 const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
3811 if (!oldUri || !newUri || oldUri === newUri) return 400;
3812 if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
3813 console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
3814 return 401;
3815 }
3816 // Nobody here follows the old actor → nothing to move. This also makes
3817 // redelivery idempotent: after the first swap the rows are gone.
3818 let rows = [];
3819 try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
3820 if (!rows.length) return 202;
3821 // A blocked destination is declined outright: the old follow stays (it goes
3822 // stale on its own), and we will not open a door to a blocked house.
3823 if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
3824 const target = await (fetchActorFn || fetchActor)(newUri);
3825 const aka = [].concat((target && target.alsoKnownAs) || [])
3826 .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
3827 if (!target || !target.id || !aka.includes(oldUri)) {
3828 console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
3829 return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
3830 }
3831 for (const row of rows) {
3832 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
3833 if (!site) continue;
3834 try {
3835 await (unfollowFn || unfollowActor)(site, oldUri);
3836 const already = fwStmts().one.get(row.slug, newUri);
3837 if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
3838 console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
3839 } catch (e) {
3840 console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
3841 }
3842 }
3843 try {
3844 const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
3845 if (g.length) console.warn('[AP] Move touches a guardianship party — left untouched (shaer-tge):', oldUri, '→', g.map((r) => `${r.role}:${r.slug}`).join(', '));
3846 } catch { /* table absent on fresh init */ }
3847 return 202;
3848}
3849
3850/**
3851 * Slice 2 van shaer-0j2 (FEP-7628, DRAFT): de UITGAANDE helft — deze Klonkt
3852 * is het oude huis en kondigt het vertrek aan. Twee eisen voordat er iets
3853 * de deur uit gaat:
3854 * 1. Geen guardians: een warded account verhuizen zonder de guardianship
3855 * te hertargeten zou het vangnet van het kind stil breken; dat is
3856 * shaer-tge's gated beslissing, dus tot die er is weigert een bewaakt
3857 * account de verhuizing.
3858 * 2. De NIEUWE actor claimt ons in alsoKnownAs — dezelfde back-reference
3859 * die elke ontvangende server (onze eigen slice 1 incluis) eist. Zonder
3860 * die claim is de Move overal dood bij aankomst.
3861 * De Move gaat duurzaam naar elke volger-inbox; hun servers doen de
3862 * re-follow. `moved_to` wordt hier vastgelegd; het SERVEREN ervan op de
3863 * actor (en het beleid van de oude site) is slice 3.
3864 * Deps injecteerbaar voor tests (geen netwerk in node:test).
3865 */
3866export async function moveAccount(site, targetRaw, { fetchActorFn = null, deliverFn = null } = {}) {
3867 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3868 if (!base || !site || !site.slug) return { error: 'config' };
3869 try {
3870 const guardians = Guardianship.listGuardians(site.slug);
3871 if (guardians.length) {
3872 console.warn('[AP] move refused: guarded account (shaer-tge):', site.slug, '→', String(targetRaw || ''));
3873 return { error: 'guarded_account' };
3874 }
3875 } catch { /* geen guardianship-tabellen = geen guardians */ }
3876 const s = String(targetRaw || '').trim();
3877 let targetUri = null;
3878 if (/^https?:\/\//i.test(s)) targetUri = safeUrl(s);
3879 else if (s.includes('@')) targetUri = await webfingerResolve(s);
3880 if (!targetUri) return { error: 'not_found' };
3881 const me = actorId(base, site.slug);
3882 if (targetUri === me) return { error: 'self' };
3883 const target = await (fetchActorFn ? fetchActorFn(targetUri) : signedGetJson(site.slug, targetUri));
3884 if (!target || !target.id || !target.inbox) return { error: 'unreachable' };
3885 const aka = [].concat(target.alsoKnownAs || [])
3886 .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
3887 if (!aka.includes(me)) return { error: 'no_backreference' };
3888 db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(target.id, site.slug);
3889 const keys = getOrCreateKeys(site.slug);
3890 const move = {
3891 '@context': AP_CONTEXT,
3892 id: `${me}#move-${Date.now()}-${rid()}`,
3893 type: 'Move',
3894 actor: me,
3895 object: me,
3896 target: target.id,
3897 to: [`${me}/followers`],
3898 };
3899 // FEP-7628: after setting movedTo, notify the followers with an Update of
3900 // the actor, so their servers hold the signpost even if the Move itself is
3901 // lost. Built from the FRESH row: `site` still carries the pre-move values.
3902 const movedSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(site.slug) || { ...site, moved_to: target.id };
3903 const update = {
3904 '@context': AP_CONTEXT,
3905 id: `${me}#update-${Date.now()}-${rid()}`,
3906 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
3907 object: buildActor(base, movedSite),
3908 published: new Date().toISOString(),
3909 };
3910 const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
3911 const send = deliverFn || deliverWithRetry;
3912 for (const inbox of inboxes) {
3913 await send(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
3914 await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
3915 }
3916 console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
3917 return { ok: true, target: target.id, inboxes: inboxes.length };
3918}
3919
3920// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
3921/**
3922 * Who is reading this outbox, and what may they see (30-7)?
3923 * - 'blocked': a verified caller this instance blocks. They get an EMPTY
3924 * collection, not even the public set (Robins eis): a block is a closed
3925 * door, and a signed fetch is the caller knocking with their name on it.
3926 * - 'friend': the owner (bearer) or a verified accepted follower or
3927 * guardian: the fan-only history rides along.
3928 * - 'public': everyone else: the public set.
3929 */
3930export function outboxAudience(slug, { bearerSlug = null, verifiedActor = null } = {}) {
3931 if (bearerSlug && bearerSlug === slug) return 'friend';
3932 if (!verifiedActor) return 'public';
3933 if (isBlockedAny(verifiedActor)) return 'blocked';
3934 try {
3935 if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
3936 } catch { /* table absent on fresh init */ }
3937 if (isWardGuardian(slug, verifiedActor)) return 'friend';
3938 return 'public';
3939}
3940
3941// guardian of the local ward `wardSlug` — so a signed GET from it may read the
3942// ward's non-public history without the guardian appearing as a follower.
3943export function isWardGuardian(wardSlug, actorUri) {
3944 try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
3945}
3946
3947// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
3948// Accept to the follower and record them, so delivery (incl. followers-only)
3949// begins. `pending` is a row from ap_pending_follows.
3950export async function acceptGatedFollow(pending) {
3951 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3952 const slug = pending.ward_slug;
3953 const me = actorId(base, slug);
3954 const keys = getOrCreateKeys(slug);
3955 fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
3956 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
3957 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
3958 await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
3959 const filled = pending.follower_shared_inbox &&
3960 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);
3961 if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
3962 console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
3963 return { ok: true };
3964}
3965
3966// The guardians denied the follow: send a Reject so the follower's server clears
3967// its pending state, then the caller drops the record.
3968export async function rejectGatedFollow(pending) {
3969 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3970 const slug = pending.ward_slug;
3971 const me = actorId(base, slug);
3972 const keys = getOrCreateKeys(slug);
3973 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
3974 const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
3975 if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
3976 console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
3977 return { ok: true };
3978}
3979
3980// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
3981// offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
3982// 2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
3983async function handleFollowApprovalInbox(act, slugParam) {
3984 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3985 const type = Array.isArray(act.type) ? act.type[0] : act.type;
3986 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
3987
3988 // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
3989 // signed by the ward, so act.actor is the ward.
3990 if (type === 'Offer') {
3991 const fo = (act.object && typeof act.object === 'object') ? act.object : null;
3992 const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
3993 if (!fo || foType !== 'Follow') return false;
3994 const followId = fo.id;
3995 const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
3996 const wardUri = actorUri;
3997 if (!followId || !follower || !wardUri) return false;
3998 const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
3999 if (slugParam) recips.push(actorId(base, slugParam));
4000 let stored = false;
4001 for (const r of new Set(recips)) {
4002 const gslug = slugFromActorUrl(r);
4003 if (!gslug) continue;
4004 if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue; // must actually guard this ward
4005 const wardDoc = await fetchActor(wardUri).catch(() => null);
4006 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
4007 Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
4008 const L = pushLang(gslug);
4009 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
4010 stored = true;
4011 }
4012 return stored;
4013 }
4014
4015 // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
4016 const fo = act.object;
4017 const followId = typeof fo === 'string' ? fo : (fo && fo.id);
4018 if (!followId) return false;
4019 const pending = Guardianship.follows.getPending(followId);
4020 if (!pending) return false;
4021 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
4022 if (!allGuardians.includes(actorUri)) return false; // only a real guardian of this ward decides
4023 const decision = type === 'Reject' ? 'reject' : 'approve';
4024 // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
4025 // restored by the one-answer rule when its activity arrived, so answering
4026 // is exactly what counts a guardian back in.
4027 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
4028 const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
4029 try {
4030 if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
4031 else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
4032 } catch { /* delivery is retried */ }
4033 return true;
4034}
4035
4036// Leg 3: a guardian in /guardian decides on a forwarded follow; send the
4037// Accept/Reject back to the ward's inbox (signed by the guardian).
4038export async function sendFollowDecision(guardianSite, review, decision) {
4039 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4040 const me = actorId(base, guardianSite.slug);
4041 const keys = getOrCreateKeys(guardianSite.slug);
4042 const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
4043 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 };
4044 if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
4045 return { ok: true };
4046}
4047
4048// Send a Like or Announce (boost) on a remote note FROM this site.
4049export async function sendInteraction(site, kind, targetNoteId, authorUri) {
4050 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4051 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
4052 const me = actorId(base, site.slug);
4053 const keys = getOrCreateKeys(site.slug);
4054 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
4055 // reblog (matched on actor+object — no record of the original Announce needed).
4056 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
4057 const followersCol = `${me}/followers`;
4058 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
4059 // attributes the boost to their post and notifies them — without this, a shared-inbox
4060 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
4061 // stamp keep us aligned with what Mastodon emits.
4062 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
4063 let act;
4064 if (kind === 'unboost' || kind === 'unlike') {
4065 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
4066 // no record of the original activity needed — Mastodon honours both).
4067 const inner = kind === 'unboost' ? 'Announce' : 'Like';
4068 act = {
4069 '@context': AP_CONTEXT,
4070 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
4071 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
4072 };
4073 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
4074 } else {
4075 const type = kind === 'boost' ? 'Announce' : 'Like';
4076 act = {
4077 '@context': AP_CONTEXT,
4078 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
4079 type, actor: me, object: targetNoteId,
4080 };
4081 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
4082 }
4083 const inboxes = new Set();
4084 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
4085 // routes the Announce/Like to the right post unambiguously.
4086 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
4087 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
4088 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
4089 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
4090 // silently lose the boost — same durability a new post (deliverCreate) already gets.
4091 let queued = 0;
4092 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
4093 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
4094 return { ok: true, delivered: queued };
4095}
4096
4097// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
4098export function getNotifications(slug, limit) {
4099 // Per-source cap scales with the requested limit so Messages can page deep
4100 // (Load more). Bounded so a huge offset can't ask for unbounded rows.
4101 const L = Math.min(1000, Math.max(80, limit || 60));
4102 const out = [];
4103 try {
4104 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)) {
4105 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
4106 }
4107 } catch { /* ignore */ }
4108 try {
4109 const rows = db.prepare(`
4110 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
4111 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
4112 p.slug AS post_slug, p.title AS post_title
4113 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
4114 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
4115 ORDER BY i.created_at DESC LIMIT ?
4116 `).all(slug, L);
4117 for (const r of rows) out.push({
4118 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
4119 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
4120 // When the post was written, for display. created_at (when it reached us)
4121 // stays the sort key and the unread watermark: a note that federated late
4122 // is still new to you.
4123 published: r.published,
4124 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (messages render)
4125 media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json, // rendered like a Krant post
4126 // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
4127 visibility: r.visibility || 'public',
4128 });
4129 } catch { /* ignore */ }
4130 try {
4131 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)) {
4132 // The reported objects: our own notes resolve to post links so the owner
4133 // sees WHICH post the report is about; other URIs (e.g. the actor itself)
4134 // are skipped — the report row already names the account.
4135 const about = [];
4136 try {
4137 for (const u of JSON.parse(r.objects || '[]')) {
4138 const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
4139 if (!m) continue;
4140 const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
4141 if (p) about.push({ slug: p.slug, title: p.title || p.slug });
4142 }
4143 } catch { /* malformed objects json → no links */ }
4144 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 });
4145 }
4146 } catch { /* ignore */ }
4147 try {
4148 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,
4149 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
4150 FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
4151 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,
4152 // Same trimmings a Krant row has, so Berichten renders the post identically.
4153 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 });
4154 }
4155 } catch { /* ignore */ }
4156 // Your own polls that have closed → a "results are in" item, derived read-time
4157 // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
4158 try {
4159 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
4160 if (site) {
4161 const polls = db.prepare(`
4162 SELECT id, slug, title, poll_json FROM posts
4163 WHERE site_id = ? AND poll_json IS NOT NULL
4164 AND json_extract(poll_json, '$.closed') = 1
4165 AND json_extract(poll_json, '$.endTime') IS NOT NULL
4166 ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
4167 for (const p of polls) {
4168 const view = ownPollView(p);
4169 if (!view) continue;
4170 let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
4171 out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
4172 }
4173 }
4174 } catch { /* ignore */ }
4175 // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
4176 // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
4177 // between likes, which also broke Messages' like-grouping).
4178 out.sort((a, b) => _msgTs(b) - _msgTs(a));
4179 return out.slice(0, limit || 60);
4180}
4181function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
4182
4183// ── Blocking / defederation ───────────────────────────────────────
4184// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
4185// Orbit"). Thin delegations keep every existing caller working.
4186export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
4187
4188// True if an actor (or its whole domain) is blocked anywhere on this instance.
4189// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
4190// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
4191// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
4192// author's Update(Question) refreshes the authoritative totals when it arrives.
4193export async function voteOnPoll(site, questionId, choices) {
4194 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4195 if (!base || !site || !site.slug || !questionId) return { error: 'config' };
4196 let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
4197 if (!row || !row.poll_json) return { error: 'not_found' };
4198 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
4199 if (poll.closed) return { error: 'closed' };
4200 if (poll.voted) return { error: 'already' };
4201 const valid = new Set(poll.options.map((o) => o.name));
4202 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
4203 if (!picks.length) return { error: 'invalid' };
4204 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
4205 const me = actorId(base, site.slug);
4206 const keys = getOrCreateKeys(site.slug);
4207 const authorUri = row.author_uri || null;
4208 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
4209 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
4210 if (!inbox) return { error: 'unreachable' };
4211 for (const name of chosen) {
4212 const nid = `${me}/votes/${Date.now()}-${rid()}`;
4213 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
4214 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
4215 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
4216 }
4217 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
4218 poll.voted = poll.multiple ? chosen : chosen[0];
4219 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
4220 if (poll.voters != null) poll.voters += 1;
4221 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
4222 return { ok: true };
4223}
4224
4225// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
4226// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
4227// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
4228// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
4229export async function voteOnRemotePoll(site, questionUrl, choices) {
4230 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4231 if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
4232 const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
4233 if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
4234 const poll = parsePoll(q);
4235 if (!poll) return { error: 'not_found' };
4236 if (poll.closed) return { error: 'closed' };
4237 const valid = new Set(poll.options.map((o) => o.name));
4238 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
4239 if (!picks.length) return { error: 'invalid' };
4240 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
4241 const authorUri = actorUriOf(q.attributedTo);
4242 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
4243 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
4244 if (!inbox) return { error: 'unreachable' };
4245 const me = actorId(base, site.slug);
4246 const keys = getOrCreateKeys(site.slug);
4247 for (const name of chosen) {
4248 const nid = `${me}/votes/${Date.now()}-${rid()}`;
4249 const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
4250 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
4251 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
4252 }
4253 return { ok: true };
4254}
4255
4256// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
4257// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
4258// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
4259// author is resolved + included) OR pass actorUri to report an account directly.
4260export async function sendReport(site, { objectUri, actorUri, reason }) {
4261 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4262 if (!base || !site || !site.slug) return { error: 'config' };
4263 let targetActor = actorUri || null;
4264 let noteUri = null;
4265 if (objectUri && /^https?:\/\//i.test(objectUri)) {
4266 const note = await apGetJson(objectUri).catch(() => null);
4267 if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
4268 else if (!targetActor) return { error: 'not_found' };
4269 }
4270 if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
4271 const actor = await fetchActor(targetActor).catch(() => null);
4272 const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
4273 if (!inbox) return { error: 'unreachable' };
4274 const me = actorId(base, site.slug);
4275 const keys = getOrCreateKeys(site.slug);
4276 const object = [targetActor];
4277 if (noteUri && noteUri !== targetActor) object.push(noteUri);
4278 const flag = {
4279 '@context': AP_CONTEXT,
4280 id: `${me}#report-${Date.now()}-${rid()}`,
4281 type: 'Flag',
4282 actor: me,
4283 content: String(reason == null ? '' : reason).slice(0, 3000),
4284 object, // [account, status?] — Mastodon's Flag shape
4285 to: [targetActor],
4286 };
4287 deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
4288 return { ok: true };
4289}
4290
4291export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
4292
4293// Block an actor (@handle or actor URL) or a whole domain; purges their content.
4294// The handle resolver is ours; the storage/purge lives in BlocklistService.
4295export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
4296
4297export function unblock(site, target) { return Blocklist.unblock(site, target); }
4298
4299// ── Guardianship module wiring (src/services/guardianship/) ────────
4300// The module owns FEP-633c (context, relations, handshake, queues, the
4301// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
4302// imports us back.
4303function selfActorId(slug) {
4304 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4305 return actorId(base, slug);
4306}
4307// Deliver one activity to one actor's inbox, signed; queued + retried on any
4308// hiccup so a slow or briefly-down ward server never loses the offer. Returns
4309// { delivered, inbox }: delivered=false means the account could not be
4310// resolved at all (a bad handle) — the offer stays recorded regardless.
4311export async function deliverToActor(site, actorUri, activity) {
4312 const me = selfActorId(site.slug);
4313 const keys = getOrCreateKeys(site.slug);
4314 const payload = { '@context': AP_CONTEXT, ...activity };
4315 // Co-location is a TRANSPORT detail, never a decision path (Robins regel,
4316 // 29-7). An inbox on this machine is not reachable over HTTP from this
4317 // machine, and should not be, so a local recipient is handed the activity
4318 // straight into the same inbox handler the wire would reach. Everything
4319 // above this line therefore behaves as if every Klonkt were remote: one code
4320 // path, exercised by every deployment, including the checks. Two bugs in one
4321 // day came from having a second, local-only path that hid a broken remote
4322 // one.
4323 const localSlug = localSlugOf(actorUri);
4324 if (localSlug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(localSlug)) {
4325 const host = (() => { try { return new URL(selfActorId(site.slug)).host; } catch { return ''; } })();
4326 const req = { body: payload, ip: 'loopback', protocol: 'https', get: () => host, headers: {} };
4327 // The signer is us, and we say so: the actor-versus-signer check runs
4328 // exactly as it does over the wire, so a mismatch fails here too.
4329 const status = await handleInbox(req, localSlug, { id: me }).catch(() => 500);
4330 const ok = status >= 200 && status < 300;
4331 console.log('[AP]', activity.type, ok ? 'delivered (loopback) →' : `got ${status} (loopback) from`, actorUri);
4332 return { delivered: ok, inbox: `${actorUri}/inbox`, loopback: true, status };
4333 }
4334 const a = await fetchActor(actorUri).catch(() => null);
4335 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
4336 if (!inbox) {
4337 console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
4338 return { delivered: false, inbox: null };
4339 }
4340 try {
4341 const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
4342 if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
4343 console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
4344 } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
4345 enqueueDelivery(site.slug, inbox, payload);
4346 return { delivered: true, inbox }; // queued: the retry worker gets it there
4347}
4348Guardianship.wireDelivery({
4349 actorId, fetchActor, localActor, deliverTo: deliverToActor, deriveHandle, escHtml, linkUrls, linkHashtags,
4350 getOutboxRow: (id) => iStmts().getO.get(id),
4351 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
4352});
4353/**
4354 * The actor document of a site WE host, read straight from the database.
4355 * Same shape fetchActor returns for anyone else, plus `local: true` so the
4356 * caller can take the loopback instead of a POST to our own hostname.
4357 * Null for an actor we do not host: that one really is fetched.
4358 */
4359function localActor(actorUri) {
4360 const slug = localSlugOf(actorUri);
4361 if (!slug) return null;
4362 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4363 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
4364 if (!site) return null;
4365 // primary_slug is what buildActor uses to pick '/' over '/user/<slug>'; the
4366 // actor route sets it the same way before building.
4367 const p = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get();
4368 try { return { ...buildActor(base, { ...site, primary_slug: p && p.slug }), local: true }; } catch { return null; }
4369}
4370// Which local site (if any) hosts this actor URI — used by the handshake to
4371// apply the local side of a commit and to derive a ward's existing guardians.
4372function localSlugOf(actorUri) {
4373 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4374 if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
4375 const slug = slugFromActorUrl(actorUri);
4376 if (!slug) return null;
4377 try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
4378 catch { return null; }
4379}
4380Guardianship.wireHandshake({
4381 selfId: selfActorId,
4382 localSlug: localSlugOf,
4383 deliverTo: deliverToActor,
4384 deriveHandle,
4385 fetchActor,
4386 // Guardian PWA / Berichten push. The kid answers an incoming offer in its
4387 // own Berichten; an existing guardian and a commit land in the PWA.
4388 onEvent: (slug, ev) => {
4389 const L = pushLang(slug);
4390 const texts = {
4391 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward
4392 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward
4393 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
4394 // §3.2: a guardian ended the relation. The ward hears that someone who
4395 // was looking after them has gone; a co-guardian hears they are one fewer.
4396 guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
4397 coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
4398 }[ev.kind];
4399 if (!texts) return;
4400 const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
4401 const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
4402 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
4403 },
4404});
4405
4406// The notification duty of FEP-633c 3.6.2, wired once for every place a
4407// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
4408// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
4409// one-answer rule is worthless to someone who does not know an answer is
4410// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
4411// is the same door this delivery knocks on; both attempts are logged.
4412Guardianship.wireAvailability({
4413 onDormant: (wardSlug, guardianUri) => {
4414 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
4415 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
4416 if (!base || !site) return;
4417 const me = selfActorId(wardSlug);
4418 const note = {
4419 id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
4420 type: 'Note', attributedTo: me, to: [guardianUri],
4421 'shaer:dormant': true,
4422 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>',
4423 };
4424 deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
4425 .catch(() => { /* retried by the queue */ });
4426 console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
4427 },
4428});
4429
4430export default {
4431 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
4432 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
4433 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
4434 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
4435 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
4436 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
4437 acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
4438 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
4439 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
4440 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
4441 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
4442 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
4443 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
4444 noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
4445 getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor, getReplyMessages, onNews, wakeNews,
4446};
Note: See TracBrowser for help on using the repository browser.