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

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

feat(fediverse): real bell badge for unseen notifications

Track a per-site 'seen' timestamp (app_settings); the top-bar bell shows a badge
with the count of fediverse notifications newer than last-seen, and opening
/meldingen marks them seen (clears the badge). Replaces the dead user_notifications
counter.

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

  • Property mode set to 100644
File size: 47.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// ── outbound replies (Klonkt → fediverse) ─────────────────────────
603const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
604const 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(); };
605
606// Build one of OUR outbound reply Notes from an ap_outbox row.
607export function buildReplyNote(base, site, row) {
608 const me = actorId(base, site.slug);
609 return {
610 id: noteId(base, row.id),
611 type: 'Note',
612 attributedTo: me,
613 inReplyTo: row.in_reply_to || undefined,
614 content: row.content,
615 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
616 published: toISO(row.created_at),
617 to: row.to_actor ? [row.to_actor] : [PUBLIC],
618 cc: [PUBLIC, `${me}/followers`],
619 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
620 };
621}
622
623// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
624export function getOutboxNote(base, id) {
625 const row = iStmts().getO.get(id);
626 if (!row) return null;
627 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
628 if (!site) return null;
629 return buildReplyNote(base, site, row);
630}
631
632// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
633// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
634export async function deliverReply(site, { postId, postSlug, parent, text }) {
635 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
636 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
637 const me = actorId(base, site.slug);
638 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
639 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
640 const mention = parent.actor_uri
641 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
642 const content = `<p>${mention}${body}</p>`;
643 // Dedup: skip if the exact same reply was already sent (double-submit guard).
644 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
645 .get(site.slug, parent.object_uri || '', content);
646 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
647 const id = crypto.randomUUID();
648 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
649 const row = iStmts().getO.get(id);
650 const note = buildReplyNote(base, site, row);
651 const create = {
652 '@context': 'https://www.w3.org/ns/activitystreams',
653 id: note.id + '#create', type: 'Create', actor: me,
654 published: note.published, to: note.to, cc: note.cc, object: note,
655 };
656 const keys = getOrCreateKeys(site.slug);
657 const keyId = `${me}#main-key`;
658 const inboxes = new Set();
659 if (parent.actor_uri) {
660 const a = await fetchActor(parent.actor_uri).catch(() => null);
661 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
662 }
663 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
664 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
665 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
666 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
667 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
668 let delivered = 0;
669 for (const inbox of [...inboxes].filter(Boolean)) {
670 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
671 }
672 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
673 return { id, content, delivered };
674}
675
676// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
677// Returns a parent-shaped object usable by deliverReply(), or null.
678export async function resolveRemoteNote(url) {
679 if (!/^https?:\/\//i.test(String(url || ''))) return null;
680 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
681 if (!note || !note.id) return null;
682 const att = note.attributedTo;
683 const actorUri = typeof att === 'string' ? att : (att && att.id);
684 if (!actorUri) return null;
685 const actor = await fetchActor(actorUri).catch(() => null);
686 const ai = actorInfo(actor, actorUri);
687 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
688 // link our reply to that local post so it shows nested in the post thread.
689 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
690 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
691 // and collect every ancestor author's inbox, so each participant's server —
692 // including the original post's author — receives + threads our reply.
693 const threadInboxes = [];
694 const seenInbox = new Set();
695 let cursor = note.inReplyTo, guard = 0;
696 while (cursor && guard++ < 6) {
697 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
698 if (!url) break;
699 const pn = await fetchActor(url).catch(() => null);
700 if (!pn) break;
701 const pa = typeof pn.attributedTo === 'string' ? pn.attributedTo : (pn.attributedTo && pn.attributedTo.id);
702 if (pa && pa !== actorUri) {
703 const paDoc = await fetchActor(pa).catch(() => null);
704 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
705 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
706 }
707 cursor = pn.inReplyTo; // climb to the next ancestor
708 }
709 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
710 const images = (Array.isArray(note.attachment) ? note.attachment : [])
711 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
712 .map((a) => a.url);
713 return {
714 object_uri: note.id,
715 actor_uri: actorUri,
716 actor_url: ai.url,
717 actor_handle: ai.handle,
718 actor_name: ai.name,
719 actor_icon: ai.icon,
720 url: note.url || url,
721 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
722 images,
723 threadInboxes, // every ancestor author's inbox
724 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
725 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
726 };
727}
728
729// List a site's own outbound fediverse replies (for the manage/delete view).
730export function listOutbox(siteSlug) {
731 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);
732}
733
734// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
735export async function deliverOutboxDelete(site, outboxId) {
736 const row = iStmts().getO.get(outboxId);
737 if (!row || row.site_slug !== site.slug) return false;
738 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
739 if (base) {
740 const me = actorId(base, site.slug);
741 const nid = noteId(base, row.id);
742 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' } };
743 const keys = getOrCreateKeys(site.slug);
744 const inboxes = new Set();
745 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
746 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
747 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
748 }
749 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
750 return true;
751}
752
753// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
754// Resolve an @user@domain handle to its actor URL via WebFinger.
755export async function webfingerResolve(handle) {
756 const h = String(handle || '').trim().replace(/^@/, '');
757 const parts = h.split('@');
758 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
759 const acct = `${parts[0]}@${parts[1]}`;
760 try {
761 const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
762 { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
763 if (!r.ok) return null;
764 const jrd = await r.json();
765 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
766 return link ? link.href : null;
767 } catch { return null; }
768}
769
770let _insFw, _delFw, _listFw, _accFw, _oneFw;
771function fwStmts() {
772 if (!_insFw) {
773 _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)');
774 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
775 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
776 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
777 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
778 }
779 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw };
780}
781export function listFollowing(slug) { return fwStmts().list.all(slug); }
782
783let _insTl, _listTl, _delTl;
784function tlStmts() {
785 if (!_insTl) {
786 _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)');
787 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
788 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
789 }
790 return { ins: _insTl, list: _listTl, del: _delTl };
791}
792export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
793
794// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
795export async function followActor(site, handle) {
796 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
797 if (!base || !site || !site.slug) return { error: 'config' };
798 const actorUrl = await webfingerResolve(handle);
799 if (!actorUrl) return { error: 'not_found' };
800 const actor = await fetchActor(actorUrl).catch(() => null);
801 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
802 const ai = actorInfo(actor, actor.id);
803 const me = actorId(base, site.slug);
804 const keys = getOrCreateKeys(site.slug);
805 const followId = `${me}#follow-${Date.now()}`;
806 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending');
807 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
808 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
809 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
810 console.log('[AP] follow', site.slug, '→', actor.id);
811 return { ok: true, name: ai.name, handle: ai.handle };
812}
813
814export async function unfollowActor(site, actorUri) {
815 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
816 const me = actorId(base, site.slug);
817 const keys = getOrCreateKeys(site.slug);
818 const row = fwStmts().one.get(site.slug, actorUri);
819 if (row && row.inbox) {
820 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 } };
821 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
822 }
823 fwStmts().del.run(site.slug, actorUri);
824 return { ok: true };
825}
826
827// Send a Like or Announce (boost) on a remote note FROM this site.
828export async function sendInteraction(site, kind, targetNoteId, authorUri) {
829 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
830 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
831 const type = kind === 'boost' ? 'Announce' : 'Like';
832 const me = actorId(base, site.slug);
833 const keys = getOrCreateKeys(site.slug);
834 const act = {
835 '@context': 'https://www.w3.org/ns/activitystreams',
836 id: `${me}#${type.toLowerCase()}-${Date.now()}`,
837 type, actor: me, object: targetNoteId,
838 };
839 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
840 const inboxes = new Set();
841 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
842 // A boost is public → also deliver to our own followers so it shows for them.
843 if (type === 'Announce') { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
844 let delivered = 0;
845 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 */ } }
846 console.log('[AP]', type, site.slug, '→', targetNoteId, 'delivered', delivered);
847 return { ok: true, delivered };
848}
849
850// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
851export function getNotifications(slug, limit) {
852 const out = [];
853 try {
854 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)) {
855 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
856 }
857 } catch { /* ignore */ }
858 try {
859 const rows = db.prepare(`
860 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
861 p.slug AS post_slug, p.title AS post_title
862 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
863 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
864 ORDER BY i.created_at DESC LIMIT 80
865 `).all(slug);
866 for (const r of rows) out.push({
867 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
868 content: r.content, post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
869 });
870 } catch { /* ignore */ }
871 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
872 return out.slice(0, limit || 60);
873}
874
875// ── Blocking / defederation ───────────────────────────────────────
876let _insBl, _delBl, _listBl;
877function blStmts() {
878 if (!_insBl) {
879 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
880 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
881 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
882 }
883 return { ins: _insBl, del: _delBl, list: _listBl };
884}
885export function listBlocks(slug) { return blStmts().list.all(slug); }
886
887// True if an actor (or its whole domain) is blocked anywhere on this instance.
888export function isBlockedAny(actorUri) {
889 if (!actorUri) return false;
890 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
891 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
892 catch { return false; }
893}
894
895function purgeBlocked(kind, target) {
896 try {
897 if (kind === 'domain') {
898 const like = `%//${target}/%`;
899 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
900 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
901 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
902 } else {
903 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
904 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
905 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
906 }
907 } catch { /* best-effort */ }
908}
909
910// Block an actor (@handle or actor URL) or a whole domain; purges their content.
911export async function blockTarget(site, input) {
912 const raw = String(input || '').trim();
913 if (!site || !site.slug || !raw) return { error: 'empty' };
914 let kind, target, label;
915 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
916 else if (raw.includes('@')) {
917 const actorUrl = await webfingerResolve(raw);
918 if (!actorUrl) return { error: 'not_found' };
919 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
920 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
921 blStmts().ins.run(site.slug, target, kind, label);
922 purgeBlocked(kind, target);
923 console.log('[AP] block', site.slug, kind, target);
924 return { ok: true, label };
925}
926
927export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
928
929export default {
930 getOrCreateKeys, apWants, sendAP, actorId, noteId,
931 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
932 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
933 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
934 listOutbox, deliverOutboxDelete,
935 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
936 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
937 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
938 getReplyUris, markNotificationsSeen, countUnseenNotifications,
939};
Note: See TracBrowser for help on using the repository browser.