source: Klonkt/src/services/ActivityPubService.js@ 98b7fdd

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

fix(fediverse): player card only for playable (hosted) audio; scope embed strictly

A link-only track (external, media_id NULL) has no hosted audio, so it must not get
a Klonkt player card — and /embed?post= must not fall back to ALL site tracks (that
showed unrelated songs). New hasPlayableAudio() gates the player card + cover-
suppression on a real file-backed track; link-only audio keeps its cover. /embed?post
now scopes strictly to the post's tracks (no all-site fallback).

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

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