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

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

Feature: owner followers/following carry name + avatar (shaer-aa3)

The C2S owner view of followers and following returned bare actor
URIs, so a client could only show ids. Each entry is now an AS2 actor
reference { id, type: Person, name, preferredUsername, icon } built
from the best cached display Klonkt already holds: the follower's own
row (now cached at Follow time), then ap_following, interactions,
timeline, mentions, falling back to a handle derived from the URI.
Priority is the set display name, then the chosen username, then the
id. ap_followers gains name/handle/icon, populated when an inbound
Follow is accepted (fetchActor already runs there).

Changed files:
src/config/database.js

  • additive columns ap_followers.name/handle/icon

src/services/ActivityPubService.js

  • cache follower display on inbound Follow
  • actorDisplay(slug, uri): best cached display across the caches
  • buildActorRef(slug, uri): AS2 actor reference with display

src/routes/activitypub.js

  • owner followers/following map URIs through buildActorRef

New file:
test/c2s-contacts.test.js

  • name/username/id priority + interaction-cache fallback

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

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