source: Klonkt/src/services/ActivityPubService.js@ 2d66d66

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

Feature: C2S owner can read own followers/following (klonkt-demo-6kc)

The followers and following collections stay count-only for the public
(privacy), but a request carrying a C2S bearer scoped to that site (the account
owner) now returns the real actor URIs, so a client (Shaer) can build a friends
list. This was the one gap keeping the Shaer app's orbit empty against a real
Klonkt (it worked against the shaer-daemon, which serves the full lists).

  • buildFollowers/buildFollowing take an optional items array: when present, orderedItems carries the URIs and totalItems reflects them; otherwise count-only as before.
  • The two GET routes verify a bearer (OAuth.verifyBearer) and, when it is scoped to the requested slug, return the full list from ap_followers.actor_uri / ap_following.actor_uri (status=accepted); everyone else gets count-only. A private site's owner can read it even when it is not publicly listed.

3 new builder tests (count-only vs owner items vs empty owner list); 83 green.
Live-verified: owner bearer -> real URIs (alice/bob) in orderedItems; no bearer
-> orderedItems empty with the count intact; a token for another slug does not
unlock it.

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

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