source: Klonkt/src/services/ActivityPubService.js@ 7b04d3b

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

Feature: Load more on News feed, page 72 (klonkt-demo-r9u, slice 1/4)

News (Krant) now pages in blocks of 72 (divisible by 2/3/4 so grid
columns stay full) with an htmx "Load more" button instead of a hard
60-item cap. getTimeline gains an offset arg; the route fetches 72+1 to
know whether the button belongs, and serves an append fragment
(partials/news-append) that swaps the next items beforeend into #tl-feed
and OOB-replaces the button with the next offset (or drops it on the
last page).

Reusable pieces for the other three views (Solo, Cirkel, Messages):

  • partials/load-more.ejs (the button + OOB wrapper)
  • partials/tl-item.ejs (extracted the timeline item so full page and append render identically)
  • .load-more-* CSS in shared-styles, i18n feed.load_more (NL/EN/DE)

Tests: feed-pagination.test.js (offset paging, no overlap, probe of
PAGE+1, past-end empty). Browser-verified: 72 -> 144 -> 150 then the
button disappears.

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

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