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

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

fix(activitypub): no self-duplicate when replying on your own post

deliverReply never delivers to our own inbox, and the inbox ignores activities
from our own actors (already stored via ap_outbox). Fixes a reply appearing
twice (once as You, once as the site actor) on the owner's own post.

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

  • Property mode set to 100644
File size: 29.8 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 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
389 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
390
391 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
392 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
393 const o = act.object;
394 const tgt = findThreadTarget(o.inReplyTo, base);
395 if (tgt && actorUri && !isLocalActor) {
396 const ai = actorInfo(await resolveActor(actorUri), actorUri);
397 const html = HtmlSanitizerService.sanitize(o.content || '');
398 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);
399 console.log('[AP] reply', actorUri, '→', tgt.post_id);
400 }
401 return 202;
402 }
403 if (type === 'Like' || type === 'Announce') {
404 const tgt = act.object;
405 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
406 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
407 const ai = actorInfo(await resolveActor(actorUri), actorUri);
408 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
409 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
410 }
411 return 202;
412 }
413 if (type === 'Delete') {
414 // A remote reply was deleted upstream → drop it if we stored it.
415 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
416 if (oid) iStmts().delReply.run(oid);
417 return 202;
418 }
419
420 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
421 return 202;
422}
423
424// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
425// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
426export async function deliverCreate(site, post) {
427 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
428 if (!base || !site || !site.slug) return;
429 const followers = fStmts().list.all(site.slug);
430 if (!followers.length) return;
431 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
432 const keys = getOrCreateKeys(site.slug);
433 const keyId = `${actorId(base, site.slug)}#main-key`;
434 const create = buildCreate(base, site, post);
435 for (const inbox of inboxes) deliver(inbox, create, keyId, keys.private_pem).catch(() => { /* best-effort */ });
436}
437
438// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
439export async function deliverDelete(site, post) {
440 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
441 if (!base || !site || !site.slug || !post || !post.id) return;
442 const followers = fStmts().list.all(site.slug);
443 if (!followers.length) return;
444 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
445 const keys = getOrCreateKeys(site.slug);
446 const me = actorId(base, site.slug);
447 const nid = noteId(base, post.id);
448 const del = {
449 '@context': 'https://www.w3.org/ns/activitystreams',
450 id: `${nid}#delete-${Date.now()}`,
451 type: 'Delete',
452 actor: me,
453 to: [PUBLIC],
454 object: { id: nid, type: 'Tombstone' },
455 };
456 for (const inbox of inboxes) deliver(inbox, del, `${me}#main-key`, keys.private_pem).catch(() => { /* best-effort */ });
457}
458
459// ── outbound replies (Klonkt → fediverse) ─────────────────────────
460const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
461const 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(); };
462
463// Build one of OUR outbound reply Notes from an ap_outbox row.
464export function buildReplyNote(base, site, row) {
465 const me = actorId(base, site.slug);
466 return {
467 id: noteId(base, row.id),
468 type: 'Note',
469 attributedTo: me,
470 inReplyTo: row.in_reply_to || undefined,
471 content: row.content,
472 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
473 published: toISO(row.created_at),
474 to: row.to_actor ? [row.to_actor] : [PUBLIC],
475 cc: [PUBLIC, `${me}/followers`],
476 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
477 };
478}
479
480// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
481export function getOutboxNote(base, id) {
482 const row = iStmts().getO.get(id);
483 if (!row) return null;
484 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
485 if (!site) return null;
486 return buildReplyNote(base, site, row);
487}
488
489// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
490// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
491export async function deliverReply(site, { postId, postSlug, parent, text }) {
492 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
493 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
494 const me = actorId(base, site.slug);
495 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
496 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
497 const mention = parent.actor_uri
498 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
499 const content = `<p>${mention}${body}</p>`;
500 // Dedup: skip if the exact same reply was already sent (double-submit guard).
501 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
502 .get(site.slug, parent.object_uri || '', content);
503 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
504 const id = crypto.randomUUID();
505 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
506 const row = iStmts().getO.get(id);
507 const note = buildReplyNote(base, site, row);
508 const create = {
509 '@context': 'https://www.w3.org/ns/activitystreams',
510 id: note.id + '#create', type: 'Create', actor: me,
511 published: note.published, to: note.to, cc: note.cc, object: note,
512 };
513 const keys = getOrCreateKeys(site.slug);
514 const keyId = `${me}#main-key`;
515 const inboxes = new Set();
516 if (parent.actor_uri) {
517 const a = await fetchActor(parent.actor_uri).catch(() => null);
518 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
519 }
520 if (parent.threadInbox) inboxes.add(parent.threadInbox); // post author's server (nesting)
521 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
522 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
523 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
524 let delivered = 0;
525 for (const inbox of [...inboxes].filter(Boolean)) {
526 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
527 }
528 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
529 return { id, content, delivered };
530}
531
532// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
533// Returns a parent-shaped object usable by deliverReply(), or null.
534export async function resolveRemoteNote(url) {
535 if (!/^https?:\/\//i.test(String(url || ''))) return null;
536 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
537 if (!note || !note.id) return null;
538 const att = note.attributedTo;
539 const actorUri = typeof att === 'string' ? att : (att && att.id);
540 if (!actorUri) return null;
541 const actor = await fetchActor(actorUri).catch(() => null);
542 const ai = actorInfo(actor, actorUri);
543 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
544 // link our reply to that local post so it shows nested in the post thread.
545 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
546 // If this note is itself a reply (a comment), also reach the original post's
547 // author so THEIR server threads our reply under the comment.
548 let threadInbox = null;
549 if (note.inReplyTo) {
550 const parentUrl = typeof note.inReplyTo === 'string' ? note.inReplyTo : (note.inReplyTo && note.inReplyTo.id);
551 const parentNote = parentUrl ? await fetchActor(parentUrl).catch(() => null) : null;
552 const pAtt = parentNote && (typeof parentNote.attributedTo === 'string' ? parentNote.attributedTo : (parentNote.attributedTo && parentNote.attributedTo.id));
553 if (pAtt && pAtt !== actorUri) {
554 const pa = await fetchActor(pAtt).catch(() => null);
555 threadInbox = pa && ((pa.endpoints && pa.endpoints.sharedInbox) || pa.inbox);
556 }
557 }
558 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
559 const images = (Array.isArray(note.attachment) ? note.attachment : [])
560 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
561 .map((a) => a.url);
562 return {
563 object_uri: note.id,
564 actor_uri: actorUri,
565 actor_url: ai.url,
566 actor_handle: ai.handle,
567 actor_name: ai.name,
568 actor_icon: ai.icon,
569 url: note.url || url,
570 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
571 images,
572 threadInbox, // post author's inbox (if a comment)
573 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
574 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
575 };
576}
577
578// List a site's own outbound fediverse replies (for the manage/delete view).
579export function listOutbox(siteSlug) {
580 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);
581}
582
583// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
584export async function deliverOutboxDelete(site, outboxId) {
585 const row = iStmts().getO.get(outboxId);
586 if (!row || row.site_slug !== site.slug) return false;
587 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
588 if (base) {
589 const me = actorId(base, site.slug);
590 const nid = noteId(base, row.id);
591 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' } };
592 const keys = getOrCreateKeys(site.slug);
593 const inboxes = new Set();
594 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
595 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
596 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
597 }
598 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
599 return true;
600}
601
602export default {
603 getOrCreateKeys, apWants, sendAP, actorId, noteId,
604 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
605 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
606 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
607 listOutbox, deliverOutboxDelete,
608};
Note: See TracBrowser for help on using the repository browser.