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

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

fediverse: push actor Update on pin/unpin so Mastodon refreshes the featured collection

Mastodon only re-fetches an actor's featured (pinned) collection on actor refresh, and
there is no 'featured changed' activity. deliverActorUpdate sends Update(Person) to
followers; /save fires it when a post's pin rank changes → pins propagate promptly.

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

  • Property mode set to 100644
File size: 51.9 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import db from '../config/database.js';
20import HtmlSanitizerService from './HtmlSanitizerService.js';
21
22const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
23const MAX_OUTBOX = 20;
24
25// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
26// Prepared lazily (NOT at module load) — the ap_keys table is created in
27// initializeDatabase(), which runs after this module is imported.
28let _sel, _ins;
29function keyStmts() {
30 if (!_sel) {
31 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
32 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
33 }
34 return { sel: _sel, ins: _ins };
35}
36
37export function getOrCreateKeys(slug) {
38 const { sel, ins } = keyStmts();
39 const row = sel.get(slug);
40 if (row) return row;
41 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
42 modulusLength: 2048,
43 publicKeyEncoding: { type: 'spki', format: 'pem' },
44 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
45 });
46 ins.run(slug, publicKey, privateKey);
47 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
48}
49
50// ── content negotiation ───────────────────────────────────────────
51// True when the caller wants ActivityPub JSON rather than the HTML page.
52export function apWants(req) {
53 const a = String(req.headers.accept || '').toLowerCase();
54 return a.includes('application/activity+json') ||
55 (a.includes('application/ld+json') && a.includes('activitystreams'));
56}
57
58const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
59export function sendAP(res, obj) {
60 res.type(AP_CONTENT_TYPE);
61 res.set('Cache-Control', 'public, max-age=120');
62 res.send(JSON.stringify(obj));
63}
64
65// ── document builders ─────────────────────────────────────────────
66export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
67export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
68
69export function buildActor(base, site) {
70 const id = actorId(base, site.slug);
71 const keys = getOrCreateKeys(site.slug);
72 const actor = {
73 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
74 id,
75 type: 'Person',
76 preferredUsername: site.slug,
77 name: site.title || site.slug,
78 summary: site.tagline || site.description || '',
79 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
80 manuallyApprovesFollowers: false,
81 discoverable: true,
82 inbox: `${id}/inbox`,
83 outbox: `${id}/outbox`,
84 followers: `${id}/followers`,
85 featured: `${id}/featured`,
86 endpoints: { sharedInbox: `${base}/ap/inbox` },
87 publicKey: {
88 id: `${id}#main-key`,
89 owner: id,
90 publicKeyPem: keys.public_pem,
91 },
92 };
93 if (site.profile_photo) {
94 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
95 actor.icon = { type: 'Image', url: u };
96 }
97 return actor;
98}
99
100// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
101// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
102// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
103export function hasPlayableAudio(content, siteId) {
104 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
105 try {
106 for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT media_id FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.media_id) return true; }
107 for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL LIMIT 1').get(siteId, m[1].trim())) return true; }
108 for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL LIMIT 1').get(m[1])) return true; }
109 } catch { /* non-fatal */ }
110 return false;
111}
112
113// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
114export function buildNote(base, site, post) {
115 const id = noteId(base, post.id);
116 const aId = actorId(base, site.slug);
117 const human = `${base}/${encodeURIComponent(post.slug)}`;
118 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
119 // first line) — the standard blog→fediverse convention. post.content is
120 // already sanitized HTML; the title is plain text, so escape it.
121 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
122 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
123
124 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
125 // the cover + any inline <img>, make absolute, then strip <img> from the content
126 // to avoid duplicate rendering on clients that DO keep them.
127 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
128 const mediaType = (u) => {
129 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
130 return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
131 };
132 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
133 const playable = hasPlayableAudio(post.content || '', site && site.id);
134 const urls = [];
135 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
136 // the player CARD (twitter:player) instead of the cover — media attachment and
137 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
138 // keeps its cover (no player card to show).
139 if (post.cover_image_url && !playable) urls.push(abs(post.cover_image_url));
140 let body = post.content || '';
141 if (!playable) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
142 body = body.replace(/<img\b[^>]*>/gi, '');
143 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
144 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
145 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
146 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
147 // a click-through to the protected player (discovery without leaking the file).
148 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
149 const audioLabels = [];
150 try {
151 for (const m of body.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT title FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.title) audioLabels.push(r.title); }
152 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
153 } catch { /* non-fatal */ }
154 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
155 if (hadAudio) {
156 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
157 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${human}">listen on ${esc(site.title || 'the site')}</a></p>`;
158 }
159 const seen = new Set();
160 const attachment = urls.filter(Boolean)
161 .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
162 .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
163
164 const note = {
165 id,
166 type: 'Note',
167 attributedTo: aId,
168 content: titleHtml + body,
169 url: human,
170 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
171 to: [PUBLIC],
172 cc: [`${aId}/followers`],
173 tag: Array.isArray(post.tags) ? post.tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/\s+/g, '') })) : [],
174 replies: `${id}/replies`,
175 };
176 if (attachment.length) note.attachment = attachment;
177 return note;
178}
179
180// All reply note URIs on a local post (inbound fediverse replies + our own
181// outbound replies) — backs the Note's `replies` Collection so remote servers
182// can fetch the whole thread.
183export function getReplyUris(base, postId) {
184 const out = [];
185 try {
186 for (const r of db.prepare("SELECT object_uri FROM ap_interactions WHERE kind = 'reply' AND post_id = ? AND object_uri != '' ORDER BY created_at").all(postId)) out.push(r.object_uri);
187 for (const r of db.prepare('SELECT id FROM ap_outbox WHERE post_id = ? ORDER BY rowid').all(postId)) out.push(`${base}/ap/notes/${r.id}`);
188 } catch { /* non-fatal */ }
189 return out;
190}
191
192// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
193export function markNotificationsSeen(slug) {
194 try {
195 db.prepare("INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP")
196 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
197 } catch { /* non-fatal */ }
198}
199export function countUnseenNotifications(slug) {
200 try {
201 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
202 const seen = row ? Date.parse(row.value) : 0;
203 let n = 0;
204 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
205 return n;
206 } catch { return 0; }
207}
208
209export function buildCreate(base, site, post) {
210 const note = buildNote(base, site, post);
211 return {
212 '@context': 'https://www.w3.org/ns/activitystreams',
213 id: note.id + '#create',
214 type: 'Create',
215 actor: actorId(base, site.slug),
216 published: note.published,
217 to: note.to,
218 cc: note.cc,
219 object: note,
220 };
221}
222
223export function buildOutbox(base, site, posts) {
224 const id = `${actorId(base, site.slug)}/outbox`;
225 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
226 return {
227 '@context': 'https://www.w3.org/ns/activitystreams',
228 id,
229 type: 'OrderedCollection',
230 totalItems: items.length,
231 orderedItems: items,
232 };
233}
234
235export function buildFollowers(base, site, count) {
236 const id = `${actorId(base, site.slug)}/followers`;
237 return {
238 '@context': 'https://www.w3.org/ns/activitystreams',
239 id,
240 type: 'OrderedCollection',
241 totalItems: count || 0,
242 orderedItems: [], // hidden for privacy; count only
243 };
244}
245
246// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
247// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
248// rank; embedded as full Notes so a remote server doesn't need extra fetches.
249export function buildFeatured(base, site, posts) {
250 const id = `${actorId(base, site.slug)}/featured`;
251 const items = (posts || []).map((p) => buildNote(base, site, p));
252 return {
253 '@context': 'https://www.w3.org/ns/activitystreams',
254 id,
255 type: 'OrderedCollection',
256 totalItems: items.length,
257 orderedItems: items,
258 };
259}
260
261// ── followers store (lazy stmts) ──────────────────────────────────
262let _insF, _delF, _listF, _cntF;
263function fStmts() {
264 if (!_insF) {
265 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
266 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
267 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
268 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
269 }
270 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
271}
272export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
273
274// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
275let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
276function iStmts() {
277 if (!_insI) {
278 _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)');
279 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
280 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
281 _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');
282 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
283 _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)');
284 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
285 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
286 }
287 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
288}
289
290export function getInteractionById(id) { return iStmts().getI.get(id); }
291
292const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
293// Extract our local post id from a note URL, but only if it's ours (base match).
294function postIdFromNoteUrl(url, base) {
295 const s = String(url || '');
296 if (base && !s.startsWith(base)) return null;
297 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
298 return m ? decodeURIComponent(m[1]) : null;
299}
300function deriveHandle(actorUri) {
301 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
302}
303function actorInfo(doc, actorUri) {
304 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
305 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
306 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
307 return {
308 name: (doc && (doc.name || doc.preferredUsername)) || handle,
309 handle,
310 url: (doc && (doc.url || doc.id)) || actorUri,
311 icon: icon || null,
312 };
313}
314
315// Given an inReplyTo note URL, find which local post the thread belongs to + the
316// note being replied to (parent), so a reply-to-a-comment can be nested.
317function findThreadTarget(inReplyTo, base) {
318 if (!inReplyTo) return null;
319 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
320 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
321 if (seg) {
322 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 */ }
323 }
324 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 */ }
325 return null;
326}
327
328// View-ready threaded view of a post's fediverse activity (inbound replies +
329// our outbound replies, nested), plus like/boost counts.
330export function getInteractions(postId, base, site) {
331 const s = iStmts();
332 const rows = s.list.all(postId);
333 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
334 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
335 // Our own (outbound) replies show the SITE identity for everyone (not "You").
336 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
337 const siteName = (site && (site.title || site.slug)) || '';
338 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
339 const siteUrl = baseClean ? `${baseClean}/` : '';
340 const siteIcon = (site && site.profile_photo) || null;
341
342 const nodes = [];
343 for (const r of rows) {
344 if (r.kind !== 'reply') continue;
345 nodes.push({
346 noteId: r.object_uri, parent: r.parent_uri || null, mine: false,
347 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
348 actor_icon: r.actor_icon, content: r.content, created_at: r.published || r.created_at,
349 children: [],
350 });
351 }
352 for (const o of s.listO.all(postId)) {
353 nodes.push({
354 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
355 mine: true, outboxId: o.id, content: o.content, created_at: o.created_at,
356 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
357 children: [],
358 });
359 }
360
361 const byId = new Map(nodes.map((n) => [n.noteId, n]));
362 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
363 const tops = [];
364 for (const n of nodes) {
365 if (isTop(n)) { tops.push(n); continue; }
366 let anc = n, guard = 0;
367 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
368 anc.children.push(n);
369 }
370 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
371 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
372
373 return {
374 thread: tops,
375 likeCount: rows.filter((r) => r.kind === 'like').length,
376 announceCount: rows.filter((r) => r.kind === 'announce').length,
377 total: nodes.length,
378 };
379}
380
381// ── HTTP Signatures + delivery ────────────────────────────────────
382const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
383
384// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
385export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
386 const body = JSON.stringify(bodyObj);
387 const u = new URL(inboxUrl);
388 const date = new Date().toUTCString();
389 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
390 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
391 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
392 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
393 const r = await fetch(inboxUrl, {
394 method: 'POST',
395 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
396 body,
397 signal: AbortSignal.timeout(8000),
398 });
399 return r.status;
400}
401
402export async function fetchActor(url) {
403 try {
404 const r = await fetch(url, { headers: { Accept: 'application/activity+json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
405 if (!r.ok) return null;
406 return await r.json();
407 } catch { return null; }
408}
409
410// ── Delivery queue with retries ───────────────────────────────────
411// Outbound deliveries are tried immediately; on failure (down server, timeout,
412// non-2xx) they're queued and retried with backoff so a briefly-offline follower
413// doesn't silently miss the post. The signing key is NOT stored — the worker
414// re-derives it from the actor slug at send time.
415const DELIVERY_MAX_ATTEMPTS = 6;
416const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
417let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
418function deliveryStmts() {
419 if (!_insDeliv) {
420 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
421 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
422 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
423 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
424 }
425 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
426}
427export function enqueueDelivery(slug, inbox, activity) {
428 if (!slug || !inbox || !activity) return;
429 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
430}
431// Deliver now; queue for retry if it fails.
432export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
433 if (!inbox) return;
434 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
435 enqueueDelivery(slug, inbox, activity);
436}
437export async function processDeliveryQueue() {
438 let rows;
439 try { rows = deliveryStmts().due.all(); } catch { return; }
440 if (!rows || !rows.length) return;
441 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
442 for (const row of rows) {
443 let ok = false;
444 try {
445 const keys = getOrCreateKeys(row.slug);
446 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
447 ok = st >= 200 && st < 300;
448 } catch { ok = false; }
449 if (ok) { deliveryStmts().del.run(row.id); continue; }
450 const attempts = row.attempts + 1;
451 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
452 const mins = DELIVERY_BACKOFF_MIN[Math.min(attempts, DELIVERY_BACKOFF_MIN.length - 1)];
453 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
454 }
455}
456let _delivTimer = null;
457export function startDeliveryWorker() {
458 if (_delivTimer) return;
459 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
460 if (_delivTimer.unref) _delivTimer.unref();
461}
462
463// Best-effort verification of an incoming signed request. Returns the sender's
464// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
465export async function verifyRequest(req) {
466 const sigH = req.headers['signature'];
467 if (!sigH) return null;
468 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
469 if (!p.keyId || !p.signature) return null;
470 const actor = await fetchActor(p.keyId.split('#')[0]);
471 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
472 if (!pem) return null;
473 const hs = (p.headers || '(request-target) host date').split(/\s+/);
474 const line = hs.map((h) => h === '(request-target)'
475 ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
476 : `${h}: ${req.headers[h] || ''}`).join('\n');
477 let ok = false;
478 try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
479 if (ok && hs.includes('digest') && req.rawBody) {
480 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
481 if (req.headers['digest'] !== exp) ok = false;
482 }
483 return ok ? actor : null;
484}
485
486// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
487export async function handleInbox(req, slugParam) {
488 const act = req.body || {};
489 const type = act.type;
490 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
491 const verified = await verifyRequest(req).catch(() => null);
492
493 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
494 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
495 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
496 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
497 // Blocked actor/domain → silently drop (202, don't reveal the block).
498 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor); return 202; }
499 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject'];
500 if (GATED.includes(type)) {
501 if (!verified || !claimedActor || verified.id !== claimedActor) {
502 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', verified ? '(signer mismatch)' : '(unsigned/invalid)');
503 return 401;
504 }
505 }
506
507 if (type === 'Follow') {
508 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
509 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
510 if (!who || !slug) return 400;
511 const remote = await fetchActor(who);
512 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
513 fStmts().ins.run(slug, who, remote.inbox, (remote.endpoints && remote.endpoints.sharedInbox) || null);
514 const me = actorId(base, slug);
515 const keys = getOrCreateKeys(slug);
516 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}`, type: 'Accept', actor: me, object: act };
517 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
518 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
519 return 202;
520 }
521 if (type === 'Undo' && act.object) {
522 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
523 const ot = act.object.type;
524 if (ot === 'Follow') {
525 const obj = act.object.object;
526 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
527 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
528 return 202;
529 }
530 if (ot === 'Like' || ot === 'Announce') {
531 const tgt = act.object.object;
532 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
533 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
534 return 202;
535 }
536 return 202;
537 }
538
539 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
540 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
541 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
542 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
543
544 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
545 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
546 const o = act.object;
547 const tgt = findThreadTarget(o.inReplyTo, base);
548 if (tgt && actorUri && !isLocalActor) {
549 const ai = actorInfo(await resolveActor(actorUri), actorUri);
550 const html = HtmlSanitizerService.sanitize(o.content || '');
551 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);
552 console.log('[AP] reply', actorUri, '→', tgt.post_id);
553 return 202;
554 }
555 // Home timeline (client): a top-level post from an account we follow.
556 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
557 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
558 if (subs.length) {
559 const ai = actorInfo(await resolveActor(actorUri), actorUri);
560 const html = HtmlSanitizerService.sanitize(o.content || '');
561 const media = JSON.stringify((Array.isArray(o.attachment) ? o.attachment : []).filter((a) => a && a.url).map((a) => ({ url: a.url, type: a.mediaType || '' })));
562 for (const s of subs) tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media);
563 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
564 }
565 }
566 return 202;
567 }
568 if (type === 'Like' || type === 'Announce') {
569 const tgt = act.object;
570 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
571 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
572 const ai = actorInfo(await resolveActor(actorUri), actorUri);
573 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
574 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
575 }
576 return 202;
577 }
578 if (type === 'Delete') {
579 // A remote note was deleted upstream → drop it from replies AND the timeline.
580 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
581 if (oid) { iStmts().delReply.run(oid); try { tlStmts().del.run(oid); } catch { /* ignore */ } }
582 return 202;
583 }
584 // Accept/Reject of a Follow WE sent (client side).
585 if (type === 'Accept' && act.object) {
586 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
587 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
588 console.log('[AP] follow accepted', actorUri);
589 return 202;
590 }
591 if (type === 'Reject' && act.object) {
592 const who = actorUri;
593 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
594 return 202;
595 }
596
597 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
598 return 202;
599}
600
601// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
602// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
603export async function deliverCreate(site, post) {
604 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
605 if (!base || !site || !site.slug) return;
606 const followers = fStmts().list.all(site.slug);
607 if (!followers.length) return;
608 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
609 const keys = getOrCreateKeys(site.slug);
610 const keyId = `${actorId(base, site.slug)}#main-key`;
611 const create = buildCreate(base, site, post);
612 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
613}
614
615// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
616export async function deliverDelete(site, post) {
617 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
618 if (!base || !site || !site.slug || !post || !post.id) return;
619 const followers = fStmts().list.all(site.slug);
620 if (!followers.length) return;
621 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
622 const keys = getOrCreateKeys(site.slug);
623 const me = actorId(base, site.slug);
624 const nid = noteId(base, post.id);
625 const del = {
626 '@context': 'https://www.w3.org/ns/activitystreams',
627 id: `${nid}#delete-${Date.now()}`,
628 type: 'Delete',
629 actor: me,
630 to: [PUBLIC],
631 object: { id: nid, type: 'Tombstone' },
632 };
633 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
634}
635
636// Tell followers an already-published post changed (Update + edited Note) so
637// Mastodon refreshes the cached copy (e.g. after fixing content).
638export async function deliverUpdate(site, post) {
639 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
640 if (!base || !site || !site.slug || !post || !post.id) return;
641 const followers = fStmts().list.all(site.slug);
642 if (!followers.length) return;
643 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
644 const keys = getOrCreateKeys(site.slug);
645 const me = actorId(base, site.slug);
646 const note = buildNote(base, site, post);
647 note.updated = new Date().toISOString();
648 const update = {
649 '@context': 'https://www.w3.org/ns/activitystreams',
650 id: `${noteId(base, post.id)}#update-${Date.now()}`,
651 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
652 object: note,
653 };
654 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
655}
656
657// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
658// account AND re-fetches the featured (pinned) collection — there is no standard
659// "featured changed" activity, so this is how a pin/unpin propagates promptly.
660export async function deliverActorUpdate(site) {
661 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
662 if (!base || !site || !site.slug) return;
663 const followers = fStmts().list.all(site.slug);
664 if (!followers.length) return;
665 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
666 const keys = getOrCreateKeys(site.slug);
667 const me = actorId(base, site.slug);
668 const update = {
669 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
670 id: `${me}#update-${Date.now()}`,
671 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
672 object: buildActor(base, site),
673 };
674 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
675}
676
677// ── outbound replies (Klonkt → fediverse) ─────────────────────────
678const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
679const 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(); };
680
681// Build one of OUR outbound reply Notes from an ap_outbox row.
682export function buildReplyNote(base, site, row) {
683 const me = actorId(base, site.slug);
684 return {
685 id: noteId(base, row.id),
686 type: 'Note',
687 attributedTo: me,
688 inReplyTo: row.in_reply_to || undefined,
689 content: row.content,
690 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
691 published: toISO(row.created_at),
692 to: row.to_actor ? [row.to_actor] : [PUBLIC],
693 cc: [PUBLIC, `${me}/followers`],
694 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
695 };
696}
697
698// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
699export function getOutboxNote(base, id) {
700 const row = iStmts().getO.get(id);
701 if (!row) return null;
702 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
703 if (!site) return null;
704 return buildReplyNote(base, site, row);
705}
706
707// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
708// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
709export async function deliverReply(site, { postId, postSlug, parent, text }) {
710 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
711 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
712 const me = actorId(base, site.slug);
713 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
714 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
715 const mention = parent.actor_uri
716 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
717 const content = `<p>${mention}${body}</p>`;
718 // Dedup: skip if the exact same reply was already sent (double-submit guard).
719 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
720 .get(site.slug, parent.object_uri || '', content);
721 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
722 const id = crypto.randomUUID();
723 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
724 const row = iStmts().getO.get(id);
725 const note = buildReplyNote(base, site, row);
726 const create = {
727 '@context': 'https://www.w3.org/ns/activitystreams',
728 id: note.id + '#create', type: 'Create', actor: me,
729 published: note.published, to: note.to, cc: note.cc, object: note,
730 };
731 const keys = getOrCreateKeys(site.slug);
732 const keyId = `${me}#main-key`;
733 const inboxes = new Set();
734 if (parent.actor_uri) {
735 const a = await fetchActor(parent.actor_uri).catch(() => null);
736 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
737 }
738 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
739 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
740 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
741 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
742 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
743 let delivered = 0;
744 for (const inbox of [...inboxes].filter(Boolean)) {
745 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
746 }
747 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
748 return { id, content, delivered };
749}
750
751// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
752// Returns a parent-shaped object usable by deliverReply(), or null.
753export async function resolveRemoteNote(url) {
754 if (!/^https?:\/\//i.test(String(url || ''))) return null;
755 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
756 if (!note || !note.id) return null;
757 const att = note.attributedTo;
758 const actorUri = typeof att === 'string' ? att : (att && att.id);
759 if (!actorUri) return null;
760 const actor = await fetchActor(actorUri).catch(() => null);
761 const ai = actorInfo(actor, actorUri);
762 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
763 // link our reply to that local post so it shows nested in the post thread.
764 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
765 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
766 // and collect every ancestor author's inbox, so each participant's server —
767 // including the original post's author — receives + threads our reply.
768 const threadInboxes = [];
769 const seenInbox = new Set();
770 let cursor = note.inReplyTo, guard = 0;
771 while (cursor && guard++ < 6) {
772 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
773 if (!url) break;
774 const pn = await fetchActor(url).catch(() => null);
775 if (!pn) break;
776 const pa = typeof pn.attributedTo === 'string' ? pn.attributedTo : (pn.attributedTo && pn.attributedTo.id);
777 if (pa && pa !== actorUri) {
778 const paDoc = await fetchActor(pa).catch(() => null);
779 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
780 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
781 }
782 cursor = pn.inReplyTo; // climb to the next ancestor
783 }
784 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
785 const images = (Array.isArray(note.attachment) ? note.attachment : [])
786 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
787 .map((a) => a.url);
788 return {
789 object_uri: note.id,
790 actor_uri: actorUri,
791 actor_url: ai.url,
792 actor_handle: ai.handle,
793 actor_name: ai.name,
794 actor_icon: ai.icon,
795 url: note.url || url,
796 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
797 images,
798 threadInboxes, // every ancestor author's inbox
799 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
800 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
801 };
802}
803
804// List a site's own outbound fediverse replies (for the manage/delete view).
805export function listOutbox(siteSlug) {
806 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);
807}
808
809// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
810export async function deliverOutboxDelete(site, outboxId) {
811 const row = iStmts().getO.get(outboxId);
812 if (!row || row.site_slug !== site.slug) return false;
813 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
814 if (base) {
815 const me = actorId(base, site.slug);
816 const nid = noteId(base, row.id);
817 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' } };
818 const keys = getOrCreateKeys(site.slug);
819 const inboxes = new Set();
820 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
821 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
822 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
823 }
824 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
825 return true;
826}
827
828// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
829// Resolve an @user@domain handle to its actor URL via WebFinger.
830export async function webfingerResolve(handle) {
831 const h = String(handle || '').trim().replace(/^@/, '');
832 const parts = h.split('@');
833 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
834 const acct = `${parts[0]}@${parts[1]}`;
835 try {
836 const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
837 { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
838 if (!r.ok) return null;
839 const jrd = await r.json();
840 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
841 return link ? link.href : null;
842 } catch { return null; }
843}
844
845let _insFw, _delFw, _listFw, _accFw, _oneFw;
846function fwStmts() {
847 if (!_insFw) {
848 _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, created_at) VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
849 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
850 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
851 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
852 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
853 }
854 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw };
855}
856export function listFollowing(slug) { return fwStmts().list.all(slug); }
857
858let _insTl, _listTl, _delTl;
859function tlStmts() {
860 if (!_insTl) {
861 _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
862 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
863 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
864 }
865 return { ins: _insTl, list: _listTl, del: _delTl };
866}
867export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
868
869// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
870export async function followActor(site, handle) {
871 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
872 if (!base || !site || !site.slug) return { error: 'config' };
873 const actorUrl = await webfingerResolve(handle);
874 if (!actorUrl) return { error: 'not_found' };
875 const actor = await fetchActor(actorUrl).catch(() => null);
876 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
877 const ai = actorInfo(actor, actor.id);
878 const me = actorId(base, site.slug);
879 const keys = getOrCreateKeys(site.slug);
880 const followId = `${me}#follow-${Date.now()}`;
881 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending');
882 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
883 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
884 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
885 console.log('[AP] follow', site.slug, '→', actor.id);
886 return { ok: true, name: ai.name, handle: ai.handle };
887}
888
889export async function unfollowActor(site, actorUri) {
890 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
891 const me = actorId(base, site.slug);
892 const keys = getOrCreateKeys(site.slug);
893 const row = fwStmts().one.get(site.slug, actorUri);
894 if (row && row.inbox) {
895 const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
896 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
897 }
898 fwStmts().del.run(site.slug, actorUri);
899 return { ok: true };
900}
901
902// Send a Like or Announce (boost) on a remote note FROM this site.
903export async function sendInteraction(site, kind, targetNoteId, authorUri) {
904 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
905 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
906 const type = kind === 'boost' ? 'Announce' : 'Like';
907 const me = actorId(base, site.slug);
908 const keys = getOrCreateKeys(site.slug);
909 const act = {
910 '@context': 'https://www.w3.org/ns/activitystreams',
911 id: `${me}#${type.toLowerCase()}-${Date.now()}`,
912 type, actor: me, object: targetNoteId,
913 };
914 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
915 const inboxes = new Set();
916 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
917 // A boost is public → also deliver to our own followers so it shows for them.
918 if (type === 'Announce') { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
919 let delivered = 0;
920 for (const inbox of [...inboxes].filter(Boolean)) { try { const st = await deliver(inbox, act, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ } }
921 console.log('[AP]', type, site.slug, '→', targetNoteId, 'delivered', delivered);
922 return { ok: true, delivered };
923}
924
925// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
926export function getNotifications(slug, limit) {
927 const out = [];
928 try {
929 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {
930 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
931 }
932 } catch { /* ignore */ }
933 try {
934 const rows = db.prepare(`
935 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
936 p.slug AS post_slug, p.title AS post_title
937 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
938 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
939 ORDER BY i.created_at DESC LIMIT 80
940 `).all(slug);
941 for (const r of rows) out.push({
942 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
943 content: r.content, post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
944 });
945 } catch { /* ignore */ }
946 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
947 return out.slice(0, limit || 60);
948}
949
950// ── Blocking / defederation ───────────────────────────────────────
951let _insBl, _delBl, _listBl;
952function blStmts() {
953 if (!_insBl) {
954 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
955 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
956 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
957 }
958 return { ins: _insBl, del: _delBl, list: _listBl };
959}
960export function listBlocks(slug) { return blStmts().list.all(slug); }
961
962// True if an actor (or its whole domain) is blocked anywhere on this instance.
963export function isBlockedAny(actorUri) {
964 if (!actorUri) return false;
965 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
966 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
967 catch { return false; }
968}
969
970function purgeBlocked(kind, target) {
971 try {
972 if (kind === 'domain') {
973 const like = `%//${target}/%`;
974 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
975 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
976 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
977 } else {
978 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
979 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
980 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
981 }
982 } catch { /* best-effort */ }
983}
984
985// Block an actor (@handle or actor URL) or a whole domain; purges their content.
986export async function blockTarget(site, input) {
987 const raw = String(input || '').trim();
988 if (!site || !site.slug || !raw) return { error: 'empty' };
989 let kind, target, label;
990 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
991 else if (raw.includes('@')) {
992 const actorUrl = await webfingerResolve(raw);
993 if (!actorUrl) return { error: 'not_found' };
994 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
995 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
996 blStmts().ins.run(site.slug, target, kind, label);
997 purgeBlocked(kind, target);
998 console.log('[AP] block', site.slug, kind, target);
999 return { ok: true, label };
1000}
1001
1002export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1003
1004export default {
1005 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1006 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1007 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate,
1008 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1009 listOutbox, deliverOutboxDelete,
1010 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
1011 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1012 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1013 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1014};
Note: See TracBrowser for help on using the repository browser.