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

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

feat(fediverse): auto-backfill recent posts to new followers on Follow

Mastodon doesn't fetch an account's history on follow, so new followers saw an empty
timeline. On Follow->Accept we now deliver our most recent 20 published, non-fan posts
as Create (oldest-first) to the new follower's inbox. Fire-and-forget; no more manual
backfills.

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

  • Property mode set to 100644
File size: 53.8 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 db from '../config/database.js';
20import HtmlSanitizerService from './HtmlSanitizerService.js';
21
22const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
23const MAX_OUTBOX = 20;
24// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
25// (square) player card. Bump this whenever the twitter:player card dimensions change.
26const FEDI_CARD_VER = '2';
27
28// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
29// Prepared lazily (NOT at module load) — the ap_keys table is created in
30// initializeDatabase(), which runs after this module is imported.
31let _sel, _ins;
32function keyStmts() {
33 if (!_sel) {
34 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
35 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
36 }
37 return { sel: _sel, ins: _ins };
38}
39
40export function getOrCreateKeys(slug) {
41 const { sel, ins } = keyStmts();
42 const row = sel.get(slug);
43 if (row) return row;
44 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
45 modulusLength: 2048,
46 publicKeyEncoding: { type: 'spki', format: 'pem' },
47 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
48 });
49 ins.run(slug, publicKey, privateKey);
50 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
51}
52
53// ── content negotiation ───────────────────────────────────────────
54// True when the caller wants ActivityPub JSON rather than the HTML page.
55export function apWants(req) {
56 const a = String(req.headers.accept || '').toLowerCase();
57 return a.includes('application/activity+json') ||
58 (a.includes('application/ld+json') && a.includes('activitystreams'));
59}
60
61const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
62export function sendAP(res, obj) {
63 res.type(AP_CONTENT_TYPE);
64 res.set('Cache-Control', 'public, max-age=120');
65 res.send(JSON.stringify(obj));
66}
67
68// ── document builders ─────────────────────────────────────────────
69export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
70export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
71
72export function buildActor(base, site) {
73 const id = actorId(base, site.slug);
74 const keys = getOrCreateKeys(site.slug);
75 const actor = {
76 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
77 id,
78 type: 'Person',
79 preferredUsername: site.slug,
80 name: site.title || site.slug,
81 summary: site.tagline || site.description || '',
82 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
83 manuallyApprovesFollowers: false,
84 discoverable: true,
85 inbox: `${id}/inbox`,
86 outbox: `${id}/outbox`,
87 followers: `${id}/followers`,
88 featured: `${id}/featured`,
89 endpoints: { sharedInbox: `${base}/ap/inbox` },
90 publicKey: {
91 id: `${id}#main-key`,
92 owner: id,
93 publicKeyPem: keys.public_pem,
94 },
95 };
96 if (site.profile_photo) {
97 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
98 actor.icon = { type: 'Image', url: u };
99 }
100 return actor;
101}
102
103// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
104// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
105// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
106export function hasPlayableAudio(content, siteId) {
107 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
108 try {
109 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; }
110 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; }
111 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; }
112 } catch { /* non-fatal */ }
113 return false;
114}
115
116// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
117export function buildNote(base, site, post) {
118 const id = noteId(base, post.id);
119 const aId = actorId(base, site.slug);
120 const human = `${base}/${encodeURIComponent(post.slug)}`;
121 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
122 // first line) — the standard blog→fediverse convention. post.content is
123 // already sanitized HTML; the title is plain text, so escape it.
124 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
125 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
126
127 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
128 // the cover + any inline <img>, make absolute, then strip <img> from the content
129 // to avoid duplicate rendering on clients that DO keep them.
130 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
131 const mediaType = (u) => {
132 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
133 return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
134 };
135 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
136 const playable = hasPlayableAudio(post.content || '', site && site.id);
137 const urls = [];
138 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
139 // the player CARD (twitter:player) instead of the cover — media attachment and
140 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
141 // keeps its cover (no player card to show).
142 if (post.cover_image_url && !playable) urls.push(abs(post.cover_image_url));
143 let body = post.content || '';
144 if (!playable) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
145 body = body.replace(/<img\b[^>]*>/gi, '');
146 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
147 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
148 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
149 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
150 // a click-through to the protected player (discovery without leaking the file).
151 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
152 const audioLabels = [];
153 try {
154 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); }
155 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
156 } catch { /* non-fatal */ }
157 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
158 if (hadAudio) {
159 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
160 // For playable posts, append a version param to the listen-link so Mastodon
161 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
162 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
163 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
164 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
165 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
166 }
167 const seen = new Set();
168 const attachment = urls.filter(Boolean)
169 .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
170 .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
171
172 const note = {
173 id,
174 type: 'Note',
175 attributedTo: aId,
176 content: titleHtml + body,
177 url: human,
178 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
179 to: [PUBLIC],
180 cc: [`${aId}/followers`],
181 tag: Array.isArray(post.tags) ? post.tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/\s+/g, '') })) : [],
182 replies: `${id}/replies`,
183 };
184 if (attachment.length) note.attachment = attachment;
185 return note;
186}
187
188// All reply note URIs on a local post (inbound fediverse replies + our own
189// outbound replies) — backs the Note's `replies` Collection so remote servers
190// can fetch the whole thread.
191export function getReplyUris(base, postId) {
192 const out = [];
193 try {
194 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);
195 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}`);
196 } catch { /* non-fatal */ }
197 return out;
198}
199
200// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
201export function markNotificationsSeen(slug) {
202 try {
203 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")
204 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
205 } catch { /* non-fatal */ }
206}
207export function countUnseenNotifications(slug) {
208 try {
209 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
210 const seen = row ? Date.parse(row.value) : 0;
211 let n = 0;
212 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
213 return n;
214 } catch { return 0; }
215}
216
217export function buildCreate(base, site, post) {
218 const note = buildNote(base, site, post);
219 return {
220 '@context': 'https://www.w3.org/ns/activitystreams',
221 id: note.id + '#create',
222 type: 'Create',
223 actor: actorId(base, site.slug),
224 published: note.published,
225 to: note.to,
226 cc: note.cc,
227 object: note,
228 };
229}
230
231export function buildOutbox(base, site, posts) {
232 const id = `${actorId(base, site.slug)}/outbox`;
233 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
234 return {
235 '@context': 'https://www.w3.org/ns/activitystreams',
236 id,
237 type: 'OrderedCollection',
238 totalItems: items.length,
239 orderedItems: items,
240 };
241}
242
243export function buildFollowers(base, site, count) {
244 const id = `${actorId(base, site.slug)}/followers`;
245 return {
246 '@context': 'https://www.w3.org/ns/activitystreams',
247 id,
248 type: 'OrderedCollection',
249 totalItems: count || 0,
250 orderedItems: [], // hidden for privacy; count only
251 };
252}
253
254// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
255// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
256// rank; embedded as full Notes so a remote server doesn't need extra fetches.
257export function buildFeatured(base, site, posts) {
258 const id = `${actorId(base, site.slug)}/featured`;
259 const items = (posts || []).map((p) => buildNote(base, site, p));
260 return {
261 '@context': 'https://www.w3.org/ns/activitystreams',
262 id,
263 type: 'OrderedCollection',
264 totalItems: items.length,
265 orderedItems: items,
266 };
267}
268
269// ── followers store (lazy stmts) ──────────────────────────────────
270let _insF, _delF, _listF, _cntF;
271function fStmts() {
272 if (!_insF) {
273 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
274 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
275 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
276 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
277 }
278 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
279}
280export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
281
282// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
283let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
284function iStmts() {
285 if (!_insI) {
286 _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)');
287 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
288 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
289 _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
290 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
291 _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)');
292 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
293 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
294 }
295 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
296}
297
298export function getInteractionById(id) { return iStmts().getI.get(id); }
299
300const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
301// Extract our local post id from a note URL, but only if it's ours (base match).
302function postIdFromNoteUrl(url, base) {
303 const s = String(url || '');
304 if (base && !s.startsWith(base)) return null;
305 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
306 return m ? decodeURIComponent(m[1]) : null;
307}
308function deriveHandle(actorUri) {
309 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
310}
311function actorInfo(doc, actorUri) {
312 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
313 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
314 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
315 return {
316 name: (doc && (doc.name || doc.preferredUsername)) || handle,
317 handle,
318 url: (doc && (doc.url || doc.id)) || actorUri,
319 icon: icon || null,
320 };
321}
322
323// Given an inReplyTo note URL, find which local post the thread belongs to + the
324// note being replied to (parent), so a reply-to-a-comment can be nested.
325function findThreadTarget(inReplyTo, base) {
326 if (!inReplyTo) return null;
327 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
328 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
329 if (seg) {
330 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 */ }
331 }
332 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 */ }
333 return null;
334}
335
336// View-ready threaded view of a post's fediverse activity (inbound replies +
337// our outbound replies, nested), plus like/boost counts.
338export function getInteractions(postId, base, site) {
339 const s = iStmts();
340 const rows = s.list.all(postId);
341 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
342 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
343 // Our own (outbound) replies show the SITE identity for everyone (not "You").
344 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
345 const siteName = (site && (site.title || site.slug)) || '';
346 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
347 const siteUrl = baseClean ? `${baseClean}/` : '';
348 const siteIcon = (site && site.profile_photo) || null;
349
350 const nodes = [];
351 for (const r of rows) {
352 if (r.kind !== 'reply') continue;
353 nodes.push({
354 noteId: r.object_uri, parent: r.parent_uri || null, mine: false,
355 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
356 actor_icon: r.actor_icon, content: r.content, created_at: r.published || r.created_at,
357 children: [],
358 });
359 }
360 for (const o of s.listO.all(postId)) {
361 nodes.push({
362 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
363 mine: true, outboxId: o.id, content: o.content, created_at: o.created_at,
364 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
365 children: [],
366 });
367 }
368
369 const byId = new Map(nodes.map((n) => [n.noteId, n]));
370 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
371 const tops = [];
372 for (const n of nodes) {
373 if (isTop(n)) { tops.push(n); continue; }
374 let anc = n, guard = 0;
375 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
376 anc.children.push(n);
377 }
378 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
379 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
380
381 return {
382 thread: tops,
383 likeCount: rows.filter((r) => r.kind === 'like').length,
384 announceCount: rows.filter((r) => r.kind === 'announce').length,
385 total: nodes.length,
386 };
387}
388
389// ── HTTP Signatures + delivery ────────────────────────────────────
390const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
391
392// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
393export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
394 const body = JSON.stringify(bodyObj);
395 const u = new URL(inboxUrl);
396 const date = new Date().toUTCString();
397 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
398 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
399 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
400 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
401 const r = await fetch(inboxUrl, {
402 method: 'POST',
403 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
404 body,
405 signal: AbortSignal.timeout(8000),
406 });
407 return r.status;
408}
409
410export async function fetchActor(url) {
411 try {
412 const r = await fetch(url, { headers: { Accept: 'application/activity+json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
413 if (!r.ok) return null;
414 return await r.json();
415 } catch { return null; }
416}
417
418// ── Delivery queue with retries ───────────────────────────────────
419// Outbound deliveries are tried immediately; on failure (down server, timeout,
420// non-2xx) they're queued and retried with backoff so a briefly-offline follower
421// doesn't silently miss the post. The signing key is NOT stored — the worker
422// re-derives it from the actor slug at send time.
423const DELIVERY_MAX_ATTEMPTS = 6;
424const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
425let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
426function deliveryStmts() {
427 if (!_insDeliv) {
428 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
429 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
430 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
431 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
432 }
433 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
434}
435export function enqueueDelivery(slug, inbox, activity) {
436 if (!slug || !inbox || !activity) return;
437 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
438}
439// Deliver now; queue for retry if it fails.
440export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
441 if (!inbox) return;
442 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
443 enqueueDelivery(slug, inbox, activity);
444}
445export async function processDeliveryQueue() {
446 let rows;
447 try { rows = deliveryStmts().due.all(); } catch { return; }
448 if (!rows || !rows.length) return;
449 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
450 for (const row of rows) {
451 let ok = false;
452 try {
453 const keys = getOrCreateKeys(row.slug);
454 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
455 ok = st >= 200 && st < 300;
456 } catch { ok = false; }
457 if (ok) { deliveryStmts().del.run(row.id); continue; }
458 const attempts = row.attempts + 1;
459 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
460 const mins = DELIVERY_BACKOFF_MIN[Math.min(attempts, DELIVERY_BACKOFF_MIN.length - 1)];
461 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
462 }
463}
464let _delivTimer = null;
465export function startDeliveryWorker() {
466 if (_delivTimer) return;
467 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
468 if (_delivTimer.unref) _delivTimer.unref();
469}
470
471// Best-effort verification of an incoming signed request. Returns the sender's
472// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
473export async function verifyRequest(req) {
474 const sigH = req.headers['signature'];
475 if (!sigH) return null;
476 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
477 if (!p.keyId || !p.signature) return null;
478 const actor = await fetchActor(p.keyId.split('#')[0]);
479 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
480 if (!pem) return null;
481 const hs = (p.headers || '(request-target) host date').split(/\s+/);
482 const line = hs.map((h) => h === '(request-target)'
483 ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
484 : `${h}: ${req.headers[h] || ''}`).join('\n');
485 let ok = false;
486 try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
487 if (ok && hs.includes('digest') && req.rawBody) {
488 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
489 if (req.headers['digest'] !== exp) ok = false;
490 }
491 return ok ? actor : null;
492}
493
494// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
495export async function handleInbox(req, slugParam) {
496 const act = req.body || {};
497 const type = act.type;
498 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
499 const verified = await verifyRequest(req).catch(() => null);
500
501 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
502 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
503 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
504 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
505 // Blocked actor/domain → silently drop (202, don't reveal the block).
506 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor); return 202; }
507 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject'];
508 if (GATED.includes(type)) {
509 if (!verified || !claimedActor || verified.id !== claimedActor) {
510 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', verified ? '(signer mismatch)' : '(unsigned/invalid)');
511 return 401;
512 }
513 }
514
515 if (type === 'Follow') {
516 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
517 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
518 if (!who || !slug) return 400;
519 const remote = await fetchActor(who);
520 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
521 fStmts().ins.run(slug, who, remote.inbox, (remote.endpoints && remote.endpoints.sharedInbox) || null);
522 const me = actorId(base, slug);
523 const keys = getOrCreateKeys(slug);
524 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}`, type: 'Accept', actor: me, object: act };
525 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
526 // Auto-backfill: send the new follower our recent posts as Create so their
527 // timeline isn't empty (Mastodon doesn't fetch history on follow). Fire-and-forget.
528 backfillNewFollower(base, slug, remote.inbox).catch(() => { /* best-effort */ });
529 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
530 return 202;
531 }
532 if (type === 'Undo' && act.object) {
533 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
534 const ot = act.object.type;
535 if (ot === 'Follow') {
536 const obj = act.object.object;
537 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
538 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
539 return 202;
540 }
541 if (ot === 'Like' || ot === 'Announce') {
542 const tgt = act.object.object;
543 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
544 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
545 return 202;
546 }
547 return 202;
548 }
549
550 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
551 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
552 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
553 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
554
555 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
556 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
557 const o = act.object;
558 const tgt = findThreadTarget(o.inReplyTo, base);
559 if (tgt && actorUri && !isLocalActor) {
560 const ai = actorInfo(await resolveActor(actorUri), actorUri);
561 const html = HtmlSanitizerService.sanitize(o.content || '');
562 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);
563 console.log('[AP] reply', actorUri, '→', tgt.post_id);
564 return 202;
565 }
566 // Home timeline (client): a top-level post from an account we follow.
567 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
568 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
569 if (subs.length) {
570 const ai = actorInfo(await resolveActor(actorUri), actorUri);
571 const html = HtmlSanitizerService.sanitize(o.content || '');
572 const media = JSON.stringify((Array.isArray(o.attachment) ? o.attachment : []).filter((a) => a && a.url).map((a) => ({ url: a.url, type: a.mediaType || '' })));
573 for (const s of subs) tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media);
574 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
575 }
576 }
577 return 202;
578 }
579 if (type === 'Like' || type === 'Announce') {
580 const tgt = act.object;
581 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
582 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
583 const ai = actorInfo(await resolveActor(actorUri), actorUri);
584 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
585 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
586 }
587 return 202;
588 }
589 if (type === 'Delete') {
590 // A remote note was deleted upstream → drop it from replies AND the timeline.
591 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
592 if (oid) { iStmts().delReply.run(oid); try { tlStmts().del.run(oid); } catch { /* ignore */ } }
593 return 202;
594 }
595 // Accept/Reject of a Follow WE sent (client side).
596 if (type === 'Accept' && act.object) {
597 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
598 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
599 console.log('[AP] follow accepted', actorUri);
600 return 202;
601 }
602 if (type === 'Reject' && act.object) {
603 const who = actorUri;
604 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
605 return 202;
606 }
607
608 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
609 return 202;
610}
611
612// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
613// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
614export async function deliverCreate(site, post) {
615 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
616 if (!base || !site || !site.slug) return;
617 const followers = fStmts().list.all(site.slug);
618 if (!followers.length) return;
619 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
620 const keys = getOrCreateKeys(site.slug);
621 const keyId = `${actorId(base, site.slug)}#main-key`;
622 const create = buildCreate(base, site, post);
623 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
624}
625
626// On a new Follow, send that follower our most recent posts as Create so their
627// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
628// so they sort into the follower's timeline at their original dates.
629async function backfillNewFollower(base, slug, inbox) {
630 if (!base || !slug || !inbox) return;
631 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
632 if (!site) return;
633 const recent = db.prepare(
634 `SELECT id, slug, title, content, cover_image_url, published_at, created_at
635 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
636 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
637 ).all(site.id).reverse();
638 if (!recent.length) return;
639 const keys = getOrCreateKeys(slug);
640 const keyId = `${actorId(base, slug)}#main-key`;
641 for (const p of recent) {
642 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
643 await new Promise((r) => setTimeout(r, 150));
644 }
645 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
646}
647
648// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
649export async function deliverDelete(site, post) {
650 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
651 if (!base || !site || !site.slug || !post || !post.id) return;
652 const followers = fStmts().list.all(site.slug);
653 if (!followers.length) return;
654 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
655 const keys = getOrCreateKeys(site.slug);
656 const me = actorId(base, site.slug);
657 const nid = noteId(base, post.id);
658 const del = {
659 '@context': 'https://www.w3.org/ns/activitystreams',
660 id: `${nid}#delete-${Date.now()}`,
661 type: 'Delete',
662 actor: me,
663 to: [PUBLIC],
664 object: { id: nid, type: 'Tombstone' },
665 };
666 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
667}
668
669// Tell followers an already-published post changed (Update + edited Note) so
670// Mastodon refreshes the cached copy (e.g. after fixing content).
671export async function deliverUpdate(site, post) {
672 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
673 if (!base || !site || !site.slug || !post || !post.id) return;
674 const followers = fStmts().list.all(site.slug);
675 if (!followers.length) return;
676 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
677 const keys = getOrCreateKeys(site.slug);
678 const me = actorId(base, site.slug);
679 const note = buildNote(base, site, post);
680 note.updated = new Date().toISOString();
681 const update = {
682 '@context': 'https://www.w3.org/ns/activitystreams',
683 id: `${noteId(base, post.id)}#update-${Date.now()}`,
684 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
685 object: note,
686 };
687 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
688}
689
690// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
691// account AND re-fetches the featured (pinned) collection — there is no standard
692// "featured changed" activity, so this is how a pin/unpin propagates promptly.
693export async function deliverActorUpdate(site) {
694 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
695 if (!base || !site || !site.slug) return;
696 const followers = fStmts().list.all(site.slug);
697 if (!followers.length) return;
698 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
699 const keys = getOrCreateKeys(site.slug);
700 const me = actorId(base, site.slug);
701 const update = {
702 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
703 id: `${me}#update-${Date.now()}`,
704 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
705 object: buildActor(base, site),
706 };
707 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
708}
709
710// ── outbound replies (Klonkt → fediverse) ─────────────────────────
711const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
712const 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(); };
713
714// Build one of OUR outbound reply Notes from an ap_outbox row.
715export function buildReplyNote(base, site, row) {
716 const me = actorId(base, site.slug);
717 return {
718 id: noteId(base, row.id),
719 type: 'Note',
720 attributedTo: me,
721 inReplyTo: row.in_reply_to || undefined,
722 content: row.content,
723 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
724 published: toISO(row.created_at),
725 to: row.to_actor ? [row.to_actor] : [PUBLIC],
726 cc: [PUBLIC, `${me}/followers`],
727 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
728 };
729}
730
731// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
732export function getOutboxNote(base, id) {
733 const row = iStmts().getO.get(id);
734 if (!row) return null;
735 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
736 if (!site) return null;
737 return buildReplyNote(base, site, row);
738}
739
740// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
741// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
742export async function deliverReply(site, { postId, postSlug, parent, text }) {
743 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
744 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
745 const me = actorId(base, site.slug);
746 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
747 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
748 const mention = parent.actor_uri
749 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
750 const content = `<p>${mention}${body}</p>`;
751 // Dedup: skip if the exact same reply was already sent (double-submit guard).
752 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
753 .get(site.slug, parent.object_uri || '', content);
754 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
755 const id = crypto.randomUUID();
756 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
757 const row = iStmts().getO.get(id);
758 const note = buildReplyNote(base, site, row);
759 const create = {
760 '@context': 'https://www.w3.org/ns/activitystreams',
761 id: note.id + '#create', type: 'Create', actor: me,
762 published: note.published, to: note.to, cc: note.cc, object: note,
763 };
764 const keys = getOrCreateKeys(site.slug);
765 const keyId = `${me}#main-key`;
766 const inboxes = new Set();
767 if (parent.actor_uri) {
768 const a = await fetchActor(parent.actor_uri).catch(() => null);
769 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
770 }
771 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
772 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
773 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
774 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
775 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
776 let delivered = 0;
777 for (const inbox of [...inboxes].filter(Boolean)) {
778 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
779 }
780 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
781 return { id, content, delivered };
782}
783
784// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
785// Returns a parent-shaped object usable by deliverReply(), or null.
786export async function resolveRemoteNote(url) {
787 if (!/^https?:\/\//i.test(String(url || ''))) return null;
788 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
789 if (!note || !note.id) return null;
790 const att = note.attributedTo;
791 const actorUri = typeof att === 'string' ? att : (att && att.id);
792 if (!actorUri) return null;
793 const actor = await fetchActor(actorUri).catch(() => null);
794 const ai = actorInfo(actor, actorUri);
795 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
796 // link our reply to that local post so it shows nested in the post thread.
797 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
798 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
799 // and collect every ancestor author's inbox, so each participant's server —
800 // including the original post's author — receives + threads our reply.
801 const threadInboxes = [];
802 const seenInbox = new Set();
803 let cursor = note.inReplyTo, guard = 0;
804 while (cursor && guard++ < 6) {
805 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
806 if (!url) break;
807 const pn = await fetchActor(url).catch(() => null);
808 if (!pn) break;
809 const pa = typeof pn.attributedTo === 'string' ? pn.attributedTo : (pn.attributedTo && pn.attributedTo.id);
810 if (pa && pa !== actorUri) {
811 const paDoc = await fetchActor(pa).catch(() => null);
812 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
813 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
814 }
815 cursor = pn.inReplyTo; // climb to the next ancestor
816 }
817 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
818 const images = (Array.isArray(note.attachment) ? note.attachment : [])
819 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
820 .map((a) => a.url);
821 return {
822 object_uri: note.id,
823 actor_uri: actorUri,
824 actor_url: ai.url,
825 actor_handle: ai.handle,
826 actor_name: ai.name,
827 actor_icon: ai.icon,
828 url: note.url || url,
829 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
830 images,
831 threadInboxes, // every ancestor author's inbox
832 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
833 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
834 };
835}
836
837// List a site's own outbound fediverse replies (for the manage/delete view).
838export function listOutbox(siteSlug) {
839 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC').all(siteSlug);
840}
841
842// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
843export async function deliverOutboxDelete(site, outboxId) {
844 const row = iStmts().getO.get(outboxId);
845 if (!row || row.site_slug !== site.slug) return false;
846 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
847 if (base) {
848 const me = actorId(base, site.slug);
849 const nid = noteId(base, row.id);
850 const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
851 const keys = getOrCreateKeys(site.slug);
852 const inboxes = new Set();
853 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
854 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
855 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
856 }
857 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
858 return true;
859}
860
861// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
862// Resolve an @user@domain handle to its actor URL via WebFinger.
863export async function webfingerResolve(handle) {
864 const h = String(handle || '').trim().replace(/^@/, '');
865 const parts = h.split('@');
866 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
867 const acct = `${parts[0]}@${parts[1]}`;
868 try {
869 const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
870 { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
871 if (!r.ok) return null;
872 const jrd = await r.json();
873 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
874 return link ? link.href : null;
875 } catch { return null; }
876}
877
878let _insFw, _delFw, _listFw, _accFw, _oneFw;
879function fwStmts() {
880 if (!_insFw) {
881 _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, created_at) VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
882 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
883 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
884 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
885 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
886 }
887 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw };
888}
889export function listFollowing(slug) { return fwStmts().list.all(slug); }
890
891let _insTl, _listTl, _delTl;
892function tlStmts() {
893 if (!_insTl) {
894 _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, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
895 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
896 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
897 }
898 return { ins: _insTl, list: _listTl, del: _delTl };
899}
900export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
901
902// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
903export async function followActor(site, handle) {
904 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
905 if (!base || !site || !site.slug) return { error: 'config' };
906 const actorUrl = await webfingerResolve(handle);
907 if (!actorUrl) return { error: 'not_found' };
908 const actor = await fetchActor(actorUrl).catch(() => null);
909 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
910 const ai = actorInfo(actor, actor.id);
911 const me = actorId(base, site.slug);
912 const keys = getOrCreateKeys(site.slug);
913 const followId = `${me}#follow-${Date.now()}`;
914 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending');
915 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
916 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
917 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
918 console.log('[AP] follow', site.slug, '→', actor.id);
919 return { ok: true, name: ai.name, handle: ai.handle };
920}
921
922export async function unfollowActor(site, actorUri) {
923 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
924 const me = actorId(base, site.slug);
925 const keys = getOrCreateKeys(site.slug);
926 const row = fwStmts().one.get(site.slug, actorUri);
927 if (row && row.inbox) {
928 const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
929 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
930 }
931 fwStmts().del.run(site.slug, actorUri);
932 return { ok: true };
933}
934
935// Send a Like or Announce (boost) on a remote note FROM this site.
936export async function sendInteraction(site, kind, targetNoteId, authorUri) {
937 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
938 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
939 const type = kind === 'boost' ? 'Announce' : 'Like';
940 const me = actorId(base, site.slug);
941 const keys = getOrCreateKeys(site.slug);
942 const act = {
943 '@context': 'https://www.w3.org/ns/activitystreams',
944 id: `${me}#${type.toLowerCase()}-${Date.now()}`,
945 type, actor: me, object: targetNoteId,
946 };
947 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
948 const inboxes = new Set();
949 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
950 // A boost is public → also deliver to our own followers so it shows for them.
951 if (type === 'Announce') { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
952 let delivered = 0;
953 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 */ } }
954 console.log('[AP]', type, site.slug, '→', targetNoteId, 'delivered', delivered);
955 return { ok: true, delivered };
956}
957
958// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
959export function getNotifications(slug, limit) {
960 const out = [];
961 try {
962 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)) {
963 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
964 }
965 } catch { /* ignore */ }
966 try {
967 const rows = db.prepare(`
968 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
969 p.slug AS post_slug, p.title AS post_title
970 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
971 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
972 ORDER BY i.created_at DESC LIMIT 80
973 `).all(slug);
974 for (const r of rows) out.push({
975 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
976 content: r.content, post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
977 });
978 } catch { /* ignore */ }
979 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
980 return out.slice(0, limit || 60);
981}
982
983// ── Blocking / defederation ───────────────────────────────────────
984let _insBl, _delBl, _listBl;
985function blStmts() {
986 if (!_insBl) {
987 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
988 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
989 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
990 }
991 return { ins: _insBl, del: _delBl, list: _listBl };
992}
993export function listBlocks(slug) { return blStmts().list.all(slug); }
994
995// True if an actor (or its whole domain) is blocked anywhere on this instance.
996export function isBlockedAny(actorUri) {
997 if (!actorUri) return false;
998 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
999 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1000 catch { return false; }
1001}
1002
1003function purgeBlocked(kind, target) {
1004 try {
1005 if (kind === 'domain') {
1006 const like = `%//${target}/%`;
1007 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1008 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1009 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1010 } else {
1011 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1012 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1013 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1014 }
1015 } catch { /* best-effort */ }
1016}
1017
1018// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1019export async function blockTarget(site, input) {
1020 const raw = String(input || '').trim();
1021 if (!site || !site.slug || !raw) return { error: 'empty' };
1022 let kind, target, label;
1023 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1024 else if (raw.includes('@')) {
1025 const actorUrl = await webfingerResolve(raw);
1026 if (!actorUrl) return { error: 'not_found' };
1027 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1028 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1029 blStmts().ins.run(site.slug, target, kind, label);
1030 purgeBlocked(kind, target);
1031 console.log('[AP] block', site.slug, kind, target);
1032 return { ok: true, label };
1033}
1034
1035export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1036
1037export default {
1038 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1039 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1040 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate,
1041 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1042 listOutbox, deliverOutboxDelete,
1043 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
1044 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1045 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1046 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1047};
Note: See TracBrowser for help on using the repository browser.