source: Klonkt/src/services/ActivityPubService.js@ 2923a95

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

feat(fediverse): audio posts skip image attachment so Mastodon shows the player card

A media attachment and a link/player card are mutually exclusive on Mastodon, so
the cover image was winning over the player card. Audio posts now omit image
attachments (the cover still shows as the player card's og:image thumbnail), so
Mastodon renders the inline player. Non-audio posts keep their image attachments.

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

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