source: Klonkt/src/services/ActivityPubService.js@ 5a6a457

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

feat(fediverse): federate on save+scheduler, delivery retry-queue, music as listen-link

#1 Posts now federate to followers not only on create, but also when a draft/

scheduled post becomes published (editor save) and when the Scheduler flips a
scheduled post live β€” previously those silently didn't reach followers.

#2 Delivery retry-queue (ap_delivery): a failed delivery (down server/timeout) is

queued and retried with backoff (1/5/15/60/180/360 min, 6 tries) by a worker,
instead of fire-and-forget. Signing key re-derived from the slug, never stored.

#3 Music posts: audio shortcodes federate as a '🎡 listen on the site' link to the

post (protected player) instead of the raw mp3 β€” keeps Klonkt's audio friction
intact (no downloadable file handed to Mastodon).

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

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