source: Klonkt/src/services/ActivityPubService.js@ 33e1dbd

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

Feature: rich replies phase 1 — shared editor, mobile full-screen, language (klonkt-demo-c7f)

Replying to fediverse comments used bare textareas in four places. This adds
ONE shared, progressively-enhanced editor component and mounts it on the two
new-reply spots (inline thread reply in fedi-node, and authorize_interaction);
the edit forms and prutter follow with the media phase.

  • partials/reply-editor.ejs + assets/js/reply-editor.js + css: renders a plain textarea that works without JS; the JS upgrades it to a contenteditable with a small toolbar (bold/italic/link/list/quote) and a language select. Assets load once per render even when the partial repeats per comment.
  • Mobile (max-width 700px): focusing the editor opens a FULL-SCREEN compose overlay (top bar with cancel and send, scroll lock), the right pattern on phones. Two real-world fixes came out of browser verification: site CSS gives thread forms display:contents, which collapses the form box and breaks both flex and position:fixed (now overridden with !important); and the overlay sits at z-index 1200, above the bottom tab bar (1050) and sheets (1100).
  • Server: fedi-reply and authorize_interaction accept content (editor HTML) + language next to text. deliverReply sanitizes the HTML (HtmlSanitizerService), runs the same mention/hashtag/URL enrichment as the plain path, and places the parent mention inline in the first paragraph (own paragraph before block content, merged paragraph around bare inline text). Plain-text path unchanged (no-JS fallback).
  • ap_outbox.language (additive) -> contentMap on the outgoing Note.

5 new tests (sanitize, mention placement, plain path unchanged, empty-html
reject, bogus language dropped); 88 green. Live-verified in the browser:
desktop upgrade, mobile full-screen (enter/cancel/scroll-lock), and a real
submit landing in ap_outbox with markup + language intact.

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

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