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

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

Feature: Messages merges Replies + Notifications into one inbox

/messages replaces /fediverse (manage own replies) and /notifications with a
single stream, per the Robin+Bart decision. getMessages() merges notifications
with your outbound replies ('sent' items, edit/delete via their outbox routes)
and collapses consecutive likes/boosts on the same post into one grouped item.
Design refresh: filter chips (All/Conversations/Activity/Sent, client-side),
avatars with a type-dot overlay, sent items with a direction indicator and
accent border, a lock badge on private replies (visibility now flows through
getNotifications along with actor icons), and unread dots via the existing
seen-watermark (read before marking seen). The two tabs collapse into one
Messages tab carrying the badge; the bookmarklet moved to /messages; old routes
redirect. Also fixes a latent NaN-unsafe date sort in getNotifications that
scrambled ordering when a row had a garbled created_at (seen live). i18n
NL/EN/DE. 4 new tests (67 green); verified in a browser incl. grouping, chips,
private badge, edit/delete forms. Beads: klonkt-demo-pkg. Old
fedi-notifications.ejs and the authorize-interaction manage block are now
unreachable; cleanup tracked in klonkt-demo-bk8.

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

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