source: Klonkt/src/services/ActivityPubService.js@ 6bc2ca7e

main
Last change on this file since 6bc2ca7e was 6bc2ca7e, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(fedi): hashtags in replies federate as Hashtag tags + clickable links

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