source: Klonkt/src/services/ActivityPubService.js@ 2e9773f

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

fix(activitypub): own replies show the site identity to everyone (not 'You')

Outbound reply nodes carry the site name/handle/avatar so visitors see e.g.
'Joost Groot @demo@...' instead of 'You'; the owner still gets the delete action.

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

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