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

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

fix(activitypub): link owner's remote-reply to the local post so it shows

resolveRemoteNote now resolves the local post (findThreadTarget on the note id);
the authorize_interaction flow stores the owner's reply with that post_id, so a
reply to a comment on your own post appears nested in the thread.

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

  • Property mode set to 100644
File size: 29.4 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) {
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
258 const nodes = [];
259 for (const r of rows) {
260 if (r.kind !== 'reply') continue;
261 nodes.push({
262 noteId: r.object_uri, parent: r.parent_uri || null, mine: false,
263 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
264 actor_icon: r.actor_icon, content: r.content, created_at: r.published || r.created_at,
265 children: [],
266 });
267 }
268 for (const o of s.listO.all(postId)) {
269 nodes.push({
270 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
271 mine: true, outboxId: o.id, content: o.content, created_at: o.created_at, children: [],
272 });
273 }
274
275 const byId = new Map(nodes.map((n) => [n.noteId, n]));
276 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
277 const tops = [];
278 for (const n of nodes) {
279 if (isTop(n)) { tops.push(n); continue; }
280 let anc = n, guard = 0;
281 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
282 anc.children.push(n);
283 }
284 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
285 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
286
287 return {
288 thread: tops,
289 likeCount: rows.filter((r) => r.kind === 'like').length,
290 announceCount: rows.filter((r) => r.kind === 'announce').length,
291 total: nodes.length,
292 };
293}
294
295// ── HTTP Signatures + delivery ────────────────────────────────────
296const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
297
298// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
299export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
300 const body = JSON.stringify(bodyObj);
301 const u = new URL(inboxUrl);
302 const date = new Date().toUTCString();
303 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
304 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
305 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
306 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
307 const r = await fetch(inboxUrl, {
308 method: 'POST',
309 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
310 body,
311 signal: AbortSignal.timeout(8000),
312 });
313 return r.status;
314}
315
316export async function fetchActor(url) {
317 try {
318 const r = await fetch(url, { headers: { Accept: 'application/activity+json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
319 if (!r.ok) return null;
320 return await r.json();
321 } catch { return null; }
322}
323
324// Best-effort verification of an incoming signed request. Returns the sender's
325// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
326export async function verifyRequest(req) {
327 const sigH = req.headers['signature'];
328 if (!sigH) return null;
329 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
330 if (!p.keyId || !p.signature) return null;
331 const actor = await fetchActor(p.keyId.split('#')[0]);
332 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
333 if (!pem) return null;
334 const hs = (p.headers || '(request-target) host date').split(/\s+/);
335 const line = hs.map((h) => h === '(request-target)'
336 ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
337 : `${h}: ${req.headers[h] || ''}`).join('\n');
338 let ok = false;
339 try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
340 if (ok && hs.includes('digest') && req.rawBody) {
341 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
342 if (req.headers['digest'] !== exp) ok = false;
343 }
344 return ok ? actor : null;
345}
346
347// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
348export async function handleInbox(req, slugParam) {
349 const act = req.body || {};
350 const type = act.type;
351 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
352 const verified = await verifyRequest(req).catch(() => null); // best-effort; not gating (MVP)
353
354 if (type === 'Follow') {
355 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
356 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
357 if (!who || !slug) return 400;
358 const remote = await fetchActor(who);
359 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
360 fStmts().ins.run(slug, who, remote.inbox, (remote.endpoints && remote.endpoints.sharedInbox) || null);
361 const me = actorId(base, slug);
362 const keys = getOrCreateKeys(slug);
363 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}`, type: 'Accept', actor: me, object: act };
364 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
365 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
366 return 202;
367 }
368 if (type === 'Undo' && act.object) {
369 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
370 const ot = act.object.type;
371 if (ot === 'Follow') {
372 const obj = act.object.object;
373 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
374 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
375 return 202;
376 }
377 if (ot === 'Like' || ot === 'Announce') {
378 const tgt = act.object.object;
379 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
380 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
381 return 202;
382 }
383 return 202;
384 }
385
386 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
387 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
388
389 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
390 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
391 const o = act.object;
392 const tgt = findThreadTarget(o.inReplyTo, base);
393 if (tgt && actorUri) {
394 const ai = actorInfo(await resolveActor(actorUri), actorUri);
395 const html = HtmlSanitizerService.sanitize(o.content || '');
396 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);
397 console.log('[AP] reply', actorUri, '→', tgt.post_id);
398 }
399 return 202;
400 }
401 if (type === 'Like' || type === 'Announce') {
402 const tgt = act.object;
403 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
404 if (pid && actorUri && localPostExists(pid)) {
405 const ai = actorInfo(await resolveActor(actorUri), actorUri);
406 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
407 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
408 }
409 return 202;
410 }
411 if (type === 'Delete') {
412 // A remote reply was deleted upstream → drop it if we stored it.
413 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
414 if (oid) iStmts().delReply.run(oid);
415 return 202;
416 }
417
418 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
419 return 202;
420}
421
422// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
423// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
424export async function deliverCreate(site, post) {
425 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
426 if (!base || !site || !site.slug) return;
427 const followers = fStmts().list.all(site.slug);
428 if (!followers.length) return;
429 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
430 const keys = getOrCreateKeys(site.slug);
431 const keyId = `${actorId(base, site.slug)}#main-key`;
432 const create = buildCreate(base, site, post);
433 for (const inbox of inboxes) deliver(inbox, create, keyId, keys.private_pem).catch(() => { /* best-effort */ });
434}
435
436// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
437export async function deliverDelete(site, post) {
438 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
439 if (!base || !site || !site.slug || !post || !post.id) return;
440 const followers = fStmts().list.all(site.slug);
441 if (!followers.length) return;
442 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
443 const keys = getOrCreateKeys(site.slug);
444 const me = actorId(base, site.slug);
445 const nid = noteId(base, post.id);
446 const del = {
447 '@context': 'https://www.w3.org/ns/activitystreams',
448 id: `${nid}#delete-${Date.now()}`,
449 type: 'Delete',
450 actor: me,
451 to: [PUBLIC],
452 object: { id: nid, type: 'Tombstone' },
453 };
454 for (const inbox of inboxes) deliver(inbox, del, `${me}#main-key`, keys.private_pem).catch(() => { /* best-effort */ });
455}
456
457// ── outbound replies (Klonkt → fediverse) ─────────────────────────
458const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
459const 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(); };
460
461// Build one of OUR outbound reply Notes from an ap_outbox row.
462export function buildReplyNote(base, site, row) {
463 const me = actorId(base, site.slug);
464 return {
465 id: noteId(base, row.id),
466 type: 'Note',
467 attributedTo: me,
468 inReplyTo: row.in_reply_to || undefined,
469 content: row.content,
470 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
471 published: toISO(row.created_at),
472 to: row.to_actor ? [row.to_actor] : [PUBLIC],
473 cc: [PUBLIC, `${me}/followers`],
474 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
475 };
476}
477
478// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
479export function getOutboxNote(base, id) {
480 const row = iStmts().getO.get(id);
481 if (!row) return null;
482 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
483 if (!site) return null;
484 return buildReplyNote(base, site, row);
485}
486
487// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
488// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
489export async function deliverReply(site, { postId, postSlug, parent, text }) {
490 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
491 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
492 const me = actorId(base, site.slug);
493 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
494 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
495 const mention = parent.actor_uri
496 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
497 const content = `<p>${mention}${body}</p>`;
498 // Dedup: skip if the exact same reply was already sent (double-submit guard).
499 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
500 .get(site.slug, parent.object_uri || '', content);
501 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
502 const id = crypto.randomUUID();
503 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
504 const row = iStmts().getO.get(id);
505 const note = buildReplyNote(base, site, row);
506 const create = {
507 '@context': 'https://www.w3.org/ns/activitystreams',
508 id: note.id + '#create', type: 'Create', actor: me,
509 published: note.published, to: note.to, cc: note.cc, object: note,
510 };
511 const keys = getOrCreateKeys(site.slug);
512 const keyId = `${me}#main-key`;
513 const inboxes = new Set();
514 if (parent.actor_uri) {
515 const a = await fetchActor(parent.actor_uri).catch(() => null);
516 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
517 }
518 if (parent.threadInbox) inboxes.add(parent.threadInbox); // post author's server (nesting)
519 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
520 let delivered = 0;
521 for (const inbox of [...inboxes].filter(Boolean)) {
522 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
523 }
524 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
525 return { id, content, delivered };
526}
527
528// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
529// Returns a parent-shaped object usable by deliverReply(), or null.
530export async function resolveRemoteNote(url) {
531 if (!/^https?:\/\//i.test(String(url || ''))) return null;
532 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
533 if (!note || !note.id) return null;
534 const att = note.attributedTo;
535 const actorUri = typeof att === 'string' ? att : (att && att.id);
536 if (!actorUri) return null;
537 const actor = await fetchActor(actorUri).catch(() => null);
538 const ai = actorInfo(actor, actorUri);
539 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
540 // link our reply to that local post so it shows nested in the post thread.
541 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
542 // If this note is itself a reply (a comment), also reach the original post's
543 // author so THEIR server threads our reply under the comment.
544 let threadInbox = null;
545 if (note.inReplyTo) {
546 const parentUrl = typeof note.inReplyTo === 'string' ? note.inReplyTo : (note.inReplyTo && note.inReplyTo.id);
547 const parentNote = parentUrl ? await fetchActor(parentUrl).catch(() => null) : null;
548 const pAtt = parentNote && (typeof parentNote.attributedTo === 'string' ? parentNote.attributedTo : (parentNote.attributedTo && parentNote.attributedTo.id));
549 if (pAtt && pAtt !== actorUri) {
550 const pa = await fetchActor(pAtt).catch(() => null);
551 threadInbox = pa && ((pa.endpoints && pa.endpoints.sharedInbox) || pa.inbox);
552 }
553 }
554 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
555 const images = (Array.isArray(note.attachment) ? note.attachment : [])
556 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
557 .map((a) => a.url);
558 return {
559 object_uri: note.id,
560 actor_uri: actorUri,
561 actor_url: ai.url,
562 actor_handle: ai.handle,
563 actor_name: ai.name,
564 actor_icon: ai.icon,
565 url: note.url || url,
566 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
567 images,
568 threadInbox, // post author's inbox (if a comment)
569 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
570 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
571 };
572}
573
574// List a site's own outbound fediverse replies (for the manage/delete view).
575export function listOutbox(siteSlug) {
576 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);
577}
578
579// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
580export async function deliverOutboxDelete(site, outboxId) {
581 const row = iStmts().getO.get(outboxId);
582 if (!row || row.site_slug !== site.slug) return false;
583 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
584 if (base) {
585 const me = actorId(base, site.slug);
586 const nid = noteId(base, row.id);
587 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' } };
588 const keys = getOrCreateKeys(site.slug);
589 const inboxes = new Set();
590 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
591 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
592 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
593 }
594 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
595 return true;
596}
597
598export default {
599 getOrCreateKeys, apWants, sendAP, actorId, noteId,
600 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
601 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
602 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
603 listOutbox, deliverOutboxDelete,
604};
Note: See TracBrowser for help on using the repository browser.