source: Klonkt/src/services/ActivityPubService.js@ 0cea12b

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

Feature: Load more on Solo + Cirkel, page 72 (klonkt-demo-r9u, slice 3/4)

The home feed (Solo) and the Cirkel feed now page in 72s instead of a
hard 30/80 cap. Both render two containers (list + grid, CSS-toggled),
so the append fragment (partials/home-append) sends the post-cards as
the primary beforeend swap into #post-list and OOB-appends the same
posts as tiles into #grid-tiles. htmx 1.9.12 unwraps the OOB wrapper's
children on a positional swap, so the tiles land as direct grid items
(the display:contents wrapper is a belt-and-braces fallback). The
button lives once below both views and OOB-replaces itself with the
next offset, dropping on the last page. Pinned posts stay on page 1
only. getCirkelPosts gains an offset arg; FEED_PAGE hoisted to the top
of posts.js.

Browser-verified both feeds: 72 -> 144 -> 150 in list AND grid, then
the button disappears.

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

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