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

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

feat(fedi): inline @mentions in replies federate (resolve + link + notify)

  • services/ActivityPubService.js — replies now resolve inline @user@domain mentions (resolveMentionsInText via WebFinger), link them (class="u-url mention", href = actor URI) and deliver to the mentioned actors' inboxes so they get notified. buildReplyNote derives Mention tags from the content (mentionTags) — covering the parent mention + inline ones. deliverOutboxUpdate (edit) links inline mentions too.
  • Property mode set to 100644
File size: 82.0 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 const re = /<a href="([^"]+)" class="u-url mention">@([^<]+)<\/a>/gi;
937 let m;
938 while ((m = re.exec(content || ''))) {
939 const href = m[1];
940 if (seen.has(href)) continue; seen.add(href);
941 tags.push({ type: 'Mention', href, name: '@' + m[2] });
942 }
943 return tags;
944}
945// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
946// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
947async function resolveMentionsInText(base, html) {
948 const inboxes = [];
949 const handles = new Set();
950 const re = /(^|[\s>])@([A-Za-z0-9_.-]+@[A-Za-z0-9.-]+)/g;
951 let m;
952 while ((m = re.exec(html || ''))) handles.add(m[2]);
953 let out = String(html || '');
954 for (const h of handles) {
955 let actorUri = null;
956 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
957 if (!actorUri) continue;
958 const actor = await fetchActor(actorUri).catch(() => null);
959 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
960 if (inbox) inboxes.push(inbox);
961 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
962 out = out.replace(new RegExp('(^|[\\s>])@' + esc + '(?![A-Za-z0-9_.-])', 'g'),
963 (full, pre) => `${pre}<a href="${actorUri}" class="u-url mention">@${h}</a>`);
964 }
965 return { html: out, inboxes };
966}
967
968export function buildReplyNote(base, site, row) {
969 const me = actorId(base, site.slug);
970 return {
971 id: noteId(base, row.id),
972 type: 'Note',
973 attributedTo: me,
974 inReplyTo: row.in_reply_to || undefined,
975 content: row.content,
976 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
977 published: toISO(row.created_at),
978 to: row.to_actor ? [row.to_actor] : [PUBLIC],
979 cc: [PUBLIC, `${me}/followers`],
980 tag: [
981 ...mentionTags(row.content),
982 ...hashtagTags(base, row.content),
983 ],
984 };
985}
986
987// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
988export function getOutboxNote(base, id) {
989 const row = iStmts().getO.get(id);
990 if (!row) return null;
991 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
992 if (!site) return null;
993 return buildReplyNote(base, site, row);
994}
995
996// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
997// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
998export async function deliverReply(site, { postId, postSlug, parent, text }) {
999 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1000 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
1001 const me = actorId(base, site.slug);
1002 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
1003 const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
1004 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
1005 const mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
1006 const mention = parent.actor_uri
1007 ? `<a href="${escHtml(parent.actor_uri || parent.actor_url)}" class="u-url mention">${escHtml(dispHandle)}</a> ` : '';
1008 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1009 // Dedup: skip if the exact same reply was already sent (double-submit guard).
1010 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
1011 .get(site.slug, parent.object_uri || '', content);
1012 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
1013 const id = crypto.randomUUID();
1014 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
1015 const row = iStmts().getO.get(id);
1016 const note = buildReplyNote(base, site, row);
1017 const create = {
1018 '@context': 'https://www.w3.org/ns/activitystreams',
1019 id: note.id + '#create', type: 'Create', actor: me,
1020 published: note.published, to: note.to, cc: note.cc, object: note,
1021 };
1022 const keys = getOrCreateKeys(site.slug);
1023 const keyId = `${me}#main-key`;
1024 const inboxes = new Set();
1025 if (parent.actor_uri) {
1026 const a = await fetchActor(parent.actor_uri).catch(() => null);
1027 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1028 }
1029 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
1030 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
1031 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1032 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
1033 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
1034 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
1035 let delivered = 0;
1036 for (const inbox of [...inboxes].filter(Boolean)) {
1037 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1038 }
1039 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
1040 return { id, content, delivered };
1041}
1042
1043// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
1044// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
1045function actorUriOf(att) {
1046 if (!att) return null;
1047 if (typeof att === 'string') return att;
1048 if (Array.isArray(att)) {
1049 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
1050 if (person) return person.id;
1051 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
1052 return null;
1053 }
1054 return att.id || null;
1055}
1056
1057// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
1058// Returns a parent-shaped object usable by deliverReply(), or null.
1059export async function resolveRemoteNote(url) {
1060 if (!/^https?:\/\//i.test(String(url || ''))) return null;
1061 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
1062 if (!note || !note.id) return null;
1063 const att = note.attributedTo;
1064 const actorUri = actorUriOf(att);
1065 if (!actorUri) return null;
1066 const actor = await fetchActor(actorUri).catch(() => null);
1067 const ai = actorInfo(actor, actorUri);
1068 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
1069 // link our reply to that local post so it shows nested in the post thread.
1070 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
1071 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
1072 // and collect every ancestor author's inbox, so each participant's server —
1073 // including the original post's author — receives + threads our reply.
1074 const threadInboxes = [];
1075 const seenInbox = new Set();
1076 let cursor = note.inReplyTo, guard = 0;
1077 while (cursor && guard++ < 6) {
1078 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
1079 if (!url) break;
1080 const pn = await fetchActor(url).catch(() => null);
1081 if (!pn) break;
1082 const pa = actorUriOf(pn.attributedTo);
1083 if (pa && pa !== actorUri) {
1084 const paDoc = await fetchActor(pa).catch(() => null);
1085 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
1086 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
1087 }
1088 cursor = pn.inReplyTo; // climb to the next ancestor
1089 }
1090 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
1091 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
1092 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1093 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
1094 const images = (Array.isArray(note.attachment) ? note.attachment : [])
1095 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
1096 .map((a) => safeUrl(a.url)).filter(Boolean);
1097 return {
1098 object_uri: safeUrl(note.id) || note.id,
1099 actor_uri: actorUri,
1100 actor_url: ai.url,
1101 actor_handle: ai.handle,
1102 actor_name: ai.name,
1103 actor_icon: ai.icon,
1104 url: note.url || url,
1105 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
1106 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
1107 cw: note.summary || '',
1108 images,
1109 threadInboxes, // every ancestor author's inbox
1110 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
1111 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1112 };
1113}
1114
1115// List a site's own outbound fediverse replies (for the manage/delete view).
1116// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1117// so the manage view can prefill an edit box; the mention is re-added on save.
1118function outboxEditableText(content) {
1119 return String(content || '')
1120 .replace(/<br\s*\/?>/gi, '\n')
1121 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1122 .replace(/<[^>]+>/g, '')
1123 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1124 .trim();
1125}
1126export function listOutbox(siteSlug) {
1127 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1128 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1129}
1130
1131// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1132export async function deliverOutboxDelete(site, outboxId) {
1133 const row = iStmts().getO.get(outboxId);
1134 if (!row || row.site_slug !== site.slug) return false;
1135 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1136 if (base) {
1137 const me = actorId(base, site.slug);
1138 const nid = noteId(base, row.id);
1139 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' } };
1140 const keys = getOrCreateKeys(site.slug);
1141 const inboxes = new Set();
1142 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1143 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1144 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1145 }
1146 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1147 return true;
1148}
1149
1150// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1151// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1152export async function deliverOutboxUpdate(site, outboxId, newText) {
1153 const row = iStmts().getO.get(outboxId);
1154 if (!row || row.site_slug !== site.slug) return false;
1155 const text = String(newText || '').trim();
1156 if (!text) return false;
1157 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1158 if (!base) return false;
1159 const me = actorId(base, site.slug);
1160 const mention = row.to_actor
1161 ? `<a href="${escHtml(row.to_actor)}" class="u-url mention">${escHtml(row.to_handle || deriveHandle(row.to_actor))}</a> ` : '';
1162 const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
1163 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1164 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1165 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1166 note.updated = new Date().toISOString();
1167 const update = {
1168 '@context': 'https://www.w3.org/ns/activitystreams',
1169 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1170 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1171 };
1172 const keys = getOrCreateKeys(site.slug);
1173 const inboxes = new Set();
1174 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1175 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1176 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1177 let delivered = 0;
1178 for (const inbox of [...inboxes].filter(Boolean)) {
1179 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1180 }
1181 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1182 return { ok: true, content, delivered };
1183}
1184
1185// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1186// Resolve an @user@domain handle to its actor URL via WebFinger.
1187export async function webfingerResolve(handle) {
1188 const h = String(handle || '').trim().replace(/^@/, '');
1189 const parts = h.split('@');
1190 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1191 const acct = `${parts[0]}@${parts[1]}`;
1192 try {
1193 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1194 { headers: { Accept: 'application/jrd+json, application/json' } });
1195 if (!r.ok) return null;
1196 const jrd = await r.json();
1197 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1198 return safeUrl(link ? link.href : '') || null;
1199 } catch { return null; }
1200}
1201
1202let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1203function fwStmts() {
1204 if (!_insFw) {
1205 _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)');
1206 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1207 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1208 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1209 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1210 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1211 }
1212 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1213}
1214export function listFollowing(slug) { return fwStmts().list.all(slug); }
1215
1216// Toggle auto-boost ("feature") on an account we already follow.
1217export function setAutoBoost(slug, actorUri, on) {
1218 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1219 return { ok: true };
1220}
1221
1222let _insTl, _listTl, _delTl;
1223function tlStmts() {
1224 if (!_insTl) {
1225 _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)');
1226 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1227 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1228 }
1229 return { ins: _insTl, list: _listTl, del: _delTl };
1230}
1231export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1232
1233// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1234let _abCount, _cirkelPosts, _cirkelMembers;
1235export function autoBoostCount(slug) {
1236 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; }
1237}
1238export function getCirkelPosts(slug, limit) {
1239 try {
1240 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1241 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1242 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1243 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1244 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1245 FROM ap_timeline t
1246 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1247 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1248 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1249 LIMIT ?`);
1250 return _cirkelPosts.all(slug, limit || 60);
1251 } catch { return []; }
1252}
1253export function getCirkelMembers(slug) {
1254 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 []; }
1255}
1256// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1257let _markBoost, _unmarkBoost, _boostedCount;
1258export function markBoosted(slug, noteId) {
1259 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1260}
1261export function unmarkBoosted(slug, noteId) {
1262 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1263}
1264let _markLike, _unmarkLike;
1265export function markLiked(slug, noteId) {
1266 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1267}
1268export function unmarkLiked(slug, noteId) {
1269 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1270}
1271export function getTimelineReaction(slug, noteId) {
1272 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 }; }
1273}
1274// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1275// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1276// the Cirkel with a Boost badge, then flag it boosted.
1277export function upsertBoostedNote(slug, note) {
1278 if (!slug || !note || !note.object_uri) return;
1279 const id = note.object_uri;
1280 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1281 try {
1282 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1283 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1284 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1285 } catch { /* ignore */ }
1286 markBoosted(slug, id);
1287}
1288export function boostedCount(slug) {
1289 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; }
1290}
1291
1292// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1293// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1294// followActor for bare-domain follows.
1295// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1296// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1297// never throws anything into the fediverse automatically" (would surprise-Follow
1298// for some operators at scale). The dead circle_links table stays as harmless dead
1299// data; an operator restores an old cirkel by re-following in /following (their click).
1300async function resolveApActor(siteUrl) {
1301 try {
1302 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1303 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1304 if (r.ok) return siteUrl;
1305 } catch { /* unreachable */ }
1306 return null;
1307}
1308
1309// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1310// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1311// note and refreshes content + media (recovers covers/edits that were delivered
1312// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1313// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1314const SELFHEAL_VERSION = 2;
1315async function fetchNoteAP(url) {
1316 try {
1317 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1318 if (r.status === 404 || r.status === 410) return 404;
1319 if (r.ok) return await r.json();
1320 } catch { /* unreachable */ }
1321 return null;
1322}
1323function mediaFromNote(note) {
1324 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1325 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1326 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1327 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1328 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1329 }
1330 return JSON.stringify(atts);
1331}
1332let _selfHealing = false;
1333export async function selfHealTimeline() {
1334 if (_selfHealing) return; _selfHealing = true;
1335 try {
1336 let cur = 0;
1337 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; }
1338 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1339 let rows = [];
1340 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
1341 let healed = 0;
1342 for (const r of rows) {
1343 try {
1344 const note = await fetchNoteAP(r.id);
1345 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1346 if (!note || typeof note !== 'object') continue;
1347 const html = HtmlSanitizerService.sanitize(note.content || '');
1348 const media = mediaFromNote(note);
1349 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1350 const cw = note.summary || null;
1351 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '')) {
1352 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, r.id);
1353 healed++;
1354 }
1355 } catch { /* per-note best-effort */ }
1356 }
1357 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1358 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1359 } catch { /* never block boot */ } finally { _selfHealing = false; }
1360}
1361
1362// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1363export async function followActor(site, handle, autoBoost = false) {
1364 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1365 if (!base || !site || !site.slug) return { error: 'config' };
1366 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1367 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1368 // resolves to its AP actor, so you can follow a site by just its domain.
1369 const s = String(handle || '').trim();
1370 let actorUrl;
1371 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1372 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1373 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1374 else actorUrl = null;
1375 if (!actorUrl) return { error: 'not_found' };
1376 const actor = await fetchActor(actorUrl).catch(() => null);
1377 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1378 const ai = actorInfo(actor, actor.id);
1379 const me = actorId(base, site.slug);
1380 const keys = getOrCreateKeys(site.slug);
1381 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1382 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1383 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
1384 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
1385 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
1386 console.log('[AP] follow', site.slug, '→', actor.id);
1387 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1388}
1389
1390// Resolve a profile URL or @handle to a followable remote actor (for the
1391// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1392// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1393export async function resolveRemoteActor(input) {
1394 const s = String(input || '').trim();
1395 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1396 if (!actorUrl) return null;
1397 const actor = await fetchActor(actorUrl).catch(() => null);
1398 if (!actor || !actor.id || !actor.inbox) return null;
1399 const ai = actorInfo(actor, actor.id);
1400 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1401}
1402
1403export async function unfollowActor(site, actorUri) {
1404 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1405 const me = actorId(base, site.slug);
1406 const keys = getOrCreateKeys(site.slug);
1407 const row = fwStmts().one.get(site.slug, actorUri);
1408 if (row && row.inbox) {
1409 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 } };
1410 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
1411 }
1412 fwStmts().del.run(site.slug, actorUri);
1413 return { ok: true };
1414}
1415
1416// Send a Like or Announce (boost) on a remote note FROM this site.
1417export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1418 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1419 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1420 const me = actorId(base, site.slug);
1421 const keys = getOrCreateKeys(site.slug);
1422 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1423 // reblog (matched on actor+object — no record of the original Announce needed).
1424 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1425 let act;
1426 if (kind === 'unboost' || kind === 'unlike') {
1427 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1428 // no record of the original activity needed — Mastodon honours both).
1429 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1430 act = {
1431 '@context': 'https://www.w3.org/ns/activitystreams',
1432 id: `${me}#undo-${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1433 object: { id: `${me}#${inner.toLowerCase()}-${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1434 };
1435 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1436 } else {
1437 const type = kind === 'boost' ? 'Announce' : 'Like';
1438 act = {
1439 '@context': 'https://www.w3.org/ns/activitystreams',
1440 id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
1441 type, actor: me, object: targetNoteId,
1442 };
1443 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1444 }
1445 const inboxes = new Set();
1446 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1447 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1448 let delivered = 0;
1449 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 */ } }
1450 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'delivered', delivered);
1451 return { ok: true, delivered };
1452}
1453
1454// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1455export function getNotifications(slug, limit) {
1456 const out = [];
1457 try {
1458 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)) {
1459 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1460 }
1461 } catch { /* ignore */ }
1462 try {
1463 const rows = db.prepare(`
1464 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1465 p.slug AS post_slug, p.title AS post_title
1466 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1467 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1468 ORDER BY i.created_at DESC LIMIT 80
1469 `).all(slug);
1470 for (const r of rows) out.push({
1471 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1472 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1473 });
1474 } catch { /* ignore */ }
1475 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1476 return out.slice(0, limit || 60);
1477}
1478
1479// ── Blocking / defederation ───────────────────────────────────────
1480let _insBl, _delBl, _listBl;
1481function blStmts() {
1482 if (!_insBl) {
1483 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1484 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1485 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1486 }
1487 return { ins: _insBl, del: _delBl, list: _listBl };
1488}
1489export function listBlocks(slug) { return blStmts().list.all(slug); }
1490
1491// True if an actor (or its whole domain) is blocked anywhere on this instance.
1492export function isBlockedAny(actorUri) {
1493 if (!actorUri) return false;
1494 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1495 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1496 catch { return false; }
1497}
1498
1499function purgeBlocked(kind, target) {
1500 try {
1501 if (kind === 'domain') {
1502 const like = `%//${target}/%`;
1503 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1504 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1505 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1506 } else {
1507 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1508 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1509 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1510 }
1511 } catch { /* best-effort */ }
1512}
1513
1514// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1515export async function blockTarget(site, input) {
1516 const raw = String(input || '').trim();
1517 if (!site || !site.slug || !raw) return { error: 'empty' };
1518 let kind, target, label;
1519 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1520 else if (raw.includes('@')) {
1521 const actorUrl = await webfingerResolve(raw);
1522 if (!actorUrl) return { error: 'not_found' };
1523 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1524 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1525 blStmts().ins.run(site.slug, target, kind, label);
1526 purgeBlocked(kind, target);
1527 console.log('[AP] block', site.slug, kind, target);
1528 return { ok: true, label };
1529}
1530
1531export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1532
1533export default {
1534 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1535 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1536 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1537 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1538 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
1539 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
1540 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
1541 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1542 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1543 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1544};
Note: See TracBrowser for help on using the repository browser.