source: Klonkt/src/services/ActivityPubService.js@ 00f669b

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

feat(fediverse-client): notifications inbox (/meldingen)

Aggregates new followers + replies/likes/boosts on your posts into one feed
(getNotifications). New pages/fedi-notifications view + Beheer link. The old
native notifications.ejs (dead since comments removed) is left untouched.

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

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