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

main
Last change on this file since f882048 was f2796d3, checked in by roboburr <roboburr@…>, 2 months ago

fix(federation): AP conformance round — actor enrichment, following collection, webfinger, unfollow fix

Audited the AP surface against the W3C spec + fediverse pioneers (Mastodon/Hubzilla/
Friendica/WordPress-ActivityPub/PeerTube). Additive enrichment toward non-Mastodon
receivers + one real correctness fix; the proven Mastodon happy-path (actor core, Note,
Create, Delete, Update) is left untouched.

  • src/services/ActivityPubService.js — buildActor now emits following, published (site created date) and attachment PropertyValue rows from profile_links (rel=me, HTML-escaped) so profile metadata federates; new buildFollowing (count-only collection); unfollowActor now sends Undo(Follow) with the stored real follow id via the retry queue (the old ${me}#follow fallback never matched, so unfollow silently failed on the remote), and skips the network Undo for legacy rows with no stored follow id
  • src/routes/activitypub.js — WebFinger adds aliases + a profile-page link; new GET /ap/users/:slug/following (count only); NodeInfo users.total now counts public sites (AP actors) instead of the users/account-rows table

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

  • Property mode set to 100644
File size: 90.2 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';
23
24const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
25
26// Short random suffix so two activity ids minted in the same millisecond (e.g.
27// parallel saves) don't collide and get deduped by a receiver.
28const rid = () => crypto.randomBytes(4).toString('hex');
29
30// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
31// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
32const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
33
34// ── SSRF guard for outbound fetches ───────────────────────────────
35// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
36// every outbound fetch must refuse hosts that resolve to private/loopback ranges
37// (cloud metadata, internal services) — on the initial host AND each redirect hop.
38function isBlockedIp(ip) {
39 if (!ip) return true;
40 const v = net.isIP(ip);
41 if (v === 4) {
42 const o = ip.split('.').map(Number);
43 return o[0] === 127 || o[0] === 10 || o[0] === 0
44 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
45 || (o[0] === 192 && o[1] === 168)
46 || (o[0] === 169 && o[1] === 254)
47 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
48 }
49 if (v === 6) {
50 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
51 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
52 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
53 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
54 }
55 return true; // not an IP literal we recognise → refuse
56}
57async function assertPublicHost(hostname) {
58 if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
59 const addrs = await dns.promises.lookup(hostname, { all: true });
60 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
61}
62export async function safeFetch(url, opts = {}, maxRedirects = 3) {
63 let target = url;
64 for (let hop = 0; ; hop++) {
65 const u = new URL(target); // throws on malformed → caller's catch
66 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
67 await assertPublicHost(u.hostname);
68 const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
69 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
70 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
71 return r;
72 }
73}
74const MAX_OUTBOX = 20;
75// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
76// (square) player card. Bump this whenever the twitter:player card dimensions change.
77const FEDI_CARD_VER = '2';
78
79// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
80// Prepared lazily (NOT at module load) — the ap_keys table is created in
81// initializeDatabase(), which runs after this module is imported.
82let _sel, _ins;
83function keyStmts() {
84 if (!_sel) {
85 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
86 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
87 }
88 return { sel: _sel, ins: _ins };
89}
90
91export function getOrCreateKeys(slug) {
92 const { sel, ins } = keyStmts();
93 const row = sel.get(slug);
94 if (row) return row;
95 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
96 modulusLength: 2048,
97 publicKeyEncoding: { type: 'spki', format: 'pem' },
98 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
99 });
100 ins.run(slug, publicKey, privateKey);
101 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
102}
103
104// ── content negotiation ───────────────────────────────────────────
105// True when the caller wants ActivityPub JSON rather than the HTML page.
106export function apWants(req) {
107 const a = String(req.headers.accept || '').toLowerCase();
108 return a.includes('application/activity+json') ||
109 (a.includes('application/ld+json') && a.includes('activitystreams'));
110}
111
112const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
113export function sendAP(res, obj) {
114 res.type(AP_CONTENT_TYPE);
115 res.set('Cache-Control', 'public, max-age=120');
116 res.send(JSON.stringify(obj));
117}
118
119// ── document builders ─────────────────────────────────────────────
120export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
121export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
122
123export function buildActor(base, site) {
124 const id = actorId(base, site.slug);
125 const keys = getOrCreateKeys(site.slug);
126 const actor = {
127 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
128 id,
129 type: 'Person',
130 preferredUsername: site.slug,
131 name: site.title || site.slug,
132 summary: site.tagline || site.description || '',
133 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
134 manuallyApprovesFollowers: false,
135 discoverable: true,
136 inbox: `${id}/inbox`,
137 outbox: `${id}/outbox`,
138 followers: `${id}/followers`,
139 following: `${id}/following`,
140 featured: `${id}/featured`,
141 endpoints: { sharedInbox: `${base}/ap/inbox` },
142 publicKey: {
143 id: `${id}#main-key`,
144 owner: id,
145 publicKeyPem: keys.public_pem,
146 },
147 };
148 if (site.profile_photo) {
149 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
150 actor.icon = { type: 'Image', url: u };
151 }
152 // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
153 if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
154 // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
155 // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
156 try {
157 const links = JSON.parse(site.profile_links || '[]');
158 if (Array.isArray(links) && links.length) {
159 const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
160 const rows = links
161 .filter((l) => l && l.url && /^https?:/i.test(l.url))
162 .map((l) => ({
163 type: 'PropertyValue',
164 name: esc(l.platform || 'Link'),
165 value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
166 }));
167 if (rows.length) actor.attachment = rows;
168 }
169 } catch { /* skip malformed profile_links */ }
170 return actor;
171}
172
173// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
174// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
175// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
176export function hasPlayableAudio(content, siteId) {
177 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
178 try {
179 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; }
180 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; }
181 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; }
182 } catch { /* non-fatal */ }
183 return false;
184}
185
186// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
187export function buildNote(base, site, post) {
188 const id = noteId(base, post.id);
189 const aId = actorId(base, site.slug);
190 const human = `${base}/${encodeURIComponent(post.slug)}`;
191 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
192 // first line) — the standard blog→fediverse convention. post.content is
193 // already sanitized HTML; the title is plain text, so escape it.
194 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
195 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
196
197 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
198 // the cover + any inline <img>, make absolute, then strip <img> from the content
199 // to avoid duplicate rendering on clients that DO keep them.
200 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
201 const mediaType = (u) => {
202 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
203 return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
204 };
205 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
206 const playable = hasPlayableAudio(post.content || '', site && site.id);
207 const urls = [];
208 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
209 // the player CARD (twitter:player) instead of the cover — media attachment and
210 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
211 // keeps its cover (no player card to show).
212 if (post.cover_image_url && !playable) urls.push(abs(post.cover_image_url));
213 let body = post.content || '';
214 if (!playable) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
215 body = body.replace(/<img\b[^>]*>/gi, '');
216 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
217 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
218 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
219 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
220 // a click-through to the protected player (discovery without leaking the file).
221 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
222 const audioLabels = [];
223 try {
224 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); }
225 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
226 } catch { /* non-fatal */ }
227 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
228 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
229 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
230 // of federating the raw shortcode text.
231 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
232 const u = esc(raw.trim().replace(/&amp;/g, '&'));
233 return `<p><a href="${u}">${u}</a></p>`;
234 });
235 if (hadAudio) {
236 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
237 // For playable posts, append a version param to the listen-link so Mastodon
238 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
239 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
240 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
241 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
242 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
243 }
244 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
245 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
246 // so convert newlines to <br> for the federated copy (content already made with
247 // shift+enter uses <br> and has no \n → this is a no-op there).
248 body = body.replace(/\r?\n/g, '<br>');
249 body = linkHashtags(base, body); // link inline #hashtags in the post body too
250 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
251 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
252 // multi-word tags; skip any already present inline in the body.
253 {
254 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
255 const addSeen = new Set();
256 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
257 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
258 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
259 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
260 }
261 const seen = new Set();
262 const attachment = urls.filter(Boolean)
263 .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
264 .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
265
266 const note = {
267 id,
268 type: 'Note',
269 attributedTo: aId,
270 content: titleHtml + body,
271 url: human,
272 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
273 // fan_only = "fans only" → followers-only visibility (delivered to your followers
274 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
275 to: post.fan_only ? [`${aId}/followers`] : [PUBLIC],
276 cc: post.fan_only ? [] : [`${aId}/followers`],
277 tag: buildHashtagList(base, post.tags, body),
278 replies: `${id}/replies`,
279 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
280 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
281 sensitive: !!post.nsfw,
282 };
283 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
284 if (attachment.length) note.attachment = attachment;
285 // Playable-audio posts suppress the cover attachment (player card). Still expose
286 // the cover via AS2 `image` so card/grid consumers (the Klonkt Cirkel) can show
287 // it — Mastodon ignores a Note's `image`, so the player card is unaffected.
288 if (post.cover_image_url && playable) {
289 const cov = abs(post.cover_image_url);
290 if (cov) note.image = { type: 'Image', mediaType: mediaType(cov), url: cov };
291 }
292 return note;
293}
294
295// All reply note URIs on a local post (inbound fediverse replies + our own
296// outbound replies) — backs the Note's `replies` Collection so remote servers
297// can fetch the whole thread.
298export function getReplyUris(base, postId) {
299 const out = [];
300 try {
301 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);
302 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}`);
303 } catch { /* non-fatal */ }
304 return out;
305}
306
307// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
308export function markNotificationsSeen(slug) {
309 try {
310 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")
311 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
312 } catch { /* non-fatal */ }
313}
314export function countUnseenNotifications(slug) {
315 try {
316 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
317 const seen = row ? Date.parse(row.value) : 0;
318 let n = 0;
319 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
320 return n;
321 } catch { return 0; }
322}
323
324export function buildCreate(base, site, post) {
325 const note = buildNote(base, site, post);
326 return {
327 '@context': 'https://www.w3.org/ns/activitystreams',
328 id: note.id + '#create',
329 type: 'Create',
330 actor: actorId(base, site.slug),
331 published: note.published,
332 to: note.to,
333 cc: note.cc,
334 object: note,
335 };
336}
337
338export function buildOutbox(base, site, posts) {
339 const id = `${actorId(base, site.slug)}/outbox`;
340 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
341 return {
342 '@context': 'https://www.w3.org/ns/activitystreams',
343 id,
344 type: 'OrderedCollection',
345 totalItems: items.length,
346 orderedItems: items,
347 };
348}
349
350export function buildFollowers(base, site, count) {
351 const id = `${actorId(base, site.slug)}/followers`;
352 return {
353 '@context': 'https://www.w3.org/ns/activitystreams',
354 id,
355 type: 'OrderedCollection',
356 totalItems: count || 0,
357 orderedItems: [], // hidden for privacy; count only
358 };
359}
360
361// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
362// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
363export function buildFollowing(base, site, count) {
364 const id = `${actorId(base, site.slug)}/following`;
365 return {
366 '@context': 'https://www.w3.org/ns/activitystreams',
367 id,
368 type: 'OrderedCollection',
369 totalItems: count || 0,
370 orderedItems: [], // count only
371 };
372}
373
374// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
375// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
376// rank; embedded as full Notes so a remote server doesn't need extra fetches.
377export function buildFeatured(base, site, posts) {
378 const id = `${actorId(base, site.slug)}/featured`;
379 const items = (posts || []).map((p) => buildNote(base, site, p));
380 return {
381 '@context': 'https://www.w3.org/ns/activitystreams',
382 id,
383 type: 'OrderedCollection',
384 totalItems: items.length,
385 orderedItems: items,
386 };
387}
388
389// ── followers store (lazy stmts) ──────────────────────────────────
390let _insF, _delF, _listF, _cntF;
391function fStmts() {
392 if (!_insF) {
393 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
394 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
395 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
396 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
397 }
398 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
399}
400export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
401
402// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
403let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
404function iStmts() {
405 if (!_insI) {
406 _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, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
407 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
408 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
409 _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 FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
410 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
411 _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
412 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
413 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
414 }
415 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
416}
417
418export function getInteractionById(id) { return iStmts().getI.get(id); }
419export function setInteractionBoosted(id, on) {
420 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
421}
422export function setInteractionLiked(id, on) {
423 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
424}
425// Your like/boost state on a REMOTE post (interact page toggles).
426export function setMyReaction(slug, uri, kind, on) {
427 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
428 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
429}
430export function getMyReactions(slug, uri) {
431 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
432 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
433}
434
435const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
436// Extract our local post id from a note URL, but only if it's ours (base match).
437function postIdFromNoteUrl(url, base) {
438 const s = String(url || '');
439 if (base && !s.startsWith(base)) return null;
440 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
441 return m ? decodeURIComponent(m[1]) : null;
442}
443function deriveHandle(actorUri) {
444 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
445}
446function actorInfo(doc, actorUri) {
447 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
448 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
449 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
450 return {
451 name: (doc && (doc.name || doc.preferredUsername)) || handle,
452 handle,
453 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
454 icon: safeUrl(icon) || null,
455 };
456}
457
458// Given an inReplyTo note URL, find which local post the thread belongs to + the
459// note being replied to (parent), so a reply-to-a-comment can be nested.
460function findThreadTarget(inReplyTo, base) {
461 if (!inReplyTo) return null;
462 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
463 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
464 if (seg) {
465 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 */ }
466 }
467 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 */ }
468 return null;
469}
470
471// Drop the leading @mention(s) a federated reply carries (the person being replied to),
472// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
473// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
474export function stripLeadingMentions(html) {
475 if (!html) return html;
476 let s = String(html);
477 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
478 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
479 return s;
480}
481
482// View-ready threaded view of a post's fediverse activity (inbound replies +
483// our outbound replies, nested), plus like/boost counts.
484export function getInteractions(postId, base, site) {
485 const s = iStmts();
486 const rows = s.list.all(postId);
487 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
488 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
489 // Our own (outbound) replies show the SITE identity for everyone (not "You").
490 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
491 const siteName = (site && (site.title || site.slug)) || '';
492 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
493 const siteUrl = baseClean ? `${baseClean}/` : '';
494 const siteIcon = (site && site.profile_photo) || null;
495
496 const nodes = [];
497 for (const r of rows) {
498 if (r.kind !== 'reply') continue;
499 nodes.push({
500 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
501 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
502 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
503 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
504 children: [],
505 });
506 }
507 for (const o of s.listO.all(postId)) {
508 nodes.push({
509 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
510 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
511 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
512 children: [],
513 });
514 }
515
516 const byId = new Map(nodes.map((n) => [n.noteId, n]));
517 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
518 const tops = [];
519 for (const n of nodes) {
520 if (isTop(n)) { tops.push(n); continue; }
521 let anc = n, guard = 0;
522 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
523 anc.children.push(n);
524 }
525 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
526 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
527
528 return {
529 thread: tops,
530 likeCount: rows.filter((r) => r.kind === 'like').length,
531 announceCount: rows.filter((r) => r.kind === 'announce').length,
532 total: nodes.length,
533 };
534}
535
536// ── HTTP Signatures + delivery ────────────────────────────────────
537const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
538
539// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
540export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
541 const body = JSON.stringify(bodyObj);
542 const u = new URL(inboxUrl);
543 const date = new Date().toUTCString();
544 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
545 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
546 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
547 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
548 const r = await safeFetch(inboxUrl, {
549 method: 'POST',
550 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
551 body,
552 });
553 return r.status;
554}
555
556export async function fetchActor(url) {
557 try {
558 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
559 if (!r.ok) return null;
560 const len = Number(r.headers.get('content-length') || 0);
561 if (len > 2_000_000) return null; // refuse oversized actor docs
562 return await r.json();
563 } catch { return null; }
564}
565
566// ── Delivery queue with retries ───────────────────────────────────
567// Outbound deliveries are tried immediately; on failure (down server, timeout,
568// non-2xx) they're queued and retried with backoff so a briefly-offline follower
569// doesn't silently miss the post. The signing key is NOT stored — the worker
570// re-derives it from the actor slug at send time.
571const DELIVERY_MAX_ATTEMPTS = 6;
572const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
573let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
574function deliveryStmts() {
575 if (!_insDeliv) {
576 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
577 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
578 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
579 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
580 }
581 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
582}
583export function enqueueDelivery(slug, inbox, activity) {
584 if (!slug || !inbox || !activity) return;
585 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
586}
587// Deliver now; queue for retry if it fails.
588export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
589 if (!inbox) return;
590 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
591 enqueueDelivery(slug, inbox, activity);
592}
593let _processingDeliv = false;
594export async function processDeliveryQueue() {
595 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
596 _processingDeliv = true;
597 try {
598 let rows;
599 try { rows = deliveryStmts().due.all(); } catch { return; }
600 if (!rows || !rows.length) return;
601 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
602 for (const row of rows) {
603 let ok = false;
604 try {
605 const keys = getOrCreateKeys(row.slug);
606 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
607 ok = st >= 200 && st < 300;
608 } catch { ok = false; }
609 if (ok) { deliveryStmts().del.run(row.id); continue; }
610 const attempts = row.attempts + 1;
611 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
612 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
613 // retry uses the 1-min tier instead of skipping it.
614 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
615 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
616 }
617 } finally { _processingDeliv = false; }
618}
619let _delivTimer = null;
620export function startDeliveryWorker() {
621 if (_delivTimer) return;
622 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
623 if (_delivTimer.unref) _delivTimer.unref();
624}
625
626// Best-effort verification of an incoming signed request. Returns the sender's
627// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
628export async function verifyRequest(req) {
629 const sigH = req.headers['signature'];
630 if (!sigH) return null;
631 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
632 if (!p.keyId || !p.signature) return null;
633 const actor = await fetchActor(p.keyId.split('#')[0]);
634 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
635 if (!pem) return null;
636 const hs = (p.headers || '(request-target) host date').split(/\s+/);
637 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
638 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
639 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
640 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
641 // against any. An attacker can't forge a match (no private key), so this only rescues the
642 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
643 let _pubHost = null;
644 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
645 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
646 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
647 const _sig = Buffer.from(p.signature, 'base64');
648 let ok = false;
649 for (const _h of _hosts) {
650 const line = hs.map((x) => x === '(request-target)'
651 ? `(request-target): ${_target}`
652 : x === 'host' ? `host: ${_h}`
653 : `${x}: ${req.headers[x] || ''}`).join('\n');
654 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
655 }
656 if (ok && hs.includes('digest') && req.rawBody) {
657 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
658 if (req.headers['digest'] !== exp) ok = false;
659 }
660 return ok ? actor : null;
661}
662
663// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
664export async function handleInbox(req, slugParam) {
665 const act = req.body || {};
666 const type = act.type;
667 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
668 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
669 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
670 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
671 const verified = await verifyRequest(req).catch(() => null);
672
673 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
674 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
675 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
676 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
677 // Blocked actor/domain → silently drop (202, don't reveal the block).
678 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
679 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update'];
680 if (GATED.includes(type)) {
681 if (!verified || !claimedActor || verified.id !== claimedActor) {
682 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
683 return 401;
684 }
685 }
686
687 if (type === 'Follow') {
688 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
689 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
690 if (!who || !slug) return 400;
691 const remote = await fetchActor(who);
692 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
693 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
694 fStmts().ins.run(slug, who, remote.inbox, sharedInbox);
695 const me = actorId(base, slug);
696 const keys = getOrCreateKeys(slug);
697 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
698 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
699 // Auto-backfill: send our recent posts as Create so the instance has our history
700 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
701 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
702 // a follower of ours is wasted work (and won't re-populate the new follower's
703 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
704 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
705 const instanceFilled = sharedInbox &&
706 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
707 .get(slug, sharedInbox, who);
708 if (!instanceFilled) {
709 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
710 }
711 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
712 return 202;
713 }
714 if (type === 'Undo' && act.object) {
715 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
716 const ot = act.object.type;
717 if (ot === 'Follow') {
718 const obj = act.object.object;
719 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
720 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
721 return 202;
722 }
723 if (ot === 'Like' || ot === 'Announce') {
724 const tgt = act.object.object;
725 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
726 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
727 return 202;
728 }
729 return 202;
730 }
731
732 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
733 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
734 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
735 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
736
737 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
738 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
739 const o = act.object;
740 const tgt = findThreadTarget(o.inReplyTo, base);
741 if (tgt && actorUri && !isLocalActor) {
742 const ai = actorInfo(await resolveActor(actorUri), actorUri);
743 const html = HtmlSanitizerService.sanitize(o.content || '');
744 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);
745 console.log('[AP] reply', actorUri, '→', tgt.post_id);
746 return 202;
747 }
748 // Home timeline (client): a top-level post from an account we follow.
749 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
750 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
751 if (subs.length) {
752 const ai = actorInfo(await resolveActor(actorUri), actorUri);
753 const html = HtmlSanitizerService.sanitize(o.content || '');
754 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
755 // Fallback cover: a Note's `image` (set when the attachment was suppressed
756 // for a player-card post, e.g. hosted-audio posts).
757 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
758 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
759 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
760 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
761 }
762 const media = JSON.stringify(_atts);
763 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
764 // incoming posts to the fediverse — that flooded followers. Boosting to the
765 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
766 // the timeline).
767 for (const s of subs) {
768 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);
769 }
770 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
771 }
772 }
773 return 202;
774 }
775 if (type === 'Like' || type === 'Announce') {
776 const tgt = act.object;
777 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
778 const pid = postIdFromNoteUrl(objUrl, base);
779 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
780 const ai = actorInfo(await resolveActor(actorUri), actorUri);
781 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
782 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
783 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
784 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
785 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
786 // re-announcing an incoming Announce would cascade boosts across the network).
787 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
788 if (subs.length) {
789 const bn = await fetchNoteAP(objUrl);
790 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
791 const origUri = actorUriOf(bn.attributedTo);
792 const oai = actorInfo(await resolveActor(origUri), origUri);
793 const html = HtmlSanitizerService.sanitize(bn.content || '');
794 const media = mediaFromNote(bn);
795 const booster = actorInfo(await resolveActor(actorUri), actorUri);
796 for (const s of subs) {
797 // published = now → the boost shows as fresh activity at the top (Mastodon shows
798 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
799 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
800 let inserted = false;
801 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 */ }
802 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 */ } }
803 }
804 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
805 }
806 }
807 }
808 return 202;
809 }
810 if (type === 'Delete') {
811 // A remote note was deleted upstream → drop it from replies AND the timeline.
812 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
813 // signature gate guarantees claimedActor == the verified signer here).
814 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
815 if (oid && claimedActor) {
816 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
817 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
818 }
819 return 202;
820 }
821 // Accept/Reject of a Follow WE sent (client side).
822 if (type === 'Accept' && act.object) {
823 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
824 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
825 console.log('[AP] follow accepted', actorUri);
826 return 202;
827 }
828 if (type === 'Reject' && act.object) {
829 const who = actorUri;
830 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
831 return 202;
832 }
833
834 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
835 return 202;
836}
837
838// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
839// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
840export async function deliverCreate(site, post) {
841 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
842 if (!base || !site || !site.slug) return;
843 const followers = fStmts().list.all(site.slug);
844 if (!followers.length) return;
845 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
846 const keys = getOrCreateKeys(site.slug);
847 const keyId = `${actorId(base, site.slug)}#main-key`;
848 const create = buildCreate(base, site, post);
849 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
850}
851
852// On a new Follow, send that follower our most recent posts as Create so their
853// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
854// so they sort into the follower's timeline at their original dates.
855async function backfillNewFollower(base, slug, inbox) {
856 if (!base || !slug || !inbox) return;
857 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
858 if (!site) return;
859 const recent = db.prepare(
860 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
861 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
862 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
863 ).all(site.id).reverse();
864 if (!recent.length) return;
865 const keys = getOrCreateKeys(slug);
866 const keyId = `${actorId(base, slug)}#main-key`;
867 for (const p of recent) {
868 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
869 await new Promise((r) => setTimeout(r, 150));
870 }
871 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
872}
873
874// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
875export async function deliverDelete(site, post) {
876 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
877 if (!base || !site || !site.slug || !post || !post.id) return;
878 const followers = fStmts().list.all(site.slug);
879 if (!followers.length) return;
880 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
881 const keys = getOrCreateKeys(site.slug);
882 const me = actorId(base, site.slug);
883 const nid = noteId(base, post.id);
884 const del = {
885 '@context': 'https://www.w3.org/ns/activitystreams',
886 id: `${nid}#delete-${Date.now()}-${rid()}`,
887 type: 'Delete',
888 actor: me,
889 to: [PUBLIC],
890 object: { id: nid, type: 'Tombstone' },
891 };
892 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
893}
894
895// Tell followers an already-published post changed (Update + edited Note) so
896// Mastodon refreshes the cached copy (e.g. after fixing content).
897export async function deliverUpdate(site, post) {
898 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
899 if (!base || !site || !site.slug || !post || !post.id) return;
900 const followers = fStmts().list.all(site.slug);
901 if (!followers.length) return;
902 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
903 const keys = getOrCreateKeys(site.slug);
904 const me = actorId(base, site.slug);
905 const note = buildNote(base, site, post);
906 note.updated = new Date().toISOString();
907 const update = {
908 '@context': 'https://www.w3.org/ns/activitystreams',
909 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
910 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
911 object: note,
912 };
913 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
914}
915
916// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
917// account AND re-fetches the featured (pinned) collection — there is no standard
918// "featured changed" activity, so this is how a pin/unpin propagates promptly.
919export async function deliverActorUpdate(site) {
920 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
921 if (!base || !site || !site.slug) return;
922 const followers = fStmts().list.all(site.slug);
923 if (!followers.length) return;
924 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
925 const keys = getOrCreateKeys(site.slug);
926 const me = actorId(base, site.slug);
927 const update = {
928 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
929 id: `${me}#update-${Date.now()}-${rid()}`,
930 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
931 object: buildActor(base, site),
932 };
933 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
934}
935
936// Reliably set the pinned order on followers' instances via Add/Remove activities
937// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
938// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
939// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
940// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
941// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
942export async function resyncFeaturedPins(site, alsoRemove = []) {
943 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
944 if (!base || !site || !site.slug) return;
945 const followers = fStmts().list.all(site.slug);
946 if (!followers.length) return;
947 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
948 const keys = getOrCreateKeys(site.slug);
949 const me = actorId(base, site.slug);
950 const keyId = `${me}#main-key`;
951 const featured = `${me}/featured`;
952 const AS = 'https://www.w3.org/ns/activitystreams';
953 const note = (id) => noteId(base, id);
954 const pinned = db.prepare(
955 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
956 AND pinned IS NOT NULL AND pinned > 0
957 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
958 ).all(site.id);
959 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
960 // 1. Remove every current pin so Mastodon can recreate them in order.
961 for (const id of removeIds) {
962 const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
963 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
964 }
965 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
966 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
967 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
968 for (const p of pinned) {
969 const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
970 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
971 await new Promise((r) => setTimeout(r, 2000));
972 }
973 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
974}
975
976// ── outbound replies (Klonkt → fediverse) ─────────────────────────
977const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
978const 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(); };
979
980// Build one of OUR outbound reply Notes from an ap_outbox row.
981// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
982function linkHashtags(base, html) {
983 return String(html || '').replace(/(^|[\s>])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
984 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
985}
986// Extract the AP Hashtag tag objects from already-linked reply content.
987function hashtagTags(base, content) {
988 const tags = [], seen = new Set();
989 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
990 let m;
991 while ((m = re.exec(content || ''))) {
992 const k = m[1].toLowerCase();
993 if (seen.has(k)) continue; seen.add(k);
994 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
995 }
996 return tags;
997}
998
999// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
1000function normalizeTags(t) {
1001 if (Array.isArray(t)) return t;
1002 if (typeof t === 'string') {
1003 const s = t.trim(); if (!s) return [];
1004 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
1005 return s.split(',').map((x) => x.trim()).filter(Boolean);
1006 }
1007 return [];
1008}
1009// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
1010// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
1011// slug/href stays lowercase ("livemusic").
1012function tagParts(raw) {
1013 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
1014 if (!words.length) return null;
1015 const slug = words.join('').toLowerCase();
1016 if (!slug) return null;
1017 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
1018 return { label, slug };
1019}
1020// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
1021// Hashtag tag list (with hrefs to our /tag page).
1022function buildHashtagList(base, tagsField, content) {
1023 const out = [], seen = new Set();
1024 for (const t of normalizeTags(tagsField)) {
1025 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
1026 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
1027 }
1028 for (const h of hashtagTags(base, content)) {
1029 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
1030 out.push(h);
1031 }
1032 return out;
1033}
1034
1035// Extract Mention tag objects from already-linked content (class="u-url mention").
1036function mentionTags(content) {
1037 const tags = [], seen = new Set();
1038 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
1039 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
1040 let m;
1041 while ((m = re.exec(content || ''))) {
1042 const href = m[1];
1043 if (seen.has(href)) continue; seen.add(href);
1044 tags.push({ type: 'Mention', href, name: '@' + m[2] });
1045 }
1046 return tags;
1047}
1048// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
1049// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
1050async function resolveMentionsInText(base, html) {
1051 const inboxes = [];
1052 const handles = new Set();
1053 const re = /(^|[\s>])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
1054 let m;
1055 while ((m = re.exec(html || ''))) handles.add(m[2]);
1056 let out = String(html || '');
1057 for (const h of handles) {
1058 let actorUri = null;
1059 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
1060 if (!actorUri) continue;
1061 const actor = await fetchActor(actorUri).catch(() => null);
1062 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
1063 if (inbox) inboxes.push(inbox);
1064 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
1065 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1066 out = out.replace(new RegExp('(^|[\\s>])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
1067 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
1068 }
1069 return { html: out, inboxes };
1070}
1071
1072export function buildReplyNote(base, site, row) {
1073 const me = actorId(base, site.slug);
1074 return {
1075 id: noteId(base, row.id),
1076 type: 'Note',
1077 attributedTo: me,
1078 inReplyTo: row.in_reply_to || undefined,
1079 content: row.content,
1080 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
1081 published: toISO(row.created_at),
1082 to: row.to_actor ? [row.to_actor] : [PUBLIC],
1083 cc: [PUBLIC, `${me}/followers`],
1084 tag: [
1085 ...mentionTags(row.content),
1086 ...hashtagTags(base, row.content),
1087 ],
1088 };
1089}
1090
1091// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
1092export function getOutboxNote(base, id) {
1093 const row = iStmts().getO.get(id);
1094 if (!row) return null;
1095 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
1096 if (!site) return null;
1097 return buildReplyNote(base, site, row);
1098}
1099
1100// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
1101// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
1102export async function deliverReply(site, { postId, postSlug, parent, text }) {
1103 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1104 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
1105 const me = actorId(base, site.slug);
1106 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
1107 const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
1108 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
1109 const mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
1110 const mention = parent.actor_uri
1111 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention" data-actor="${escHtml(parent.actor_uri)}">${escHtml(dispHandle)}</a> ` : '';
1112 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1113 // Dedup: skip if the exact same reply was already sent (double-submit guard).
1114 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
1115 .get(site.slug, parent.object_uri || '', content);
1116 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
1117 const id = crypto.randomUUID();
1118 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
1119 const row = iStmts().getO.get(id);
1120 const note = buildReplyNote(base, site, row);
1121 const create = {
1122 '@context': 'https://www.w3.org/ns/activitystreams',
1123 id: note.id + '#create', type: 'Create', actor: me,
1124 published: note.published, to: note.to, cc: note.cc, object: note,
1125 };
1126 const keys = getOrCreateKeys(site.slug);
1127 const keyId = `${me}#main-key`;
1128 const inboxes = new Set();
1129 if (parent.actor_uri) {
1130 const a = await fetchActor(parent.actor_uri).catch(() => null);
1131 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1132 }
1133 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
1134 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
1135 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1136 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
1137 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
1138 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
1139 let delivered = 0;
1140 for (const inbox of [...inboxes].filter(Boolean)) {
1141 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1142 }
1143 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
1144 return { id, content, delivered };
1145}
1146
1147// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
1148// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
1149function actorUriOf(att) {
1150 if (!att) return null;
1151 if (typeof att === 'string') return att;
1152 if (Array.isArray(att)) {
1153 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
1154 if (person) return person.id;
1155 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
1156 return null;
1157 }
1158 return att.id || null;
1159}
1160
1161// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
1162// Returns a parent-shaped object usable by deliverReply(), or null.
1163export async function resolveRemoteNote(url) {
1164 if (!/^https?:\/\//i.test(String(url || ''))) return null;
1165 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
1166 if (!note || !note.id) return null;
1167 const att = note.attributedTo;
1168 const actorUri = actorUriOf(att);
1169 if (!actorUri) return null;
1170 const actor = await fetchActor(actorUri).catch(() => null);
1171 const ai = actorInfo(actor, actorUri);
1172 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
1173 // link our reply to that local post so it shows nested in the post thread.
1174 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
1175 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
1176 // and collect every ancestor author's inbox, so each participant's server —
1177 // including the original post's author — receives + threads our reply.
1178 const threadInboxes = [];
1179 const seenInbox = new Set();
1180 let cursor = note.inReplyTo, guard = 0;
1181 while (cursor && guard++ < 6) {
1182 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
1183 if (!url) break;
1184 const pn = await fetchActor(url).catch(() => null);
1185 if (!pn) break;
1186 const pa = actorUriOf(pn.attributedTo);
1187 if (pa && pa !== actorUri) {
1188 const paDoc = await fetchActor(pa).catch(() => null);
1189 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
1190 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
1191 }
1192 cursor = pn.inReplyTo; // climb to the next ancestor
1193 }
1194 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
1195 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
1196 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1197 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
1198 const images = (Array.isArray(note.attachment) ? note.attachment : [])
1199 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
1200 .map((a) => safeUrl(a.url)).filter(Boolean);
1201 return {
1202 object_uri: safeUrl(note.id) || note.id,
1203 actor_uri: actorUri,
1204 actor_url: ai.url,
1205 actor_handle: ai.handle,
1206 actor_name: ai.name,
1207 actor_icon: ai.icon,
1208 url: note.url || url,
1209 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
1210 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
1211 cw: note.summary || '',
1212 images,
1213 threadInboxes, // every ancestor author's inbox
1214 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
1215 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1216 };
1217}
1218
1219// List a site's own outbound fediverse replies (for the manage/delete view).
1220// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1221// so the manage view can prefill an edit box; the mention is re-added on save.
1222function outboxEditableText(content) {
1223 return String(content || '')
1224 .replace(/<br\s*\/?>/gi, '\n')
1225 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1226 .replace(/<[^>]+>/g, '')
1227 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1228 .trim();
1229}
1230export function listOutbox(siteSlug) {
1231 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1232 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1233}
1234
1235// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1236export async function deliverOutboxDelete(site, outboxId) {
1237 const row = iStmts().getO.get(outboxId);
1238 if (!row || row.site_slug !== site.slug) return false;
1239 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1240 if (base) {
1241 const me = actorId(base, site.slug);
1242 const nid = noteId(base, row.id);
1243 const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
1244 const keys = getOrCreateKeys(site.slug);
1245 const inboxes = new Set();
1246 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1247 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1248 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1249 }
1250 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1251 return true;
1252}
1253
1254// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1255// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1256export async function deliverOutboxUpdate(site, outboxId, newText) {
1257 const row = iStmts().getO.get(outboxId);
1258 if (!row || row.site_slug !== site.slug) return false;
1259 const text = String(newText || '').trim();
1260 if (!text) return false;
1261 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1262 if (!base) return false;
1263 const me = actorId(base, site.slug);
1264 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
1265 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
1266 const _h = row.to_handle || deriveHandle(row.to_actor);
1267 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
1268 const mention = row.to_actor
1269 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
1270 const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
1271 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1272 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1273 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1274 note.updated = new Date().toISOString();
1275 const update = {
1276 '@context': 'https://www.w3.org/ns/activitystreams',
1277 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1278 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1279 };
1280 const keys = getOrCreateKeys(site.slug);
1281 const inboxes = new Set();
1282 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
1283 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1284 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
1285 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1286 let delivered = 0;
1287 for (const inbox of [...inboxes].filter(Boolean)) {
1288 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1289 }
1290 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1291 return { ok: true, content, delivered };
1292}
1293
1294// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1295// Resolve an @user@domain handle to its actor URL via WebFinger.
1296export async function webfingerResolve(handle) {
1297 const h = String(handle || '').trim().replace(/^@/, '');
1298 const parts = h.split('@');
1299 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1300 const acct = `${parts[0]}@${parts[1]}`;
1301 try {
1302 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1303 { headers: { Accept: 'application/jrd+json, application/json' } });
1304 if (!r.ok) return null;
1305 const jrd = await r.json();
1306 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1307 return safeUrl(link ? link.href : '') || null;
1308 } catch { return null; }
1309}
1310
1311let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1312function fwStmts() {
1313 if (!_insFw) {
1314 _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)');
1315 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1316 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1317 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1318 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1319 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1320 }
1321 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1322}
1323export function listFollowing(slug) { return fwStmts().list.all(slug); }
1324
1325// Toggle auto-boost ("feature") on an account we already follow.
1326export function setAutoBoost(slug, actorUri, on) {
1327 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1328 return { ok: true };
1329}
1330
1331let _insTl, _listTl, _delTl;
1332function tlStmts() {
1333 if (!_insTl) {
1334 _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)');
1335 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1336 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1337 }
1338 return { ins: _insTl, list: _listTl, del: _delTl };
1339}
1340export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1341
1342// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1343let _abCount, _cirkelPosts, _cirkelMembers;
1344export function autoBoostCount(slug) {
1345 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; }
1346}
1347export function getCirkelPosts(slug, limit) {
1348 try {
1349 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1350 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1351 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1352 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1353 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1354 FROM ap_timeline t
1355 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1356 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1357 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1358 LIMIT ?`);
1359 return _cirkelPosts.all(slug, limit || 60);
1360 } catch { return []; }
1361}
1362export function getCirkelMembers(slug) {
1363 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 []; }
1364}
1365// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1366let _markBoost, _unmarkBoost, _boostedCount;
1367export function markBoosted(slug, noteId) {
1368 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1369}
1370export function unmarkBoosted(slug, noteId) {
1371 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1372}
1373let _markLike, _unmarkLike;
1374export function markLiked(slug, noteId) {
1375 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1376}
1377export function unmarkLiked(slug, noteId) {
1378 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1379}
1380export function getTimelineReaction(slug, noteId) {
1381 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 }; }
1382}
1383// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1384// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1385// the Cirkel with a Boost badge, then flag it boosted.
1386export function upsertBoostedNote(slug, note) {
1387 if (!slug || !note || !note.object_uri) return;
1388 const id = note.object_uri;
1389 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1390 try {
1391 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1392 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1393 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1394 } catch { /* ignore */ }
1395 markBoosted(slug, id);
1396}
1397export function boostedCount(slug) {
1398 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; }
1399}
1400
1401// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1402// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1403// followActor for bare-domain follows.
1404// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1405// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1406// never throws anything into the fediverse automatically" (would surprise-Follow
1407// for some operators at scale). The dead circle_links table stays as harmless dead
1408// data; an operator restores an old cirkel by re-following in /following (their click).
1409async function resolveApActor(siteUrl) {
1410 try {
1411 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1412 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1413 if (r.ok) return siteUrl;
1414 } catch { /* unreachable */ }
1415 return null;
1416}
1417
1418// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1419// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1420// note and refreshes content + media (recovers covers/edits that were delivered
1421// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1422// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1423const SELFHEAL_VERSION = 2;
1424async function fetchNoteAP(url) {
1425 try {
1426 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1427 if (r.status === 404 || r.status === 410) return 404;
1428 if (r.ok) return await r.json();
1429 } catch { /* unreachable */ }
1430 return null;
1431}
1432function mediaFromNote(note) {
1433 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1434 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1435 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1436 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1437 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1438 }
1439 return JSON.stringify(atts);
1440}
1441let _selfHealing = false;
1442export async function selfHealTimeline() {
1443 if (_selfHealing) return; _selfHealing = true;
1444 try {
1445 let cur = 0;
1446 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; }
1447 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1448 let rows = [];
1449 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
1450 let healed = 0;
1451 for (const r of rows) {
1452 try {
1453 const note = await fetchNoteAP(r.id);
1454 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1455 if (!note || typeof note !== 'object') continue;
1456 const html = HtmlSanitizerService.sanitize(note.content || '');
1457 const media = mediaFromNote(note);
1458 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1459 const cw = note.summary || null;
1460 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '')) {
1461 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, r.id);
1462 healed++;
1463 }
1464 } catch { /* per-note best-effort */ }
1465 }
1466 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1467 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1468 } catch { /* never block boot */ } finally { _selfHealing = false; }
1469}
1470
1471// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1472export async function followActor(site, handle, autoBoost = false) {
1473 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1474 if (!base || !site || !site.slug) return { error: 'config' };
1475 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1476 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1477 // resolves to its AP actor, so you can follow a site by just its domain.
1478 const s = String(handle || '').trim();
1479 let actorUrl;
1480 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1481 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1482 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1483 else actorUrl = null;
1484 if (!actorUrl) return { error: 'not_found' };
1485 const actor = await fetchActor(actorUrl).catch(() => null);
1486 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1487 const ai = actorInfo(actor, actor.id);
1488 const me = actorId(base, site.slug);
1489 const keys = getOrCreateKeys(site.slug);
1490 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1491 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1492 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
1493 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
1494 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
1495 console.log('[AP] follow', site.slug, '→', actor.id);
1496 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1497}
1498
1499// Resolve a profile URL or @handle to a followable remote actor (for the
1500// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1501// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1502export async function resolveRemoteActor(input) {
1503 const s = String(input || '').trim();
1504 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1505 if (!actorUrl) return null;
1506 const actor = await fetchActor(actorUrl).catch(() => null);
1507 if (!actor || !actor.id || !actor.inbox) return null;
1508 const ai = actorInfo(actor, actor.id);
1509 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1510}
1511
1512export async function unfollowActor(site, actorUri) {
1513 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1514 const me = actorId(base, site.slug);
1515 const keys = getOrCreateKeys(site.slug);
1516 const row = fwStmts().one.get(site.slug, actorUri);
1517 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
1518 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
1519 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
1520 // rather than send an unmatchable one. Deliver durably via the retry queue.
1521 if (row && row.inbox && row.follow_id) {
1522 const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id, type: 'Follow', actor: me, object: actorUri } };
1523 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
1524 } else if (row && row.inbox) {
1525 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
1526 }
1527 fwStmts().del.run(site.slug, actorUri);
1528 return { ok: true };
1529}
1530
1531// Send a Like or Announce (boost) on a remote note FROM this site.
1532export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1533 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1534 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1535 const me = actorId(base, site.slug);
1536 const keys = getOrCreateKeys(site.slug);
1537 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1538 // reblog (matched on actor+object — no record of the original Announce needed).
1539 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1540 const followersCol = `${me}/followers`;
1541 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
1542 // attributes the boost to their post and notifies them — without this, a shared-inbox
1543 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
1544 // stamp keep us aligned with what Mastodon emits.
1545 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
1546 let act;
1547 if (kind === 'unboost' || kind === 'unlike') {
1548 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1549 // no record of the original activity needed — Mastodon honours both).
1550 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1551 act = {
1552 '@context': 'https://www.w3.org/ns/activitystreams',
1553 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1554 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1555 };
1556 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
1557 } else {
1558 const type = kind === 'boost' ? 'Announce' : 'Like';
1559 act = {
1560 '@context': 'https://www.w3.org/ns/activitystreams',
1561 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
1562 type, actor: me, object: targetNoteId,
1563 };
1564 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
1565 }
1566 const inboxes = new Set();
1567 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
1568 // routes the Announce/Like to the right post unambiguously.
1569 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
1570 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1571 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
1572 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
1573 // silently lose the boost — same durability a new post (deliverCreate) already gets.
1574 let queued = 0;
1575 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
1576 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
1577 return { ok: true, delivered: queued };
1578}
1579
1580// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1581export function getNotifications(slug, limit) {
1582 const out = [];
1583 try {
1584 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {
1585 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1586 }
1587 } catch { /* ignore */ }
1588 try {
1589 const rows = db.prepare(`
1590 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1591 p.slug AS post_slug, p.title AS post_title
1592 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1593 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1594 ORDER BY i.created_at DESC LIMIT 80
1595 `).all(slug);
1596 for (const r of rows) out.push({
1597 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1598 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1599 });
1600 } catch { /* ignore */ }
1601 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1602 return out.slice(0, limit || 60);
1603}
1604
1605// ── Blocking / defederation ───────────────────────────────────────
1606let _insBl, _delBl, _listBl;
1607function blStmts() {
1608 if (!_insBl) {
1609 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1610 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1611 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1612 }
1613 return { ins: _insBl, del: _delBl, list: _listBl };
1614}
1615export function listBlocks(slug) { return blStmts().list.all(slug); }
1616
1617// True if an actor (or its whole domain) is blocked anywhere on this instance.
1618export function isBlockedAny(actorUri) {
1619 if (!actorUri) return false;
1620 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1621 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1622 catch { return false; }
1623}
1624
1625function purgeBlocked(kind, target) {
1626 try {
1627 if (kind === 'domain') {
1628 const like = `%//${target}/%`;
1629 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1630 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1631 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1632 } else {
1633 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1634 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1635 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1636 }
1637 } catch { /* best-effort */ }
1638}
1639
1640// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1641export async function blockTarget(site, input) {
1642 const raw = String(input || '').trim();
1643 if (!site || !site.slug || !raw) return { error: 'empty' };
1644 let kind, target, label;
1645 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1646 else if (raw.includes('@')) {
1647 const actorUrl = await webfingerResolve(raw);
1648 if (!actorUrl) return { error: 'not_found' };
1649 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1650 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1651 blStmts().ins.run(site.slug, target, kind, label);
1652 purgeBlocked(kind, target);
1653 console.log('[AP] block', site.slug, kind, target);
1654 return { ok: true, label };
1655}
1656
1657export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1658
1659export default {
1660 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1661 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
1662 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1663 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1664 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
1665 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
1666 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
1667 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1668 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1669 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1670};
Note: See TracBrowser for help on using the repository browser.