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

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

feat(fediverse): federate post edits as Update (Mastodon refreshes cached copy)

Editing an already-published, federated post now sends Update(Note) to followers
(with an 'updated' timestamp), so Mastodon refreshes the cached post instead of
keeping the stale original — e.g. fixes a post that federated before content
handling existed. New-publish still sends Create; fan_only never federates.

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

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