source: Klonkt/src/services/ActivityPubService.js@ 5f483e3

main
Last change on this file since 5f483e3 was 204bd74, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(fedi): mention link href = profile URL, actor URI in the Mention tag (data-actor)

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