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

main
Last change on this file since af5b79b was af5b79b, checked in by Robin <roboburr@…>, 7 weeks ago

FEP-633c §2.2: shaer:hasGuardians (outbound stempelen, inbound registreren)

Objecten van een ward krijgen nu de advies-hint shaer:hasGuardians, zodat een
remote server interacties met de post naar de guardians kan routen zonder eerst
de actor op te halen. Veilig te negeren door wie geen shaer spreekt.

Outbound: elke Note die een ward publiceert wordt gestempeld (alle drie de
buildNote-paden: gewone post, paid-teaser, reply/direct), dus ook via buildCreate
in de outbox. Inbound: de hint wordt alleen GEREGISTREERD op het opgeslagen object
(ap_timeline.has_guardians / ap_mentions.has_guardians), nog geen actie. Wordt
later bekendgemaakt bij reddings-boei / escalatie-routing (Robins besluit).

Changed files:
src/services/guardianship/notes.js

  • hasGuardiansProps(slug) (stempel als de site guardians heeft) + objectHasGuardians(o)

src/services/guardianship/index.js

  • beide geexporteerd

src/services/ActivityPubService.js

  • buildNote stempelt in alle drie de return-paden; inbound zet has_guardians op timeline + mention

src/config/database.js

  • ap_timeline.has_guardians + ap_mentions.has_guardians

test/has-guardians.test.js

  • ward stempelt wel, free niet; objectHasGuardians leest de hint

remarks: npm test 170/170. Alleen ward-objecten dragen de hint; niks acteert er nog
op (register-only). Daemon heeft de constante al (dead_code); wiren kan later.

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

  • Property mode set to 100644
File size: 187.4 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 dns from 'dns';
20import net from 'net';
21import db from '../config/database.js';
22import HtmlSanitizerService from './HtmlSanitizerService.js';
23import AudioEmbedService from './AudioEmbedService.js';
24import Push from './PushService.js';
25import { getTenancy } from './SettingsService.js';
26import { t as i18nT } from './i18n.js';
27import Blocklist from './BlocklistService.js';
28import * as Guardianship from './guardianship/index.js';
29
30const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
31// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
32// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
33// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
34// This is the same context shape Mastodon publishes, so Mastodon sees no change.
35const AP_CONTEXT = [
36 'https://www.w3.org/ns/activitystreams',
37 'https://w3id.org/security/v1',
38 {
39 toot: 'http://joinmastodon.org/ns#',
40 schema: 'http://schema.org#',
41 sensitive: 'as:sensitive',
42 Hashtag: 'as:Hashtag',
43 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
44 discoverable: 'toot:discoverable',
45 featured: { '@id': 'toot:featured', '@type': '@id' },
46 PropertyValue: 'schema:PropertyValue',
47 value: 'schema:value',
48 embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
49 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
50 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
51 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
52 votersCount: 'toot:votersCount',
53 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
54 // module (src/services/guardianship/).
55 ...Guardianship.SHAER_CONTEXT,
56 },
57];
58
59// Short random suffix so two activity ids minted in the same millisecond (e.g.
60// parallel saves) don't collide and get deduped by a receiver.
61const rid = () => crypto.randomBytes(4).toString('hex');
62
63// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
64// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
65const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
66
67// ── SSRF guard for outbound fetches ───────────────────────────────
68// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
69// every outbound fetch must refuse hosts that resolve to private/loopback ranges
70// (cloud metadata, internal services) — on the initial host AND each redirect hop.
71function isBlockedIp(ip) {
72 if (!ip) return true;
73 const v = net.isIP(ip);
74 if (v === 4) {
75 const o = ip.split('.').map(Number);
76 return o[0] === 127 || o[0] === 10 || o[0] === 0
77 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
78 || (o[0] === 192 && o[1] === 168)
79 || (o[0] === 169 && o[1] === 254)
80 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
81 }
82 if (v === 6) {
83 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
84 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
85 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
86 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
87 }
88 return true; // not an IP literal we recognise → refuse
89}
90async function assertPublicHost(hostname) {
91 if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
92 const addrs = await dns.promises.lookup(hostname, { all: true });
93 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
94}
95export async function safeFetch(url, opts = {}, maxRedirects = 3) {
96 let target = url;
97 for (let hop = 0; ; hop++) {
98 const u = new URL(target); // throws on malformed → caller's catch
99 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
100 await assertPublicHost(u.hostname);
101 const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
102 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
103 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
104 return r;
105 }
106}
107const MAX_OUTBOX = 20;
108// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
109// (square) player card. Bump this whenever the twitter:player card dimensions change.
110const FEDI_CARD_VER = '2';
111
112// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
113// Prepared lazily (NOT at module load) — the ap_keys table is created in
114// initializeDatabase(), which runs after this module is imported.
115let _sel, _ins;
116function keyStmts() {
117 if (!_sel) {
118 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
119 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
120 }
121 return { sel: _sel, ins: _ins };
122}
123
124export function getOrCreateKeys(slug) {
125 const { sel, ins } = keyStmts();
126 const row = sel.get(slug);
127 if (row) return row;
128 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
129 modulusLength: 2048,
130 publicKeyEncoding: { type: 'spki', format: 'pem' },
131 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
132 });
133 ins.run(slug, publicKey, privateKey);
134 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
135}
136
137// ── content negotiation ───────────────────────────────────────────
138// True when the caller wants ActivityPub JSON rather than the HTML page.
139export function apWants(req) {
140 const a = String(req.headers.accept || '').toLowerCase();
141 return a.includes('application/activity+json') ||
142 (a.includes('application/ld+json') && a.includes('activitystreams'));
143}
144
145const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
146export function sendAP(res, obj, cacheControl) {
147 res.type(AP_CONTENT_TYPE);
148 // A per-caller (e.g. guardian-widened) view must not be publicly cached.
149 res.set('Cache-Control', cacheControl || 'public, max-age=120');
150 res.send(JSON.stringify(obj));
151}
152
153// ── document builders ─────────────────────────────────────────────
154export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
155export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
156
157export function buildActor(base, site) {
158 const id = actorId(base, site.slug);
159 const keys = getOrCreateKeys(site.slug);
160 const actor = {
161 '@context': AP_CONTEXT,
162 id,
163 type: 'Person',
164 preferredUsername: site.slug,
165 name: site.title || site.slug,
166 summary: site.tagline || site.description || '',
167 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
168 manuallyApprovesFollowers: false,
169 discoverable: true,
170 inbox: `${id}/inbox`,
171 outbox: `${id}/outbox`,
172 followers: `${id}/followers`,
173 following: `${id}/following`,
174 featured: `${id}/featured`,
175 // AP §5.6: the private blocked collection (owner-only GET). The server
176 // list is the source of truth for Shaer's "in Orbit"; clients keep no
177 // separate state.
178 blocked: `${id}/blocked`,
179 // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
180 // (guardianship module owns these).
181 ...Guardianship.guardianshipActorProps(id, site.slug),
182 // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
183 // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
184 // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
185 endpoints: {
186 sharedInbox: `${base}/ap/inbox`,
187 oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
188 oauthTokenEndpoint: `${base}/oauth/token`,
189 uploadMedia: `${id}/uploadMedia`,
190 },
191 publicKey: {
192 id: `${id}#main-key`,
193 owner: id,
194 publicKeyPem: keys.public_pem,
195 },
196 };
197 if (site.profile_photo) {
198 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
199 actor.icon = { type: 'Image', url: u };
200 }
201 // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
202 if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
203 // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
204 // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
205 try {
206 const links = JSON.parse(site.profile_links || '[]');
207 if (Array.isArray(links) && links.length) {
208 const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
209 const rows = links
210 .filter((l) => l && l.url && /^https?:/i.test(l.url))
211 .map((l) => ({
212 type: 'PropertyValue',
213 name: esc(l.platform || 'Link'),
214 value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
215 }));
216 if (rows.length) actor.attachment = rows;
217 }
218 } catch { /* skip malformed profile_links */ }
219 return actor;
220}
221
222// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
223// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
224// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
225export function hasPlayableAudio(content, siteId) {
226 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
227 try {
228 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; }
229 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; }
230 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; }
231 } catch { /* non-fatal */ }
232 return false;
233}
234
235// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
236export function buildNote(base, site, post, opts = {}) {
237 // Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
238 // (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
239 // machinery, addressed to the parent actor + thread. This early branch keeps that output
240 // byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
241 // this branch collapses and replies flow through the full post pipeline below. `post` here
242 // is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
243 if (opts.isReply) {
244 const meR = actorId(base, site.slug);
245 // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
246 // attachment array with absolute URLs and the matching object type.
247 let replyAtt;
248 try {
249 const list = post.attachments ? JSON.parse(post.attachments) : [];
250 if (Array.isArray(list) && list.length) {
251 replyAtt = list.map((a) => ({
252 type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
253 mediaType: a.mediaType,
254 url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
255 name: a.name || undefined,
256 }));
257 }
258 } catch { /* malformed attachments never block the Note */ }
259 return {
260 id: noteId(base, post.id),
261 type: 'Note',
262 attributedTo: meR,
263 inReplyTo: post.in_reply_to || undefined,
264 content: post.content,
265 // Reply language (rich replies): the AS2 language map next to `content`.
266 contentMap: post.language ? { [post.language]: post.content } : undefined,
267 attachment: replyAtt,
268 url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
269 published: toISO(post.created_at),
270 // A direct note (private mention, shaer-tqc) addresses ONLY its
271 // recipients: no Public anywhere, so it cannot be boosted and never
272 // shows in public timelines (the Mastodon DM model).
273 to: post.visibility === 'direct'
274 ? (JSON.parse(post.to_actors || '[]'))
275 : (post.to_actor ? [post.to_actor] : [PUBLIC]),
276 cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`],
277 // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
278 ...Guardianship.helpRequestProps(post),
279 ...Guardianship.waveProps(post),
280 // FEP-633c §2.2: object hint that the author is a ward.
281 ...Guardianship.hasGuardiansProps(site.slug),
282 tag: [
283 ...mentionTags(post.content),
284 ...hashtagTags(base, post.content),
285 ],
286 };
287 }
288 const id = noteId(base, post.id);
289 const aId = actorId(base, site.slug);
290 const human = `${base}/${encodeURIComponent(post.slug)}`;
291 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
292 // first line) — the standard blog→fediverse convention. post.content is
293 // already sanitized HTML; the title is plain text, so escape it.
294 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
295 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
296
297 // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
298 // content, so nothing leaks past the paywall. No media attachments either.
299 if (post.paid) {
300 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
301 const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
302 const rawTeaser = String(post.excerpt || '').trim()
303 || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
304 return {
305 '@context': AP_CONTEXT,
306 id,
307 type: 'Note',
308 attributedTo: aId,
309 content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
310 url: human,
311 published: toISO(post.published_at || post.created_at || Date.now()),
312 to: [PUBLIC],
313 cc: [`${aId}/followers`],
314 tag: [...hashtagTags(base, post.content)],
315 replies: `${id}/replies`,
316 ...Guardianship.hasGuardiansProps(site.slug),
317 };
318 }
319
320 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
321 // the cover + any inline <img>, make absolute, then strip <img> from the content
322 // to avoid duplicate rendering on clients that DO keep them.
323 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
324 const mediaType = (u) => {
325 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
326 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';
327 };
328 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
329 const playable = hasPlayableAudio(post.content || '', site && site.id);
330 // A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
331 // Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
332 // card, never both — so when the post has an embed link we skip the image attachments so the
333 // card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
334 const hasEmbed = (() => {
335 const c = post.content || '';
336 if (/\[\[embed:/i.test(c)) return true;
337 for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
338 return false;
339 })();
340 // Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
341 // so we federate them — Mastodon cards the first (its player), the rest show as clickable links
342 // — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
343 const trackEmbedLinks = (() => {
344 if (playable) return [];
345 const out = [];
346 try {
347 for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
348 const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
349 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);
350 }
351 } catch { /* non-fatal */ }
352 return [...new Set(out)].slice(0, 6);
353 })();
354 const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
355 const urls = [];
356 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
357 // the player CARD (twitter:player) instead of the cover — media attachment and
358 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
359 // keeps its cover (no player card to show).
360 // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
361 // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
362 // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
363 if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
364 else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
365 let body = post.content || '';
366 // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
367 // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
368 // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
369 // as the attachment description.
370 if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
371 const tag = m[0];
372 const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
373 if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
374 const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
375 urls.push({ url: abs(src), name: alt });
376 }
377 body = body.replace(/<img\b[^>]*>/gi, '');
378 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
379 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
380 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
381 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
382 // a click-through to the protected player (discovery without leaking the file).
383 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
384 const audioLabels = [];
385 try {
386 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); }
387 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
388 } catch { /* non-fatal */ }
389 // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
390 // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
391 // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
392 // later body mutation can't affect it.
393 const openAudio = [];
394 if (hadAudio) {
395 const seenA = new Set();
396 const addRow = (r) => {
397 const fn = r.filename || (r.storage_path || '').split('/').pop();
398 if (!fn || seenA.has(fn)) return; seenA.add(fn);
399 const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
400 // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
401 // Mastodon renders it as the artwork thumbnail on its native audio player.
402 const art = abs(r.cover_url || post.cover_image_url || null);
403 if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
404 openAudio.push(a);
405 };
406 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 ';
407 try {
408 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); }
409 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);
410 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);
411 } catch { /* non-fatal */ }
412 }
413 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
414 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
415 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
416 // of federating the raw shortcode text.
417 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
418 const u = esc(raw.trim().replace(/&amp;/g, '&'));
419 return `<p><a href="${u}">${u}</a></p>`;
420 });
421 if (hadAudio) {
422 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
423 if (trackEmbedLinks.length) {
424 // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
425 // player), the rest render as clickable links — the fediverse-native "embed + links".
426 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong>` : ''}</p>`;
427 for (const u of trackEmbedLinks) { const eu = esc(u); body += `<p><a href="${eu}">${eu}</a></p>`; }
428 } else {
429 // For playable posts, append a version param to the listen-link so Mastodon
430 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
431 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
432 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
433 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
434 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
435 }
436 }
437 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
438 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
439 // so convert newlines to <br> for the federated copy (content already made with
440 // shift+enter uses <br> and has no \n → this is a no-op there).
441 body = body.replace(/\r?\n/g, '<br>');
442 body = linkHashtags(base, body); // link inline #hashtags in the post body too
443 body = linkUrls(body); // bare URLs → clickable links on the federated copy
444 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
445 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
446 // multi-word tags; skip any already present inline in the body.
447 {
448 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
449 const addSeen = new Set();
450 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
451 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
452 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
453 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
454 }
455 const seen = new Set();
456 const attachment = urls.filter((x) => x && x.url)
457 .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
458 .map((x) => { const mt = mediaType(x.url); // specific AS2 subtype (Image/Audio/Video) over generic Document
459 const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
460 const a = { type: ty, mediaType: mt, url: x.url };
461 if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
462 return a; });
463 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
464
465 // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
466 // present when the content was already mention-linked (deliverCreate/Update resolve them
467 // at send time); a plain buildNote (outbox/notes) yields none.
468 const _mentionTags = mentionTags(body);
469 const _mentionCc = _mentionTags.map((t) => t.href);
470
471 const note = {
472 id,
473 type: 'Note',
474 attributedTo: aId,
475 content: titleHtml + body,
476 url: human,
477 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
478 // fan_only = "fans only" → followers-only visibility (delivered to your followers
479 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
480 to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
481 // Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
482 // so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
483 cc: [...new Set([
484 ...(post.ap_visibility === 'quiet' ? [PUBLIC] : []), // quiet public: Public in cc, not to
485 ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
486 ..._mentionCc])],
487 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags],
488 replies: `${id}/replies`,
489 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
490 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
491 sensitive: !!post.nsfw,
492 };
493 // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
494 Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
495 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
496 if (attachment.length) note.attachment = attachment;
497 // When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
498 // so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
499 // card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
500 // `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
501 if (post.cover_image_url && noImages) {
502 const cov = abs(post.cover_image_url);
503 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); }
504 }
505 // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
506 // (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
507 // getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
508 // standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
509 // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
510 if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
511 // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
512 // language from its key for the timeline language filter + the translate button. Emitted
513 // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
514 if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
515 // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
516 // reuses the note's content/addressing/tags, then swaps the type and strips media.
517 const ownPoll = parseOwnPoll(post.poll_json);
518 if (ownPoll) applyPollToNote(note, post.id, ownPoll);
519 return note;
520}
521
522// All reply note URIs on a local post (inbound fediverse replies + our own
523// outbound replies) — backs the Note's `replies` Collection so remote servers
524// can fetch the whole thread.
525export function getReplyUris(base, postId) {
526 const out = [];
527 try {
528 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);
529 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}`);
530 } catch { /* non-fatal */ }
531 return out;
532}
533
534// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
535export function markNotificationsSeen(slug) {
536 try {
537 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")
538 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
539 } catch { /* non-fatal */ }
540}
541export function countUnseenNotifications(slug) {
542 try {
543 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
544 const seen = row ? Date.parse(row.value) : 0;
545 let n = 0;
546 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
547 return n;
548 } catch { return 0; }
549}
550// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
551// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
552export function notificationsSeenAt(slug) {
553 try {
554 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
555 return row ? (Date.parse(row.value) || 0) : 0;
556 } catch { return 0; }
557}
558
559// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
560// every notification PLUS your own outbound replies ('sent', with edit/delete via their
561// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
562// one grouped item (actors list + count) so activity doesn't drown out conversations.
563export function getMessages(slug, limit, offset) {
564 const off = Math.max(0, offset || 0);
565 const lim = limit || 60;
566 // The stream is grouped (consecutive likes/boosts collapse), so paging is done by
567 // recomputing the whole stream top-down and slicing [off, off+lim] — stable across
568 // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
569 const need = off + lim + 100;
570 const items = getNotifications(slug, need);
571 try {
572 for (const m of listOutbox(slug).slice(0, need)) {
573 items.push({
574 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
575 content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
576 });
577 }
578 } catch { /* ignore */ }
579 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
580 const out = [];
581 for (const it of items) {
582 const prev = out[out.length - 1];
583 if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
584 && prev.post_slug === it.post_slug) {
585 prev.actors = prev.actors || [prev.name || prev.handle || '?'];
586 prev.actors.push(it.name || it.handle || '?');
587 prev.count = (prev.count || 1) + 1;
588 continue;
589 }
590 out.push(it);
591 }
592 return out.slice(off, off + lim);
593}
594
595export function buildCreate(base, site, post) {
596 const note = buildNote(base, site, post);
597 return {
598 '@context': AP_CONTEXT,
599 id: note.id + '#create',
600 type: 'Create',
601 actor: actorId(base, site.slug),
602 published: note.published,
603 to: note.to,
604 cc: note.cc,
605 object: note,
606 };
607}
608
609export function buildOutbox(base, site, posts) {
610 const id = `${actorId(base, site.slug)}/outbox`;
611 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
612 return {
613 '@context': AP_CONTEXT,
614 id,
615 type: 'OrderedCollection',
616 totalItems: items.length,
617 orderedItems: items,
618 };
619}
620
621// Public callers get a count-only collection (privacy). The authenticated
622// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
623// `items`, so their own client can build a friends list.
624export function buildFollowers(base, site, count, items = null) {
625 const id = `${actorId(base, site.slug)}/followers`;
626 return {
627 '@context': AP_CONTEXT,
628 id,
629 type: 'OrderedCollection',
630 totalItems: items ? items.length : (count || 0),
631 orderedItems: items || [], // count-only for the public; full for the owner
632 };
633}
634
635// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
636// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
637export function buildFollowing(base, site, count, items = null) {
638 const id = `${actorId(base, site.slug)}/following`;
639 return {
640 '@context': AP_CONTEXT,
641 id,
642 type: 'OrderedCollection',
643 totalItems: items ? items.length : (count || 0),
644 orderedItems: items || [], // count-only for the public; full for the owner
645 };
646}
647
648// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
649// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
650// rank; embedded as full Notes so a remote server doesn't need extra fetches.
651export function buildFeatured(base, site, posts) {
652 const id = `${actorId(base, site.slug)}/featured`;
653 const items = (posts || []).map((p) => buildNote(base, site, p));
654 return {
655 '@context': AP_CONTEXT,
656 id,
657 type: 'OrderedCollection',
658 totalItems: items.length,
659 orderedItems: items,
660 };
661}
662
663// ── followers store (lazy stmts) ──────────────────────────────────
664let _insF, _updFDisp, _delF, _listF, _cntF;
665function fStmts() {
666 if (!_insF) {
667 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
668 _updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
669 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
670 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
671 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
672 }
673 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
674}
675export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
676
677// Followers with delivery health, for the management list. Never-delivered accounts
678// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
679export function listFollowers(slug) {
680 return db.prepare(
681 `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
682 FROM ap_followers WHERE slug = ?
683 ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
684 ).all(slug);
685}
686// Manually drop a follower after a check (a still-live account would have to re-follow).
687export function removeFollower(slug, id) {
688 const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
689 return info.changes > 0;
690}
691
692// Best cached display for an actor URI, across the caches Klonkt already fills:
693// followers (now with name/icon), following, interactions, timeline, mentions.
694// Falls back to a handle derived from the URI. Display info is not sensitive.
695export function actorDisplay(slug, uri) {
696 const ok = (r) => r && (r.name || r.icon);
697 try {
698 let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
699 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
700 r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
701 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
702 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);
703 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
704 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);
705 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
706 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);
707 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
708 } catch { /* ignore */ }
709 return { name: null, handle: deriveHandle(uri), icon: null };
710}
711
712// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
713// Pure and testable; the route sets the response headers around it.
714export function prefersEnriched(preferHeader) {
715 return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
716}
717
718// AS2 actor reference with display, for the owner C2S followers/following view.
719// preferredUsername = the local part of the handle; name = the set display name.
720export function buildActorRef(slug, uri) {
721 const d = actorDisplay(slug, uri);
722 const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
723 const out = { id: uri, type: 'Person' };
724 if (d.name) out.name = d.name;
725 if (user) out.preferredUsername = user;
726 if (d.icon) out.icon = { type: 'Image', url: d.icon };
727 return out;
728}
729
730// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
731// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
732// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
733// `unreachable` flag (never delivered, or last attempt failed after the last success) so
734// the view can split dead connections into their own section. Powers the Connect page.
735export function listConnections(slug) {
736 const byUri = new Map();
737 for (const f of listFollowing(slug)) {
738 byUri.set(f.actor_uri, {
739 actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
740 icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
741 status: f.status || null, following: true, follower: false,
742 last_delivery_at: null, last_error_at: null, follower_id: null,
743 });
744 }
745 for (const fo of listFollowers(slug)) {
746 const e = byUri.get(fo.actor_uri);
747 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; }
748 else byUri.set(fo.actor_uri, {
749 actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
750 auto_boost: 0, status: null, following: false, follower: true,
751 last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
752 });
753 }
754 return [...byUri.values()].map((e) => {
755 e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
756 e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
757 return e;
758 });
759}
760
761// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
762let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
763// ── moderation tombstones (ap_rejected_objects) ───────────────────
764// A reply the owner removed stays removed: its object URI is tombstoned and
765// checked at ingest AND by the thread-crawler (else thread-filling would
766// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
767// private notes that authorize_interaction can't fetch (401/404).
768let _insRj, _hasRj;
769function rjStmts() {
770 if (!_insRj) {
771 _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
772 _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
773 }
774 return { ins: _insRj, has: _hasRj };
775}
776export function isRejectedObject(uri) {
777 if (!uri) return false;
778 try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
779}
780// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
781// interaction's post must belong to the caller's site.
782export function rejectInteraction(site, interactionId, reason) {
783 if (!site || !site.slug) return { error: 'forbidden' };
784 const row = iStmts().getI.get(interactionId);
785 if (!row) return { error: 'not_found' };
786 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
787 .get(row.post_id, site.slug);
788 if (!owns) return { error: 'forbidden' };
789 if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
790 db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
791 console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
792 return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
793}
794// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
795// from the local copy (works for private notes; no remote fetch needed to target).
796export function interactionReportTarget(site, interactionId) {
797 if (!site || !site.slug) return null;
798 const row = iStmts().getI.get(interactionId);
799 if (!row) return null;
800 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
801 .get(row.post_id, site.slug);
802 if (!owns) return null;
803 return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
804}
805
806// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
807// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
808// followers-collectie zonder Public = followers-only, anders direct (DM). Public
809// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
810export function noteVisibility(o) {
811 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
812 const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
813 const to = arr(o && o.to).map(String);
814 const cc = arr(o && o.cc).map(String);
815 if (to.some(isPub)) return 'public';
816 if (cc.some(isPub)) return 'unlisted';
817 if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
818 return 'direct';
819}
820
821function iStmts() {
822 if (!_insI) {
823 _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, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
824 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
825 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
826 _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 FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
827 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
828 _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)');
829 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
830 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
831 }
832 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
833}
834
835export function getInteractionById(id) { return iStmts().getI.get(id); }
836export function setInteractionBoosted(id, on) {
837 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
838}
839export function setInteractionLiked(id, on) {
840 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
841}
842// Your like/boost state on a REMOTE post (interact page toggles).
843export function setMyReaction(slug, uri, kind, on) {
844 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
845 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
846}
847export function getMyReactions(slug, uri) {
848 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
849 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
850}
851
852const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
853// Extract our local post id from a note URL, but only if it's ours (base match).
854function postIdFromNoteUrl(url, base) {
855 const s = String(url || '');
856 if (base && !s.startsWith(base)) return null;
857 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
858 return m ? decodeURIComponent(m[1]) : null;
859}
860function deriveHandle(actorUri) {
861 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
862}
863function actorInfo(doc, actorUri) {
864 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
865 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
866 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
867 return {
868 name: (doc && (doc.name || doc.preferredUsername)) || handle,
869 handle,
870 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
871 icon: safeUrl(icon) || null,
872 };
873}
874
875// Given an inReplyTo note URL, find which local post the thread belongs to + the
876// note being replied to (parent), so a reply-to-a-comment can be nested.
877function findThreadTarget(inReplyTo, base) {
878 if (!inReplyTo) return null;
879 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
880 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
881 if (seg) {
882 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 */ }
883 }
884 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 */ }
885 return null;
886}
887
888// Drop the leading @mention(s) a federated reply carries (the person being replied to),
889// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
890// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
891export function stripLeadingMentions(html) {
892 if (!html) return html;
893 let s = String(html);
894 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
895 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
896 return s;
897}
898
899// View-ready threaded view of a post's fediverse activity (inbound replies +
900// our outbound replies, nested), plus like/boost counts.
901export function getInteractions(postId, base, site) {
902 const s = iStmts();
903 // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
904 // public web, so it must NOT render in the public thread. It still reaches the owner
905 // via notifications (post context + reference included there). Legacy rows without a
906 // visibility value are treated as public. Likes/boosts stay counted (count-only).
907 const rows = s.list.all(postId).filter((r) =>
908 r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
909 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
910 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
911 // Our own (outbound) replies show the SITE identity for everyone (not "You").
912 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
913 const siteName = (site && (site.title || site.slug)) || '';
914 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
915 const siteUrl = baseClean ? `${baseClean}/` : '';
916 const siteIcon = (site && site.profile_photo) || null;
917
918 const nodes = [];
919 for (const r of rows) {
920 if (r.kind !== 'reply') continue;
921 nodes.push({
922 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
923 actor_uri: r.actor_uri,
924 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
925 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
926 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
927 children: [],
928 });
929 }
930 for (const o of s.listO.all(postId)) {
931 nodes.push({
932 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
933 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
934 media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
935 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
936 children: [],
937 });
938 }
939
940 const byId = new Map(nodes.map((n) => [n.noteId, n]));
941 // Conversation partners per node (u02, the reply editor's mentions bar): the
942 // node's author plus the ancestor authors up the chain. Our own nodes are
943 // skipped (we do not mention ourselves), deduped by actor, capped at 8.
944 for (const n of nodes) {
945 const seen = new Set();
946 const list = [];
947 let cur = n, guard = 0;
948 while (cur && guard++ < 12 && list.length < 8) {
949 if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
950 seen.add(cur.actor_uri);
951 list.push({
952 uri: cur.actor_uri,
953 url: cur.actor_url || cur.actor_uri,
954 handle: cur.actor_handle || deriveHandle(cur.actor_uri),
955 });
956 }
957 cur = cur.parent ? byId.get(cur.parent) : null;
958 }
959 n.participants = list;
960 }
961 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
962 const tops = [];
963 for (const n of nodes) {
964 if (isTop(n)) { tops.push(n); continue; }
965 let anc = n, guard = 0;
966 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
967 anc.children.push(n);
968 }
969 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
970 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
971
972 return {
973 thread: tops,
974 likeCount: rows.filter((r) => r.kind === 'like').length,
975 announceCount: rows.filter((r) => r.kind === 'announce').length,
976 total: nodes.length,
977 };
978}
979
980// ── HTTP Signatures + delivery ────────────────────────────────────
981const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
982// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
983// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
984// an existing site. Deduped.
985export function localMentionSlugs(tags, base) {
986 if (!base) return [];
987 const out = [], seen = new Set();
988 for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
989 if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
990 if (!t.href.startsWith(base + '/ap/users/')) continue;
991 const slug = slugFromActorUrl(t.href);
992 if (!slug || seen.has(slug)) continue; seen.add(slug);
993 try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
994 }
995 return out;
996}
997
998// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
999export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
1000 const body = JSON.stringify(bodyObj);
1001 const u = new URL(inboxUrl);
1002 const date = new Date().toUTCString();
1003 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
1004 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
1005 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
1006 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
1007 const r = await safeFetch(inboxUrl, {
1008 method: 'POST',
1009 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
1010 body,
1011 });
1012 return r.status;
1013}
1014
1015export async function fetchActor(url) {
1016 try {
1017 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
1018 if (!r.ok) return null;
1019 const len = Number(r.headers.get('content-length') || 0);
1020 if (len > 2_000_000) return null; // refuse oversized actor docs
1021 return await r.json();
1022 } catch { return null; }
1023}
1024
1025// ── Delivery queue with retries ───────────────────────────────────
1026// Outbound deliveries are tried immediately; on failure (down server, timeout,
1027// non-2xx) they're queued and retried with backoff so a briefly-offline follower
1028// doesn't silently miss the post. The signing key is NOT stored — the worker
1029// re-derives it from the actor slug at send time.
1030const DELIVERY_MAX_ATTEMPTS = 6;
1031const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
1032let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
1033function deliveryStmts() {
1034 if (!_insDeliv) {
1035 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
1036 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
1037 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
1038 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
1039 }
1040 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
1041}
1042export function enqueueDelivery(slug, inbox, activity) {
1043 if (!slug || !inbox || !activity) return;
1044 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
1045}
1046// Record delivery health per follower so the followers list can flag dead accounts.
1047// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
1048// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
1049let _fDelivOk, _fDelivErr;
1050function markFollowerDelivery(slug, inbox, ok) {
1051 if (!slug || !inbox) return;
1052 try {
1053 if (!_fDelivOk) {
1054 _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
1055 _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
1056 }
1057 (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
1058 } catch { /* health tracking is non-fatal */ }
1059}
1060// Deliver now; queue for retry if it fails.
1061export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
1062 if (!inbox) return;
1063 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
1064 enqueueDelivery(slug, inbox, activity);
1065}
1066let _processingDeliv = false;
1067export async function processDeliveryQueue() {
1068 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
1069 _processingDeliv = true;
1070 try {
1071 let rows;
1072 try { rows = deliveryStmts().due.all(); } catch { return; }
1073 if (!rows || !rows.length) return;
1074 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1075 for (const row of rows) {
1076 let ok = false;
1077 try {
1078 const keys = getOrCreateKeys(row.slug);
1079 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
1080 ok = st >= 200 && st < 300;
1081 } catch { ok = false; }
1082 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
1083 const attempts = row.attempts + 1;
1084 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; }
1085 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
1086 // retry uses the 1-min tier instead of skipping it.
1087 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
1088 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
1089 }
1090 } finally { _processingDeliv = false; }
1091}
1092let _delivTimer = null;
1093export function startDeliveryWorker() {
1094 if (_delivTimer) return;
1095 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
1096 if (_delivTimer.unref) _delivTimer.unref();
1097}
1098
1099// Best-effort verification of an incoming signed request. Returns the sender's
1100// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
1101// Max clock skew for the signed Date header (replay window). Generous default to tolerate
1102// federating servers with drifting clocks; an operator can widen it via env.
1103const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
1104export async function verifyRequest(req) {
1105 const sigH = req.headers['signature'];
1106 if (!sigH) return null;
1107 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
1108 if (!p.keyId || !p.signature) return null;
1109 const actor = await fetchActor(p.keyId.split('#')[0]);
1110 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
1111 if (!pem) return null;
1112 const hs = (p.headers || '(request-target) host date').split(/\s+/);
1113 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
1114 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
1115 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
1116 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
1117 // against any. An attacker can't forge a match (no private key), so this only rescues the
1118 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
1119 let _pubHost = null;
1120 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
1121 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
1122 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
1123 const _sig = Buffer.from(p.signature, 'base64');
1124 let ok = false;
1125 for (const _h of _hosts) {
1126 const line = hs.map((x) => x === '(request-target)'
1127 ? `(request-target): ${_target}`
1128 : x === 'host' ? `host: ${_h}`
1129 : `${x}: ${req.headers[x] || ''}`).join('\n');
1130 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
1131 }
1132 // Replay defence: the Date header must be signed and recent. A captured signed request
1133 // replayed later (or with a swapped body) is rejected.
1134 if (ok) {
1135 if (!hs.includes('date')) ok = false;
1136 else {
1137 const t = Date.parse(req.headers['date'] || '');
1138 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
1139 }
1140 }
1141 // Digest is MANDATORY when the request carries a body: without a signed digest the body
1142 // isn't covered by the signature and could be swapped on a replay.
1143 if (ok && req.rawBody && req.rawBody.length) {
1144 if (!hs.includes('digest')) ok = false;
1145 else {
1146 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
1147 if (req.headers['digest'] !== exp) ok = false;
1148 }
1149 }
1150 return ok ? actor : null;
1151}
1152
1153// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
1154// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
1155// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
1156function parsePoll(o) {
1157 if (!o || o.type !== 'Question') return null;
1158 const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
1159 if (!raw || !raw.length) return null;
1160 const options = raw.slice(0, 12).map((opt) => ({
1161 name: String((opt && opt.name) || '').slice(0, 300),
1162 count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
1163 })).filter((x) => x.name);
1164 if (!options.length) return null;
1165 const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
1166 const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
1167 return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
1168}
1169
1170// ── Polls WE host (a local post with a poll) ──────────────────────
1171// Parse the poll definition stored on our own post (posts.poll_json). Counts are
1172// NOT stored here — they're derived from the poll_votes ballots so a re-render always
1173// reflects the authoritative tally.
1174export function parseOwnPoll(pollJson) {
1175 if (!pollJson) return null;
1176 let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
1177 if (!d || !Array.isArray(d.options)) return null;
1178 const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
1179 if (options.length < 2) return null;
1180 const endTime = d.endTime || null;
1181 const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
1182 return { multiple: !!d.multiple, options, endTime, closed };
1183}
1184
1185// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
1186export function pollTally(postId) {
1187 const counts = {}; let voters = 0;
1188 try {
1189 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;
1190 voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
1191 } catch { /* table may not exist yet */ }
1192 return { counts, voters };
1193}
1194
1195// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
1196// Voting is fediverse-only, so this is display-only on the site.
1197export function ownPollView(post) {
1198 const poll = parseOwnPoll(post && post.poll_json);
1199 if (!poll) return null;
1200 const { counts, voters } = pollTally(post.id);
1201 const total = Object.values(counts).reduce((a, b) => a + b, 0);
1202 const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
1203 const options = poll.options.map((o) => {
1204 const count = counts[o.name] || 0;
1205 return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
1206 });
1207 return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
1208}
1209
1210// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
1211// status with either media OR a poll (never both), so a poll federates as content +
1212// options with no media attachment. oneOf = single choice, anyOf = multiple.
1213function applyPollToNote(note, postId, poll) {
1214 const { counts, voters } = pollTally(postId);
1215 const opts = poll.options.map((o) => ({
1216 type: 'Note',
1217 name: o.name,
1218 replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
1219 }));
1220 note.type = 'Question';
1221 note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
1222 if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
1223 // Once closed, Mastodon expects a `closed` timestamp (the effective end).
1224 if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
1225 note.votersCount = voters;
1226 delete note.attachment; // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
1227 // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
1228 // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
1229 // Deleting it stripped the cover off every boosted poll.
1230 return note;
1231}
1232
1233// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
1234// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
1235// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
1236// caller must NOT also store it as a reply), false means "not a poll, fall through".
1237function recordPollBallot(postId, actorUri, rawChoice) {
1238 const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
1239 if (!choice) return { handled: false };
1240 let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
1241 const poll = post && parseOwnPoll(post.poll_json);
1242 if (!poll) return { handled: false }; // not a poll → let the reply logic handle it
1243 if (poll.closed) return { handled: true }; // voting closed → drop
1244 if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
1245 try {
1246 // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
1247 // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
1248 if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
1249 db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
1250 } catch { return { handled: true }; }
1251 schedulePollUpdate(postId);
1252 return { handled: true };
1253}
1254
1255// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
1256// refresh ~15s out; further votes in that window ride the same pending update (which carries
1257// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
1258const _pollUpdTimers = new Map();
1259function schedulePollUpdate(postId) {
1260 if (_pollUpdTimers.has(postId)) return;
1261 const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
1262 if (t.unref) t.unref();
1263 _pollUpdTimers.set(postId, t);
1264}
1265
1266// Push the fresh poll tally (or closed state) to followers as Update(Question).
1267export async function deliverPollUpdate(postId) {
1268 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1269 if (!base || !postId) return;
1270 let post, site;
1271 try {
1272 post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
1273 if (!post || !post.poll_json) return;
1274 site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
1275 } catch { return; }
1276 if (site) await deliverUpdate(site, post);
1277}
1278
1279// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
1280// Fire-and-forget: a notification must never block or break inbox processing.
1281function pushEvent(slug, event) {
1282 try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
1283}
1284// Hub-aware path prefix for a site's pages ('' in solo).
1285function pushPrefix(slug) {
1286 try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; }
1287}
1288// Notification language: the site's content language (fallback: instance default).
1289function pushLang(slug) {
1290 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'; }
1291}
1292// Site slug, target URL and title for a post-scoped notification.
1293function pushPostCtx(postId) {
1294 try {
1295 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);
1296 if (!r) return null;
1297 return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
1298 } catch { return null; }
1299}
1300
1301// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
1302export async function handleInbox(req, slugParam) {
1303 const act = req.body || {};
1304 const type = act.type;
1305 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
1306 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
1307 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
1308 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1309 const verified = await verifyRequest(req).catch(() => null);
1310
1311 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
1312 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
1313 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
1314 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1315 // Blocked actor/domain → silently drop (202, don't reveal the block).
1316 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
1317 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
1318 if (GATED.includes(type)) {
1319 if (!verified || !claimedActor || verified.id !== claimedActor) {
1320 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
1321 return 401;
1322 }
1323 }
1324
1325 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
1326 // Accept/Reject answers an offer a local guardian sent. Anything the
1327 // guardianship module does not recognize falls through to the old paths.
1328 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
1329 // Every LOCAL party this activity is addressed to gets its own copy of the
1330 // handshake (a ward and a co-guardian may both live here). Gather candidate
1331 // local slugs from the inbox owner, the `to` list, and the ward.
1332 const cand = new Set();
1333 if (slugParam) cand.add(slugParam);
1334 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
1335 if (typeof t === 'string') { const s = slugFromActorUrl(t); if (s) cand.add(s); }
1336 }
1337 if (type === 'Offer') {
1338 const rel = Guardianship.parseRelationship(act.object);
1339 if (rel) { const s = slugFromActorUrl(rel.ward); if (s) cand.add(s); }
1340 }
1341 let consumed = false;
1342 for (const slug of cand) {
1343 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1344 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
1345 }
1346 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
1347 }
1348
1349 // A moderation report (Flag) about our content — store it for the targeted site's owner
1350 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
1351 if (type === 'Flag') {
1352 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
1353 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
1354 let targetSlug = null;
1355 const noteIds = [];
1356 for (const u of objectUris) {
1357 const s = slugFromActorUrl(u); // one of our actors?
1358 if (s) { targetSlug = targetSlug || s; continue; }
1359 const pid = postIdFromNoteUrl(u, base); // one of our notes?
1360 if (pid) noteIds.push(pid);
1361 }
1362 if (!targetSlug && noteIds.length) {
1363 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 */ }
1364 }
1365 if (!targetSlug) return 202; // not about us / can't tell → drop
1366 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
1367 const ai = actorInfo(verified || null, claimedActor);
1368 try {
1369 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
1370 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
1371 console.log('[AP] report received for', targetSlug, 'from', claimedActor);
1372 } catch { /* ignore */ }
1373 return 202;
1374 }
1375
1376 if (type === 'Follow') {
1377 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1378 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
1379 if (!who || !slug) return 400;
1380 const remote = await fetchActor(who);
1381 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
1382 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
1383 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)
1384 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
1385 // follow is gated. A committed guardian's own Follow is auto-accepted
1386 // (it needs no gate); anyone else is held pending for guardian approval.
1387 // Free actors / normal sites have no guardians → fall through, unchanged.
1388 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
1389 if (wardGuardians.length && !wardGuardians.includes(who)) {
1390 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
1391 Guardianship.follows.recordPending(slug, {
1392 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
1393 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
1394 });
1395 // The ward and its guardians live together (the family Klonkt); a push
1396 // tells each local guardian to decide in /guardian2. Over the wire the
1397 // follow stays a normal pending Follow until they accept (Robins besluit:
1398 // keep the flow simple, no cross-instance forwarding).
1399 for (const g of wardGuardians) {
1400 const gslug = slugFromActorUrl(g);
1401 if (!gslug) continue;
1402 const L = pushLang(gslug);
1403 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)}/guardian2` });
1404 }
1405 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
1406 return 202;
1407 }
1408 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
1409 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
1410 { 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` }); }
1411 const me = actorId(base, slug);
1412 const keys = getOrCreateKeys(slug);
1413 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
1414 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
1415 // Auto-backfill: send our recent posts as Create so the instance has our history
1416 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
1417 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
1418 // a follower of ours is wasted work (and won't re-populate the new follower's
1419 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
1420 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
1421 const instanceFilled = sharedInbox &&
1422 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
1423 .get(slug, sharedInbox, who);
1424 if (!instanceFilled) {
1425 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
1426 }
1427 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
1428 return 202;
1429 }
1430 if (type === 'Undo' && act.object) {
1431 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1432 const ot = act.object.type;
1433 if (ot === 'Follow') {
1434 const obj = act.object.object;
1435 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
1436 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
1437 return 202;
1438 }
1439 if (ot === 'Like' || ot === 'Announce') {
1440 const tgt = act.object.object;
1441 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
1442 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
1443 return 202;
1444 }
1445 return 202;
1446 }
1447
1448 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1449 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
1450 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
1451 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
1452
1453 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
1454 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1455 const o = act.object;
1456 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
1457 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
1458 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
1459 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
1460 const seg = postIdFromNoteUrl(o.inReplyTo, base);
1461 if (seg && localPostExists(seg)) {
1462 const rec = recordPollBallot(seg, actorUri, o.name);
1463 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
1464 }
1465 }
1466 const tgt = findThreadTarget(o.inReplyTo, base);
1467 if (tgt && actorUri && !isLocalActor) {
1468 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1469 const html = HtmlSanitizerService.sanitize(o.content || '');
1470 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
1471 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));
1472 console.log('[AP] reply', actorUri, '→', tgt.post_id);
1473 {
1474 // Private (followers/direct) replies push as a DM ping WITHOUT content
1475 // (the push service should never carry private text, design decision);
1476 // public replies carry a short snippet.
1477 const ctx = pushPostCtx(tgt.post_id);
1478 const vis = noteVisibility(o);
1479 const priv = vis === 'direct' || vis === 'followers';
1480 if (ctx) {
1481 const L = pushLang(ctx.site);
1482 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1483 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` });
1484 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 });
1485 }
1486 }
1487 return 202;
1488 }
1489 // Home timeline (client): a top-level post from an account we follow.
1490 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
1491 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
1492 if (subs.length) {
1493 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1494 const html = HtmlSanitizerService.sanitize(o.content || '');
1495 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1496 // Fallback cover: a Note's `image` (set when the attachment was suppressed
1497 // for a player-card post, e.g. hosted-audio posts).
1498 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
1499 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
1500 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
1501 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
1502 }
1503 const media = JSON.stringify(_atts);
1504 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
1505 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
1506 // incoming posts to the fediverse — that flooded followers. Boosting to the
1507 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
1508 // the timeline).
1509 for (const s of subs) {
1510 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);
1511 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
1512 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 */ } }
1513 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 */ } }
1514 }
1515 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
1516 }
1517 }
1518 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
1519 // above): store a mention notification for each of our actors named in the Mention tags.
1520 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
1521 // someone else's actor, not ours.
1522 if (actorUri && !isLocalActor && o.id) {
1523 const slugs = localMentionSlugs(o.tag, base);
1524 if (slugs.length) {
1525 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1526 const html = HtmlSanitizerService.sanitize(o.content || '');
1527 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
1528 // flag is stored so the Guardian PWA's message centre can list it.
1529 const help = Guardianship.isHelpRequest(o);
1530 const wave = Guardianship.isWave(o);
1531 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only
1532 for (const slug of slugs) {
1533 try {
1534 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, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
1535 .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);
1536 if (r.changes) {
1537 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
1538 const vis = noteVisibility(o);
1539 const priv = vis === 'direct' || vis === 'followers';
1540 const L = pushLang(slug);
1541 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1542 // Same privacy rule as replies: private mentions push without content.
1543 // A help request pushes as its own alert type, aimed at the
1544 // Guardian PWA's message centre.
1545 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
1546 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` });
1547 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
1548 }
1549 } catch { /* ignore */ }
1550 }
1551 }
1552 }
1553 return 202;
1554 }
1555 // A remote post we cached was edited upstream → refresh our cached copy. This is the
1556 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
1557 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
1558 // edit A's note (the signature gate guarantees claimedActor == the verified signer).
1559 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1560 const o = act.object;
1561 if (o.id && claimedActor) {
1562 const html = HtmlSanitizerService.sanitize(o.content || '');
1563 const media = mediaFromNote(o);
1564 try {
1565 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
1566 // rename keeps the same AP id but changes the human url, so without this the cached
1567 // post would keep linking to the old, now-dead URL.
1568 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
1569 .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
1570 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
1571 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
1572 // site keeps its own `voted` state while the counts/closed update to the new totals.
1573 const poll = parsePoll(o);
1574 if (poll) {
1575 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
1576 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
1577 for (const rw of rows) {
1578 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
1579 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
1580 }
1581 }
1582 } catch { /* ignore */ }
1583 // If this note is a cached fediverse reply on one of our posts, refresh its text too.
1584 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
1585 }
1586 return 202;
1587 }
1588 if (type === 'Like' || type === 'Announce') {
1589 const tgt = act.object;
1590 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
1591 const pid = postIdFromNoteUrl(objUrl, base);
1592 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
1593 // A boost/like of a non-public post is dropped, not stored: nobody
1594 // outside the audience should even hold it (shaer-tqc hardening).
1595 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
1596 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
1597 console.log('[AP] dropped', type, 'on non-public post', pid);
1598 return;
1599 }
1600 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1601 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
1602 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
1603 {
1604 const ctx = pushPostCtx(pid);
1605 if (ctx) {
1606 const L = pushLang(ctx.site);
1607 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1608 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 });
1609 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 });
1610 }
1611 }
1612 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
1613 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
1614 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
1615 // re-announcing an incoming Announce would cascade boosts across the network).
1616 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
1617 if (subs.length) {
1618 const bn = await fetchNoteAP(objUrl);
1619 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
1620 const origUri = actorUriOf(bn.attributedTo);
1621 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
1622 // author is blocked — otherwise a block is bypassed via someone else's boost.
1623 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
1624 const oai = actorInfo(await resolveActor(origUri), origUri);
1625 const html = HtmlSanitizerService.sanitize(bn.content || '');
1626 const media = mediaFromNote(bn);
1627 const booster = actorInfo(await resolveActor(actorUri), actorUri);
1628 for (const s of subs) {
1629 // published = now → the boost shows as fresh activity at the top (Mastodon shows
1630 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
1631 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
1632 let inserted = false;
1633 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 */ }
1634 if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, s.slug, bn.id); } catch { /* ignore */ } }
1635 }
1636 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
1637 }
1638 }
1639 }
1640 return 202;
1641 }
1642 if (type === 'Delete') {
1643 // A remote note was deleted upstream → drop it from replies AND the timeline.
1644 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
1645 // signature gate guarantees claimedActor == the verified signer here).
1646 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1647 if (oid && claimedActor) {
1648 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1649 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1650 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
1651 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
1652 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
1653 // to A's posts).
1654 try {
1655 let sameHost = false;
1656 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
1657 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
1658 } catch { /* ignore */ }
1659 }
1660 return 202;
1661 }
1662 // Accept/Reject of a Follow WE sent (client side).
1663 if (type === 'Accept' && act.object) {
1664 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1665 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
1666 console.log('[AP] follow accepted', actorUri);
1667 return 202;
1668 }
1669 if (type === 'Reject' && act.object) {
1670 const who = actorUri;
1671 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
1672 return 202;
1673 }
1674
1675 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
1676 return 202;
1677}
1678
1679// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
1680// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
1681export async function deliverCreate(site, post) {
1682 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1683 if (!base || !site || !site.slug) return;
1684 // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
1685 // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
1686 const mres = await resolveMentionsInText(base, post.content || '');
1687 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1688 const followers = fStmts().list.all(site.slug);
1689 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
1690 if (!inboxes.length) return; // no followers and no one mentioned
1691 const keys = getOrCreateKeys(site.slug);
1692 const keyId = `${actorId(base, site.slug)}#main-key`;
1693 const create = buildCreate(base, site, post2);
1694 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
1695}
1696
1697// On a new Follow, send that follower our most recent posts as Create so their
1698// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
1699// so they sort into the follower's timeline at their original dates.
1700async function backfillNewFollower(base, slug, inbox) {
1701 if (!base || !slug || !inbox) return;
1702 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1703 if (!site) return;
1704 const recent = db.prepare(
1705 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
1706 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1707 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
1708 ).all(site.id).reverse();
1709 if (!recent.length) return;
1710 const keys = getOrCreateKeys(slug);
1711 const keyId = `${actorId(base, slug)}#main-key`;
1712 for (const p of recent) {
1713 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
1714 await new Promise((r) => setTimeout(r, 150));
1715 }
1716 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
1717}
1718
1719// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
1720export async function deliverDelete(site, post) {
1721 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1722 if (!base || !site || !site.slug || !post || !post.id) return;
1723 const followers = fStmts().list.all(site.slug);
1724 if (!followers.length) return;
1725 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1726 const keys = getOrCreateKeys(site.slug);
1727 const me = actorId(base, site.slug);
1728 const nid = noteId(base, post.id);
1729 const del = {
1730 '@context': AP_CONTEXT,
1731 id: `${nid}#delete-${Date.now()}-${rid()}`,
1732 type: 'Delete',
1733 actor: me,
1734 to: [PUBLIC],
1735 object: { id: nid, type: 'Tombstone' },
1736 };
1737 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
1738}
1739
1740// Tell followers an already-published post changed (Update + edited Note) so
1741// Mastodon refreshes the cached copy (e.g. after fixing content).
1742export async function deliverUpdate(site, post) {
1743 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1744 if (!base || !site || !site.slug || !post || !post.id) return;
1745 const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
1746 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1747 const followers = fStmts().list.all(site.slug);
1748 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
1749 if (!inboxes.length) return;
1750 const keys = getOrCreateKeys(site.slug);
1751 const me = actorId(base, site.slug);
1752 const note = buildNote(base, site, post2);
1753 note.updated = new Date().toISOString();
1754 const update = {
1755 '@context': AP_CONTEXT,
1756 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
1757 type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
1758 object: note,
1759 };
1760 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
1761}
1762
1763// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
1764// account AND re-fetches the featured (pinned) collection — there is no standard
1765// "featured changed" activity, so this is how a pin/unpin propagates promptly.
1766export async function deliverActorUpdate(site) {
1767 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1768 if (!base || !site || !site.slug) return;
1769 const followers = fStmts().list.all(site.slug);
1770 if (!followers.length) return;
1771 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1772 const keys = getOrCreateKeys(site.slug);
1773 const me = actorId(base, site.slug);
1774 const update = {
1775 '@context': AP_CONTEXT,
1776 id: `${me}#update-${Date.now()}-${rid()}`,
1777 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
1778 object: buildActor(base, site),
1779 };
1780 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
1781}
1782
1783// Reliably set the pinned order on followers' instances via Add/Remove activities
1784// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
1785// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
1786// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
1787// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
1788// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
1789// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
1790// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
1791// resync already in flight for a site coalesces later requests into ONE rerun after it
1792// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
1793const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
1794export function resyncFeaturedPins(site, alsoRemove = []) {
1795 if (!site || !site.slug) return Promise.resolve();
1796 const slug = site.slug;
1797 const running = _pinResync.get(slug);
1798 if (running) {
1799 running.pending = true;
1800 running.site = site; // use the latest site object on the rerun
1801 for (const id of alsoRemove) running.pendingRemove.add(id);
1802 return running.promise;
1803 }
1804 const state = { promise: null, pending: false, pendingRemove: new Set(), site };
1805 state.promise = (async () => {
1806 let extra = alsoRemove;
1807 for (;;) {
1808 try { await doResyncFeaturedPins(state.site, extra); }
1809 catch (e) { console.warn('[AP] pin resync failed:', e.message); }
1810 if (!state.pending) break;
1811 state.pending = false;
1812 extra = [...state.pendingRemove];
1813 state.pendingRemove = new Set();
1814 }
1815 _pinResync.delete(slug);
1816 })();
1817 _pinResync.set(slug, state);
1818 return state.promise;
1819}
1820
1821// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
1822// it stays serialized per site.
1823async function doResyncFeaturedPins(site, alsoRemove = []) {
1824 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1825 if (!base || !site || !site.slug) return;
1826 const followers = fStmts().list.all(site.slug);
1827 if (!followers.length) return;
1828 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1829 const keys = getOrCreateKeys(site.slug);
1830 const me = actorId(base, site.slug);
1831 const keyId = `${me}#main-key`;
1832 const featured = `${me}/featured`;
1833 const note = (id) => noteId(base, id);
1834 const pinned = db.prepare(
1835 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1836 AND pinned IS NOT NULL AND pinned > 0
1837 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
1838 ).all(site.id);
1839 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
1840 // 1. Remove every current pin so Mastodon can recreate them in order.
1841 for (const id of removeIds) {
1842 const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
1843 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
1844 }
1845 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
1846 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
1847 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
1848 for (const p of pinned) {
1849 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`] };
1850 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
1851 await new Promise((r) => setTimeout(r, 2000));
1852 }
1853 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
1854}
1855
1856// ── outbound replies (Klonkt → fediverse) ─────────────────────────
1857const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
1858const 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(); };
1859
1860// Build one of OUR outbound reply Notes from an ap_outbox row.
1861// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
1862function linkHashtags(base, html) {
1863 // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
1864 // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
1865 return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
1866 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
1867}
1868// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
1869// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
1870// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
1871// outside the link (Mastodon-style).
1872function linkUrls(html) {
1873 const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
1874 for (let i = 0; i < parts.length; i++) {
1875 if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
1876 parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
1877 (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
1878 }
1879 return parts.join('');
1880}
1881// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
1882// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
1883// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
1884// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
1885// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
1886export function linkifyBody(base, html) {
1887 const withTags = String(html || '')
1888 .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
1889 .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
1890 .join('');
1891 return linkUrls(withTags);
1892}
1893
1894// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
1895// at save and cached in posts.content_rendered, so page views serve it statically instead of
1896// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
1897// resolve @mentions here too (webfinger once at save instead of per page view).
1898export function bakePostContent(source) {
1899 return linkifyBody('', source || '');
1900}
1901
1902// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
1903// same resolver the federated copy uses) and bakes the profile links into content_rendered,
1904// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
1905// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
1906// the save response so the request never blocks on a slow/dead remote server.
1907export async function bakePostContentWithMentions(source) {
1908 const withHashUrls = bakePostContent(source);
1909 try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
1910 catch { return withHashUrls; }
1911}
1912
1913// Extract the AP Hashtag tag objects from already-linked reply content.
1914function hashtagTags(base, content) {
1915 const tags = [], seen = new Set();
1916 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
1917 let m;
1918 while ((m = re.exec(content || ''))) {
1919 const k = m[1].toLowerCase();
1920 if (seen.has(k)) continue; seen.add(k);
1921 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
1922 }
1923 return tags;
1924}
1925
1926// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
1927function normalizeTags(t) {
1928 if (Array.isArray(t)) return t;
1929 if (typeof t === 'string') {
1930 const s = t.trim(); if (!s) return [];
1931 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
1932 return s.split(',').map((x) => x.trim()).filter(Boolean);
1933 }
1934 return [];
1935}
1936// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
1937// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
1938// slug/href stays lowercase ("livemusic").
1939function tagParts(raw) {
1940 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
1941 if (!words.length) return null;
1942 const slug = words.join('').toLowerCase();
1943 if (!slug) return null;
1944 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
1945 return { label, slug };
1946}
1947// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
1948// Hashtag tag list (with hrefs to our /tag page).
1949function buildHashtagList(base, tagsField, content) {
1950 const out = [], seen = new Set();
1951 for (const t of normalizeTags(tagsField)) {
1952 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
1953 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
1954 }
1955 for (const h of hashtagTags(base, content)) {
1956 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
1957 out.push(h);
1958 }
1959 return out;
1960}
1961
1962// Extract Mention tag objects from already-linked content (class="u-url mention").
1963function mentionTags(content) {
1964 const tags = [], seen = new Set();
1965 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
1966 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
1967 let m;
1968 while ((m = re.exec(content || ''))) {
1969 const href = m[1];
1970 if (seen.has(href)) continue; seen.add(href);
1971 tags.push({ type: 'Mention', href, name: '@' + m[2] });
1972 }
1973 return tags;
1974}
1975// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
1976// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
1977async function resolveMentionsInText(base, html) {
1978 const inboxes = [];
1979 const handles = new Set();
1980 // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
1981 // miss: a bracketed mention federated as plain text and its target was never notified).
1982 const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
1983 let m;
1984 while ((m = re.exec(html || ''))) handles.add(m[2]);
1985 let out = String(html || '');
1986 for (const h of handles) {
1987 let actorUri = null;
1988 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
1989 if (!actorUri) continue;
1990 const actor = await fetchActor(actorUri).catch(() => null);
1991 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
1992 if (inbox) inboxes.push(inbox);
1993 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
1994 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1995 out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
1996 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
1997 }
1998 return { html: out, inboxes };
1999}
2000
2001export function buildReplyNote(base, site, row) {
2002 // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
2003 return buildNote(base, site, row, { isReply: true });
2004}
2005
2006// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
2007export function getOutboxNote(base, id) {
2008 const row = iStmts().getO.get(id);
2009 if (!row) return null;
2010 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
2011 if (!site) return null;
2012 return buildReplyNote(base, site, row);
2013}
2014
2015// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
2016// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
2017// activity here and we translate it onto the SAME delivery machinery the web UI
2018// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
2019// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
2020const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
2021
2022export async function ingestOutboxActivity(site, user, activity) {
2023 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2024 if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
2025
2026 // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
2027 let type = activity.type;
2028 let object = activity.object;
2029 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
2030 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
2031
2032 // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
2033 // Relationship) belongs to the guardianship module; anything else falls
2034 // through to the switch below.
2035 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
2036 const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
2037 if (g) return g;
2038 }
2039
2040 try {
2041 switch (type) {
2042 case 'Create': {
2043 if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
2044 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
2045 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
2046 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
2047 if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
2048 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
2049 // outbox machinery to the addressed inboxes only; shows under Messages.
2050 if (c2sVisibility(object) === 'direct') {
2051 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
2052 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
2053 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
2054 if (!recipients.length) return { status: 400, error: 'no_recipients' };
2055 // AS2 attachments (e.g. the help-buoy capture, uploaded via
2056 // uploadMedia): normalize our own absolute /media/ URLs to relative
2057 // so the deliverReply-style validation applies unchanged.
2058 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
2059 .map((a) => a && typeof a === 'object' ? {
2060 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
2061 mediaType: String(a.mediaType || ''),
2062 name: String(a.name || '').slice(0, 120),
2063 } : null)
2064 .filter(Boolean);
2065 const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
2066 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help });
2067 if (!r || !r.id) return { status: 502, error: 'direct_failed' };
2068 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2069 }
2070 if (object.inReplyTo) {
2071 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
2072 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
2073 const r = await deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text: plain });
2074 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
2075 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2076 }
2077 return await c2sCreatePost(base, site, user, object);
2078 }
2079 case 'Like':
2080 case 'Announce': {
2081 const targetUri = c2sIdOf(object);
2082 if (!targetUri) return { status: 400, error: 'missing_object' };
2083 // A non-public local note cannot be boosted or liked into the open
2084 // (shaer-tqc hardening; the Mastodon 422 equivalent).
2085 const localPid = postIdFromNoteUrl(targetUri, base);
2086 if (localPid) {
2087 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
2088 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
2089 return { status: 403, error: 'not_public' };
2090 }
2091 }
2092 const note = await resolveRemoteNote(targetUri).catch(() => null);
2093 const objUri = (note && note.object_uri) || targetUri;
2094 const authorUri = note && note.actor_uri;
2095 const kind = type === 'Announce' ? 'boost' : 'like';
2096 await sendInteraction(site, kind, objUri, authorUri);
2097 setMyReaction(site.slug, targetUri, kind, true);
2098 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
2099 return { status: 202, url: objUri };
2100 }
2101 case 'Follow': {
2102 const actorUri = c2sIdOf(object);
2103 if (!actorUri) return { status: 400, error: 'missing_object' };
2104 await followActor(site, actorUri);
2105 return { status: 202, url: actorUri };
2106 }
2107 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
2108 // ap_blocks, shows in the Block tab, and purges the actor's cached
2109 // content. Client-side filtering becomes a cache of this state.
2110 case 'Block': {
2111 const targetUri = c2sIdOf(object);
2112 if (!targetUri) return { status: 400, error: 'missing_object' };
2113 const r = await blockTarget(site, targetUri);
2114 if (r && r.error) return { status: 400, error: r.error };
2115 return { status: 202, url: targetUri };
2116 }
2117 case 'Undo': {
2118 const inner = object && typeof object === 'object' ? object : null;
2119 let innerType = inner && inner.type;
2120 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
2121 const innerTarget = c2sIdOf(inner && inner.object);
2122 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
2123 if (innerType === 'Block') {
2124 if (!innerTarget) return { status: 400, error: 'missing_object' };
2125 unblock(site, innerTarget); // release from Orbit
2126 return { status: 202, url: innerTarget };
2127 }
2128 if (innerType === 'Like' || innerType === 'Announce') {
2129 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
2130 const note = await resolveRemoteNote(innerTarget).catch(() => null);
2131 const objUri = (note && note.object_uri) || innerTarget;
2132 await sendInteraction(site, kind, objUri, note && note.actor_uri);
2133 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
2134 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
2135 return { status: 202, url: objUri };
2136 }
2137 return { status: 400, error: 'unsupported_undo' };
2138 }
2139 // Delete/Update of arbitrary objects need the post-edit pipeline; tracked
2140 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
2141 default:
2142 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
2143 }
2144 } catch (e) {
2145 console.warn('[AP] C2S ingest failed:', e && e.message);
2146 return { status: 500, error: 'ingest_error' };
2147 }
2148}
2149
2150// Create a top-level microblog post from a C2S Note and federate it. Minimal
2151// sibling of the /posts/create route: sanitized HTML content, no title/cover.
2152async function c2sCreatePost(base, site, user, object) {
2153 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
2154 if (!html.trim()) return { status: 400, error: 'empty_note' };
2155 const postId = crypto.randomUUID();
2156 const slug = 'n-' + postId.slice(0, 8);
2157 const now = new Date().toISOString();
2158 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
2159 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
2160 // (rides the existing fan_only pipeline: followers-only AP delivery + web
2161 // gating), neither = participants-only (kept local until mention addressing
2162 // lands; still followers-gated on the web).
2163 const vis = c2sVisibility(object);
2164 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
2165 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)
2166 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
2167 .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
2168 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ }
2169 bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
2170 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
2171 if (vis !== 'direct') {
2172 deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis }).catch(() => { /* best-effort */ });
2173 }
2174 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
2175}
2176
2177// The direct-note leg (ward call-for-help) lives in the guardianship module
2178// (src/services/guardianship/delivery.js); wired with our AP helpers at the
2179// bottom of this file. Re-exported so every existing caller keeps working.
2180export const c2sVisibility = Guardianship.c2sVisibility;
2181export const deliverDirectNote = Guardianship.deliverDirectNote;
2182
2183// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
2184// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
2185export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions }) {
2186 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2187 // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
2188 // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
2189 const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
2190 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2191 // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
2192 // upload route is the sole producer), image/audio/video only, max 4.
2193 const media = (Array.isArray(attachments) ? attachments : [])
2194 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
2195 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
2196 .slice(0, 4)
2197 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
2198 // A media-only reply (no text) is a valid reply.
2199 if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
2200 const me = actorId(base, site.slug);
2201 // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
2202 // parent author). An ARRAY (possibly empty) = the kept conversation partners
2203 // exactly as the bar shows them; the mention prefix, the Mention tags (via
2204 // mentionTags over the content) and the delivery targets all follow it.
2205 const kept = Array.isArray(mentions)
2206 ? mentions
2207 .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
2208 .slice(0, 8)
2209 .map((m) => ({
2210 uri: m.uri,
2211 url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
2212 handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
2213 }))
2214 : null;
2215 const mentionAnchor = (uri, url, h) => {
2216 const disp = h && h[0] === '@' ? h : '@' + (h || '');
2217 return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
2218 };
2219 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
2220 const mention = kept
2221 ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
2222 : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
2223 // Who the stored reply is "to": the parent when kept, else the first kept chip.
2224 const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
2225 const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
2226 const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
2227 let content;
2228 let mres;
2229 if (rich) {
2230 // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
2231 // sanitized editor HTML. The parent mention goes inline into the first
2232 // paragraph (Mastodon convention), or becomes its own leading one.
2233 mres = await resolveMentionsInText(base, rich);
2234 const processed = linkUrls(linkHashtags(base, mres.html));
2235 if (processed.startsWith('<p>')) {
2236 content = processed.replace('<p>', `<p>${mention}`); // inline in the first paragraph
2237 } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
2238 content = `<p>${mention}</p>${processed}`; // block content: own leading paragraph
2239 } else {
2240 content = `<p>${mention}${processed}</p>`; // bare inline text: one paragraph together
2241 }
2242 } else {
2243 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
2244 mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
2245 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2246 }
2247 const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
2248 // Dedup: skip if the exact same reply was already sent (double-submit guard).
2249 // Attachments count toward "the same": two media-only replies share content.
2250 const mediaJson = media.length ? JSON.stringify(media) : null;
2251 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
2252 .get(site.slug, parent.object_uri || '', content, mediaJson);
2253 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
2254 const id = crypto.randomUUID();
2255 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
2256 const row = iStmts().getO.get(id);
2257 const note = buildReplyNote(base, site, row);
2258 const create = {
2259 '@context': AP_CONTEXT,
2260 id: note.id + '#create', type: 'Create', actor: me,
2261 published: note.published, to: note.to, cc: note.cc, object: note,
2262 };
2263 const keys = getOrCreateKeys(site.slug);
2264 const keyId = `${me}#main-key`;
2265 const inboxes = new Set();
2266 // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
2267 const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
2268 for (const uri of mentionTargets) {
2269 const a = await fetchActor(uri).catch(() => null);
2270 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
2271 }
2272 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
2273 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
2274 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2275 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
2276 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
2277 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
2278 let delivered = 0;
2279 for (const inbox of [...inboxes].filter(Boolean)) {
2280 let ok = false;
2281 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2282 if (ok) delivered++;
2283 else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
2284 }
2285 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
2286 return { id, content, delivered };
2287}
2288
2289// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
2290// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
2291function actorUriOf(att) {
2292 if (!att) return null;
2293 if (typeof att === 'string') return att;
2294 if (Array.isArray(att)) {
2295 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
2296 if (person) return person.id;
2297 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
2298 return null;
2299 }
2300 return att.id || null;
2301}
2302
2303// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
2304// Returns a parent-shaped object usable by deliverReply(), or null.
2305export async function resolveRemoteNote(url) {
2306 if (!/^https?:\/\//i.test(String(url || ''))) return null;
2307 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
2308 if (!note || !note.id) return null;
2309 const att = note.attributedTo;
2310 const actorUri = actorUriOf(att);
2311 if (!actorUri) return null;
2312 const actor = await fetchActor(actorUri).catch(() => null);
2313 const ai = actorInfo(actor, actorUri);
2314 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
2315 // link our reply to that local post so it shows nested in the post thread.
2316 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
2317 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
2318 // and collect every ancestor author's inbox, so each participant's server —
2319 // including the original post's author — receives + threads our reply.
2320 const threadInboxes = [];
2321 const seenInbox = new Set();
2322 let cursor = note.inReplyTo, guard = 0;
2323 while (cursor && guard++ < 6) {
2324 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
2325 if (!url) break;
2326 const pn = await fetchActor(url).catch(() => null);
2327 if (!pn) break;
2328 const pa = actorUriOf(pn.attributedTo);
2329 if (pa && pa !== actorUri) {
2330 const paDoc = await fetchActor(pa).catch(() => null);
2331 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
2332 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
2333 }
2334 cursor = pn.inReplyTo; // climb to the next ancestor
2335 }
2336 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
2337 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
2338 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
2339 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
2340 const images = (Array.isArray(note.attachment) ? note.attachment : [])
2341 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
2342 .map((a) => safeUrl(a.url)).filter(Boolean);
2343 // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
2344 // shows the player card, not a loose image) and puts it in `image` instead.
2345 // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
2346 if (!images.length && note.image) {
2347 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2348 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2349 if (iu) images.push(iu);
2350 }
2351 return {
2352 object_uri: safeUrl(note.id) || note.id,
2353 actor_uri: actorUri,
2354 actor_url: ai.url,
2355 actor_handle: ai.handle,
2356 actor_name: ai.name,
2357 actor_icon: ai.icon,
2358 url: note.url || url,
2359 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
2360 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
2361 cw: note.summary || '',
2362 images,
2363 // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
2364 // image-only for the interact page preview; a boosted video-only post (Loops)
2365 // lost its media entirely because upsertBoostedNote only saw `images`.
2366 media: mediaFromNote(note),
2367 threadInboxes, // every ancestor author's inbox
2368 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
2369 poll: parsePoll(note), // a Question → its options/counts (else null)
2370 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
2371 };
2372}
2373
2374// List a site's own outbound fediverse replies (for the manage/delete view).
2375// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
2376// so the manage view can prefill an edit box; the mention is re-added on save.
2377function outboxEditableText(content) {
2378 return String(content || '')
2379 .replace(/<br\s*\/?>/gi, '\n')
2380 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
2381 .replace(/<[^>]+>/g, '')
2382 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
2383 .trim();
2384}
2385export function listOutbox(siteSlug) {
2386 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')
2387 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
2388}
2389
2390// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
2391export async function deliverOutboxDelete(site, outboxId) {
2392 const row = iStmts().getO.get(outboxId);
2393 if (!row || row.site_slug !== site.slug) return false;
2394 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2395 if (base) {
2396 const me = actorId(base, site.slug);
2397 const nid = noteId(base, row.id);
2398 const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
2399 const keys = getOrCreateKeys(site.slug);
2400 const inboxes = new Set();
2401 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
2402 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2403 for (const inbox of [...inboxes].filter(Boolean)) {
2404 try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
2405 enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
2406 }
2407 }
2408 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
2409 return true;
2410}
2411
2412// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
2413// re-linked) and send an Update(Note) so recipients refresh their cached copy.
2414export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
2415 const row = iStmts().getO.get(outboxId);
2416 if (!row || row.site_slug !== site.slug) return false;
2417 const text = String(newText || '').trim();
2418 // Rich edit: same sanitize + enrichment pipeline as deliverReply.
2419 const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
2420 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2421 if (!text && !rich) return false;
2422 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2423 if (!base) return false;
2424 const me = actorId(base, site.slug);
2425 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
2426 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
2427 const _h = row.to_handle || deriveHandle(row.to_actor);
2428 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
2429 // An edit must not drop co-mentions (u02): reuse the OLD content's leading
2430 // mention anchors (the bar's kept list at send time) when present; only fall
2431 // back to rebuilding the single to_actor mention for legacy rows.
2432 const oldPrefix = (String(row.content || '')
2433 .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
2434 const mention = oldPrefix || (row.to_actor
2435 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
2436 let content;
2437 let mres;
2438 if (rich) {
2439 mres = await resolveMentionsInText(base, rich);
2440 const processed = linkUrls(linkHashtags(base, mres.html));
2441 if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
2442 else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
2443 else content = `<p>${mention}${processed}</p>`;
2444 } else {
2445 mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
2446 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2447 }
2448 // Language may be updated with the edit; attachments always survive untouched.
2449 const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
2450 db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
2451 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
2452 note.updated = new Date().toISOString();
2453 const update = {
2454 '@context': AP_CONTEXT,
2455 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
2456 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
2457 };
2458 const keys = getOrCreateKeys(site.slug);
2459 const inboxes = new Set();
2460 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
2461 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2462 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
2463 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
2464 let delivered = 0;
2465 for (const inbox of [...inboxes].filter(Boolean)) {
2466 let ok = false;
2467 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2468 if (ok) delivered++;
2469 else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
2470 }
2471 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
2472 return { ok: true, content, delivered };
2473}
2474
2475// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
2476// Resolve an @user@domain handle to its actor URL via WebFinger.
2477export async function webfingerResolve(handle) {
2478 const h = String(handle || '').trim().replace(/^@/, '');
2479 const parts = h.split('@');
2480 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
2481 const acct = `${parts[0]}@${parts[1]}`;
2482 try {
2483 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
2484 { headers: { Accept: 'application/jrd+json, application/json' } });
2485 if (!r.ok) return null;
2486 const jrd = await r.json();
2487 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
2488 return safeUrl(link ? link.href : '') || null;
2489 } catch { return null; }
2490}
2491
2492let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
2493function fwStmts() {
2494 if (!_insFw) {
2495 _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)');
2496 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
2497 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
2498 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
2499 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
2500 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
2501 }
2502 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
2503}
2504export function listFollowing(slug) { return fwStmts().list.all(slug); }
2505
2506// Toggle auto-boost ("feature") on an account we already follow.
2507export function setAutoBoost(slug, actorUri, on) {
2508 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
2509 // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
2510 // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
2511 if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
2512 return { ok: true };
2513}
2514
2515let _insTl, _listTl, _delTl;
2516function tlStmts() {
2517 if (!_insTl) {
2518 _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)');
2519 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');
2520 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
2521 }
2522 return { ins: _insTl, list: _listTl, del: _delTl };
2523}
2524export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
2525
2526// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
2527// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
2528// friend's images/audio/video natively, exactly like own outbox posts. The
2529// stored `type` is the mediaType and may be ''. Malformed JSON yields
2530// undefined and never blocks the item.
2531export function timelineAttachments(mediaJson) {
2532 try {
2533 const list = mediaJson ? JSON.parse(mediaJson) : [];
2534 const rows = (Array.isArray(list) ? list : [])
2535 .filter((m) => m && m.url)
2536 .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
2537 return rows.length ? rows : undefined;
2538 } catch { return undefined; }
2539}
2540
2541// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
2542let _abCount, _cirkelPosts, _cirkelMembers;
2543export function autoBoostCount(slug) {
2544 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; }
2545}
2546export function getCirkelPosts(slug, limit, offset) {
2547 try {
2548 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
2549 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
2550 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
2551 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
2552 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
2553 FROM ap_timeline t
2554 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
2555 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
2556 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
2557 LIMIT ? OFFSET ?`);
2558 return _cirkelPosts.all(slug, limit || 60, offset || 0);
2559 } catch { return []; }
2560}
2561export function getCirkelMembers(slug) {
2562 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 []; }
2563}
2564// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
2565let _markBoost, _unmarkBoost, _boostedCount;
2566export function markBoosted(slug, noteId) {
2567 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
2568}
2569export function unmarkBoosted(slug, noteId) {
2570 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
2571}
2572let _markLike, _unmarkLike;
2573export function markLiked(slug, noteId) {
2574 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
2575}
2576export function unmarkLiked(slug, noteId) {
2577 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
2578}
2579export function getTimelineReaction(slug, noteId) {
2580 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 }; }
2581}
2582// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
2583// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
2584// the Cirkel with a Boost badge, then flag it boosted.
2585export function upsertBoostedNote(slug, note) {
2586 if (!slug || !note || !note.object_uri) return;
2587 const id = note.object_uri;
2588 // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
2589 // rendered a bare text tile); fall back to the image-only list for older callers.
2590 const media = (note.media && note.media !== '[]')
2591 ? note.media
2592 : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
2593 try {
2594 const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
2595 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
2596 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
2597 if (!r.changes) {
2598 // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
2599 // resolved note. Without this a row cached without its cover (or with
2600 // stale content) stayed stale forever — even boosting again didn't heal it.
2601 // Keep the CACHED media when the resolve yielded none: an empty re-resolve
2602 // used to clobber a good media_json (the followed copy had the video, the
2603 // boost wiped it to []).
2604 db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
2605 nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
2606 .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
2607 }
2608 } catch { /* ignore */ }
2609 markBoosted(slug, id);
2610}
2611export function boostedCount(slug) {
2612 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; }
2613}
2614
2615// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
2616// /ap/users/<slug> (content negotiation; Location may be relative). Used by
2617// followActor for bare-domain follows.
2618// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
2619// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
2620// never throws anything into the fediverse automatically" (would surprise-Follow
2621// for some operators at scale). The dead circle_links table stays as harmless dead
2622// data; an operator restores an old cirkel by re-following in /following (their click).
2623async function resolveApActor(siteUrl) {
2624 try {
2625 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
2626 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
2627 if (r.ok) return siteUrl;
2628 } catch { /* unreachable */ }
2629 return null;
2630}
2631
2632// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
2633// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
2634// note and refreshes content + media (recovers covers/edits that were delivered
2635// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
2636// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
2637const SELFHEAL_VERSION = 7; // v7: rerun v6 with retry-until-clean semantics (origins briefly offline no longer stay stale forever)
2638async function fetchNoteAP(url) {
2639 try {
2640 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
2641 if (r.status === 404 || r.status === 410) return 404;
2642 if (r.ok) return await r.json();
2643 } catch { /* unreachable */ }
2644 return null;
2645}
2646function mediaFromNote(note) {
2647 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
2648 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
2649 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2650 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2651 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
2652 }
2653 return JSON.stringify(atts);
2654}
2655// A generic SSRF-safe AP GET (collections / pages).
2656async function apGetJson(url) {
2657 try {
2658 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
2659 if (!r.ok) return null;
2660 const len = Number(r.headers.get('content-length') || 0);
2661 if (len > 3_000_000) return null;
2662 return await r.json();
2663 } catch { return null; }
2664}
2665// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
2666// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
2667// history-from-before-you-followed or a delivery that was missed while you were down;
2668// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
2669export async function backfillFromOutbox(slug, actorUri, limit = 20) {
2670 try {
2671 if (!slug || !actorUri) return 0;
2672 const actor = await fetchActor(actorUri);
2673 if (!actor || !actor.outbox) return 0;
2674 let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
2675 let items = (page && (page.orderedItems || page.items)) || [];
2676 if (!items.length && page && page.first) {
2677 page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
2678 items = (page && (page.orderedItems || page.items)) || [];
2679 }
2680 if (!Array.isArray(items) || !items.length) return 0;
2681 const ai = actorInfo(actor, actorUri);
2682 let added = 0;
2683 for (const it of items.slice(0, limit)) {
2684 // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
2685 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
2686 if (!o || !o.id) continue;
2687 if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
2688 if (o.inReplyTo) continue; // top-level only
2689 const auth = actorUriOf(o.attributedTo);
2690 if (auth && auth !== actorUri) continue; // their OWN posts only
2691 const html = HtmlSanitizerService.sanitize(o.content || '');
2692 const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
2693 try {
2694 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);
2695 if (r && r.changes > 0) added++;
2696 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
2697 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 */ } }
2698 } catch { /* ignore */ }
2699 }
2700 if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
2701 return added;
2702 } catch { return 0; }
2703}
2704
2705// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
2706// Most replies reach us by delivery, but replies-to-replies that live on other servers and
2707// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
2708// we DO have, caching any newly-found ones in ap_interactions.
2709//
2710// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
2711// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
2712// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
2713// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
2714// never runs in a page request — the view renders from cache; a stale post kicks off a
2715// background refresh for the NEXT view.
2716const THREAD_TTL_MS = 15 * 60 * 1000; // don't re-crawl a post more than ~4×/hour
2717const THREAD_MAX_DEPTH = 1; // one hop per crawl (like Mastodon); deeper fills in over crawls
2718const THREAD_MAX_FETCHES = 30; // hard cap on remote GETs per crawl (be a good peer)
2719const _crawlingThreads = new Set(); // per-post in-flight lock (no stampede across views)
2720
2721function threadCrawlTs(postId) {
2722 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
2723 catch { return 0; }
2724}
2725function setThreadCrawlTs(postId, ts) {
2726 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)); }
2727 catch { /* ignore */ }
2728}
2729
2730// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
2731// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
2732async function collectReplyItems(repliesRef, maxPages, budget) {
2733 const uris = [];
2734 let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
2735 if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
2736 let pages = 0;
2737 while (node && pages++ < maxPages) {
2738 for (const it of (node.items || node.orderedItems || [])) {
2739 const u = typeof it === 'string' ? it : (it && it.id);
2740 if (u && /^https?:\/\//i.test(u)) uris.push(u);
2741 }
2742 if (!node.next) break;
2743 node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
2744 }
2745 return uris;
2746}
2747
2748async function crawlThread(postId) {
2749 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2750 if (!base) return;
2751 // Seed frontier = the remote reply note URIs we already have; also the dedup set.
2752 let known;
2753 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)); }
2754 catch { return; }
2755 const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
2756 if (!seeds.length) return; // nothing remote to expand
2757 // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
2758 // crawler never re-adds them via thread-filling (they're gone from the seeds
2759 // already because rejectInteraction deleted their ap_interactions row).
2760 try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
2761 catch { /* table always exists after boot migration */ }
2762
2763 let fetches = 0;
2764 const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
2765 const visited = new Set(); // notes whose replies collection we've already expanded
2766 let frontier = seeds.slice();
2767 let added = 0;
2768
2769 for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
2770 const nextFrontier = [];
2771 for (const noteUri of frontier) {
2772 if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
2773 visited.add(noteUri);
2774 const note = await budget.get(noteUri);
2775 if (!note || !note.replies) continue;
2776 const childUris = await collectReplyItems(note.replies, 2, budget);
2777 for (const cu of childUris) {
2778 if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
2779 known.add(cu);
2780 const child = await budget.get(cu);
2781 if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
2782 if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
2783 const actorUri = actorUriOf(child.attributedTo);
2784 if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
2785 const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
2786 const ai = actorInfo(actor, actorUri);
2787 const html = HtmlSanitizerService.sanitize(child.content || '');
2788 // The child replies to `note` by construction (it's in note's replies collection).
2789 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)); added++; } catch { /* ignore */ }
2790 nextFrontier.push(child.id); // expand this reply's own replies next depth
2791 }
2792 }
2793 frontier = nextFrontier;
2794 }
2795 if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
2796}
2797
2798// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
2799// fires a background crawl only if this post hasn't been crawled within the TTL.
2800export function maybeCrawlThread(postId) {
2801 if (!postId || _crawlingThreads.has(postId)) return;
2802 if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
2803 _crawlingThreads.add(postId);
2804 setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
2805 crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
2806}
2807
2808let _selfHealing = false;
2809export async function selfHealTimeline() {
2810 if (_selfHealing) return; _selfHealing = true;
2811 try {
2812 let cur = 0;
2813 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; }
2814 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
2815 let rows = [];
2816 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
2817 let healed = 0, failed = 0;
2818 for (const r of rows) {
2819 try {
2820 const note = await fetchNoteAP(r.id);
2821 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
2822 if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
2823 const html = HtmlSanitizerService.sanitize(note.content || '');
2824 const media = mediaFromNote(note);
2825 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
2826 const cw = note.summary || null;
2827 const url = note.url || null; // re-sync the human url (catches a remote slug rename)
2828 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url)) {
2829 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ?').run(html || r.content, media, nsfw, cw, url, r.id);
2830 healed++;
2831 }
2832 } catch { failed++; /* per-note best-effort */ }
2833 }
2834 // Only mark this version DONE after a clean pass. Some origins are briefly
2835 // offline exactly when we heal (phone-hosted instances!): skipping them and
2836 // consuming the version would leave those rows stale forever. Instead retry
2837 // on the next boots, giving up after a few attempts (permanently-dead
2838 // origins answer 404/410 and are deleted above, so they don't loop).
2839 const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
2840 let attempts = 0;
2841 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 */ }
2842 if (failed === 0 || attempts >= 4) {
2843 setSetting('selfheal_version', SELFHEAL_VERSION);
2844 setSetting('selfheal_attempts', 0);
2845 } else {
2846 setSetting('selfheal_attempts', attempts + 1);
2847 }
2848 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
2849 } catch { /* never block boot */ } finally { _selfHealing = false; }
2850}
2851
2852// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
2853export async function followActor(site, handle, autoBoost = false) {
2854 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2855 if (!base || !site || !site.slug) return { error: 'config' };
2856 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
2857 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
2858 // resolves to its AP actor, so you can follow a site by just its domain.
2859 const s = String(handle || '').trim();
2860 let actorUrl;
2861 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
2862 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
2863 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
2864 else actorUrl = null;
2865 if (!actorUrl) return { error: 'not_found' };
2866 const actor = await fetchActor(actorUrl).catch(() => null);
2867 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
2868 const ai = actorInfo(actor, actor.id);
2869 const me = actorId(base, site.slug);
2870 const keys = getOrCreateKeys(site.slug);
2871 const followId = `${me}#follow-${Date.now()}-${rid()}`;
2872 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
2873 const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
2874 // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
2875 // timeout, transient 5xx) is retried with backoff instead of staying stuck on
2876 // 'pending' forever — the Accept can only come back once the Follow lands.
2877 await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
2878 console.log('[AP] follow', site.slug, '→', actor.id);
2879 // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
2880 if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
2881 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
2882}
2883
2884// Resolve a profile URL or @handle to a followable remote actor (for the
2885// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
2886// when it isn't a reachable actor (e.g. the input was a post, not a profile).
2887export async function resolveRemoteActor(input) {
2888 const s = String(input || '').trim();
2889 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
2890 if (!actorUrl) return null;
2891 const actor = await fetchActor(actorUrl).catch(() => null);
2892 if (!actor || !actor.id || !actor.inbox) return null;
2893 const ai = actorInfo(actor, actor.id);
2894 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
2895}
2896
2897export async function unfollowActor(site, actorUri) {
2898 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2899 const me = actorId(base, site.slug);
2900 const keys = getOrCreateKeys(site.slug);
2901 const row = fwStmts().one.get(site.slug, actorUri);
2902 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
2903 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
2904 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
2905 // rather than send an unmatchable one. Deliver durably via the retry queue.
2906 if (row && row.inbox && row.follow_id) {
2907 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 } };
2908 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
2909 } else if (row && row.inbox) {
2910 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
2911 }
2912 fwStmts().del.run(site.slug, actorUri);
2913 return { ok: true };
2914}
2915
2916// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
2917// guardian of the local ward `wardSlug` — so a signed GET from it may read the
2918// ward's non-public history without the guardian appearing as a follower.
2919export function isWardGuardian(wardSlug, actorUri) {
2920 try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
2921}
2922
2923// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
2924// Accept to the follower and record them, so delivery (incl. followers-only)
2925// begins. `pending` is a row from ap_pending_follows.
2926export async function acceptGatedFollow(pending) {
2927 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2928 const slug = pending.ward_slug;
2929 const me = actorId(base, slug);
2930 const keys = getOrCreateKeys(slug);
2931 fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
2932 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
2933 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
2934 await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
2935 const filled = pending.follower_shared_inbox &&
2936 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);
2937 if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
2938 console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
2939 return { ok: true };
2940}
2941
2942// The guardians denied the follow: send a Reject so the follower's server clears
2943// its pending state, then the caller drops the record.
2944export async function rejectGatedFollow(pending) {
2945 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2946 const slug = pending.ward_slug;
2947 const me = actorId(base, slug);
2948 const keys = getOrCreateKeys(slug);
2949 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
2950 const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
2951 if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
2952 console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
2953 return { ok: true };
2954}
2955
2956// Send a Like or Announce (boost) on a remote note FROM this site.
2957export async function sendInteraction(site, kind, targetNoteId, authorUri) {
2958 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2959 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
2960 const me = actorId(base, site.slug);
2961 const keys = getOrCreateKeys(site.slug);
2962 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
2963 // reblog (matched on actor+object — no record of the original Announce needed).
2964 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
2965 const followersCol = `${me}/followers`;
2966 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
2967 // attributes the boost to their post and notifies them — without this, a shared-inbox
2968 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
2969 // stamp keep us aligned with what Mastodon emits.
2970 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
2971 let act;
2972 if (kind === 'unboost' || kind === 'unlike') {
2973 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
2974 // no record of the original activity needed — Mastodon honours both).
2975 const inner = kind === 'unboost' ? 'Announce' : 'Like';
2976 act = {
2977 '@context': AP_CONTEXT,
2978 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
2979 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
2980 };
2981 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
2982 } else {
2983 const type = kind === 'boost' ? 'Announce' : 'Like';
2984 act = {
2985 '@context': AP_CONTEXT,
2986 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
2987 type, actor: me, object: targetNoteId,
2988 };
2989 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
2990 }
2991 const inboxes = new Set();
2992 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
2993 // routes the Announce/Like to the right post unambiguously.
2994 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
2995 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
2996 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
2997 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
2998 // silently lose the boost — same durability a new post (deliverCreate) already gets.
2999 let queued = 0;
3000 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
3001 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
3002 return { ok: true, delivered: queued };
3003}
3004
3005// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
3006export function getNotifications(slug, limit) {
3007 // Per-source cap scales with the requested limit so Messages can page deep
3008 // (Load more). Bounded so a huge offset can't ask for unbounded rows.
3009 const L = Math.min(1000, Math.max(80, limit || 60));
3010 const out = [];
3011 try {
3012 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)) {
3013 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
3014 }
3015 } catch { /* ignore */ }
3016 try {
3017 const rows = db.prepare(`
3018 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
3019 p.slug AS post_slug, p.title AS post_title
3020 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
3021 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
3022 ORDER BY i.created_at DESC LIMIT ?
3023 `).all(slug, L);
3024 for (const r of rows) out.push({
3025 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
3026 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
3027 // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
3028 visibility: r.visibility || 'public',
3029 });
3030 } catch { /* ignore */ }
3031 try {
3032 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)) {
3033 // The reported objects: our own notes resolve to post links so the owner
3034 // sees WHICH post the report is about; other URIs (e.g. the actor itself)
3035 // are skipped — the report row already names the account.
3036 const about = [];
3037 try {
3038 for (const u of JSON.parse(r.objects || '[]')) {
3039 const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
3040 if (!m) continue;
3041 const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
3042 if (p) about.push({ slug: p.slug, title: p.title || p.slug });
3043 }
3044 } catch { /* malformed objects json → no links */ }
3045 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 });
3046 }
3047 } catch { /* ignore */ }
3048 try {
3049 for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
3050 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, actorUri: r.actor_uri, created_at: r.created_at });
3051 }
3052 } catch { /* ignore */ }
3053 // Your own polls that have closed → a "results are in" item, derived read-time
3054 // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
3055 try {
3056 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
3057 if (site) {
3058 const polls = db.prepare(`
3059 SELECT id, slug, title, poll_json FROM posts
3060 WHERE site_id = ? AND poll_json IS NOT NULL
3061 AND json_extract(poll_json, '$.closed') = 1
3062 AND json_extract(poll_json, '$.endTime') IS NOT NULL
3063 ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
3064 for (const p of polls) {
3065 const view = ownPollView(p);
3066 if (!view) continue;
3067 let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
3068 out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
3069 }
3070 }
3071 } catch { /* ignore */ }
3072 // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
3073 // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
3074 // between likes, which also broke Messages' like-grouping).
3075 out.sort((a, b) => _msgTs(b) - _msgTs(a));
3076 return out.slice(0, limit || 60);
3077}
3078function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
3079
3080// ── Blocking / defederation ───────────────────────────────────────
3081// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
3082// Orbit"). Thin delegations keep every existing caller working.
3083export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
3084
3085// True if an actor (or its whole domain) is blocked anywhere on this instance.
3086// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
3087// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
3088// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
3089// author's Update(Question) refreshes the authoritative totals when it arrives.
3090export async function voteOnPoll(site, questionId, choices) {
3091 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3092 if (!base || !site || !site.slug || !questionId) return { error: 'config' };
3093 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 */ }
3094 if (!row || !row.poll_json) return { error: 'not_found' };
3095 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
3096 if (poll.closed) return { error: 'closed' };
3097 if (poll.voted) return { error: 'already' };
3098 const valid = new Set(poll.options.map((o) => o.name));
3099 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
3100 if (!picks.length) return { error: 'invalid' };
3101 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
3102 const me = actorId(base, site.slug);
3103 const keys = getOrCreateKeys(site.slug);
3104 const authorUri = row.author_uri || null;
3105 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
3106 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
3107 if (!inbox) return { error: 'unreachable' };
3108 for (const name of chosen) {
3109 const nid = `${me}/votes/${Date.now()}-${rid()}`;
3110 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
3111 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
3112 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
3113 }
3114 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
3115 poll.voted = poll.multiple ? chosen : chosen[0];
3116 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
3117 if (poll.voters != null) poll.voters += 1;
3118 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
3119 return { ok: true };
3120}
3121
3122// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
3123// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
3124// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
3125// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
3126export async function voteOnRemotePoll(site, questionUrl, choices) {
3127 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3128 if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
3129 const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
3130 if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
3131 const poll = parsePoll(q);
3132 if (!poll) return { error: 'not_found' };
3133 if (poll.closed) return { error: 'closed' };
3134 const valid = new Set(poll.options.map((o) => o.name));
3135 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
3136 if (!picks.length) return { error: 'invalid' };
3137 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
3138 const authorUri = actorUriOf(q.attributedTo);
3139 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
3140 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
3141 if (!inbox) return { error: 'unreachable' };
3142 const me = actorId(base, site.slug);
3143 const keys = getOrCreateKeys(site.slug);
3144 for (const name of chosen) {
3145 const nid = `${me}/votes/${Date.now()}-${rid()}`;
3146 const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
3147 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
3148 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
3149 }
3150 return { ok: true };
3151}
3152
3153// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
3154// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
3155// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
3156// author is resolved + included) OR pass actorUri to report an account directly.
3157export async function sendReport(site, { objectUri, actorUri, reason }) {
3158 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3159 if (!base || !site || !site.slug) return { error: 'config' };
3160 let targetActor = actorUri || null;
3161 let noteUri = null;
3162 if (objectUri && /^https?:\/\//i.test(objectUri)) {
3163 const note = await apGetJson(objectUri).catch(() => null);
3164 if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
3165 else if (!targetActor) return { error: 'not_found' };
3166 }
3167 if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
3168 const actor = await fetchActor(targetActor).catch(() => null);
3169 const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
3170 if (!inbox) return { error: 'unreachable' };
3171 const me = actorId(base, site.slug);
3172 const keys = getOrCreateKeys(site.slug);
3173 const object = [targetActor];
3174 if (noteUri && noteUri !== targetActor) object.push(noteUri);
3175 const flag = {
3176 '@context': AP_CONTEXT,
3177 id: `${me}#report-${Date.now()}-${rid()}`,
3178 type: 'Flag',
3179 actor: me,
3180 content: String(reason == null ? '' : reason).slice(0, 3000),
3181 object, // [account, status?] — Mastodon's Flag shape
3182 to: [targetActor],
3183 };
3184 deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
3185 return { ok: true };
3186}
3187
3188export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
3189
3190// Block an actor (@handle or actor URL) or a whole domain; purges their content.
3191// The handle resolver is ours; the storage/purge lives in BlocklistService.
3192export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
3193
3194export function unblock(site, target) { return Blocklist.unblock(site, target); }
3195
3196// ── Guardianship module wiring (src/services/guardianship/) ────────
3197// The module owns FEP-633c (context, relations, handshake, queues, the
3198// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
3199// imports us back.
3200function selfActorId(slug) {
3201 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3202 return actorId(base, slug);
3203}
3204// Deliver one activity to one actor's inbox, signed; queued + retried on any
3205// hiccup so a slow or briefly-down ward server never loses the offer. Returns
3206// { delivered, inbox }: delivered=false means the account could not be
3207// resolved at all (a bad handle) — the offer stays recorded regardless.
3208async function deliverToActor(site, actorUri, activity) {
3209 const me = selfActorId(site.slug);
3210 const keys = getOrCreateKeys(site.slug);
3211 const payload = { '@context': AP_CONTEXT, ...activity };
3212 const a = await fetchActor(actorUri).catch(() => null);
3213 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
3214 if (!inbox) {
3215 console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
3216 return { delivered: false, inbox: null };
3217 }
3218 try {
3219 const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
3220 if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
3221 console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
3222 } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
3223 enqueueDelivery(site.slug, inbox, payload);
3224 return { delivered: true, inbox }; // queued: the retry worker gets it there
3225}
3226Guardianship.wireDelivery({
3227 actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
3228 getOutboxRow: (id) => iStmts().getO.get(id),
3229 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
3230});
3231// Which local site (if any) hosts this actor URI — used by the handshake to
3232// apply the local side of a commit and to derive a ward's existing guardians.
3233function localSlugOf(actorUri) {
3234 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3235 if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
3236 const slug = slugFromActorUrl(actorUri);
3237 if (!slug) return null;
3238 try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
3239 catch { return null; }
3240}
3241Guardianship.wireHandshake({
3242 selfId: selfActorId,
3243 localSlug: localSlugOf,
3244 deliverTo: deliverToActor,
3245 deriveHandle,
3246 fetchActor,
3247 // Guardian PWA / Berichten push. The kid answers an incoming offer in its
3248 // own Berichten; an existing guardian and a commit land in the PWA.
3249 onEvent: (slug, ev) => {
3250 const L = pushLang(slug);
3251 const texts = {
3252 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward
3253 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward
3254 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
3255 }[ev.kind];
3256 if (!texts) return;
3257 const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?';
3258 const url = ev.kind === 'offer_received' ? `${pushPrefix(slug)}/messages` : '/guardian';
3259 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
3260 },
3261});
3262
3263export default {
3264 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
3265 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
3266 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
3267 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
3268 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
3269 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
3270 acceptGatedFollow, rejectGatedFollow, isWardGuardian,
3271 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
3272 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
3273 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
3274 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
3275 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
3276 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
3277 noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
3278 getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
3279};
Note: See TracBrowser for help on using the repository browser.