source: Klonkt/src/services/ActivityPubService.js@ 75bda38

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

feat(fediverse): expose pinned posts via the actor's featured collection

Adds actor.featured + GET /ap/users/:slug/featured (OrderedCollection of pinned,
published, non-fan_only posts as Notes, ordered by pin rank). Mastodon reads this
and shows them under the profile's Featured tab.

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

  • Property mode set to 100644
File size: 50.8 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import db from '../config/database.js';
20import HtmlSanitizerService from './HtmlSanitizerService.js';
21
22const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
23const MAX_OUTBOX = 20;
24
25// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
26// Prepared lazily (NOT at module load) — the ap_keys table is created in
27// initializeDatabase(), which runs after this module is imported.
28let _sel, _ins;
29function keyStmts() {
30 if (!_sel) {
31 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
32 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
33 }
34 return { sel: _sel, ins: _ins };
35}
36
37export function getOrCreateKeys(slug) {
38 const { sel, ins } = keyStmts();
39 const row = sel.get(slug);
40 if (row) return row;
41 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
42 modulusLength: 2048,
43 publicKeyEncoding: { type: 'spki', format: 'pem' },
44 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
45 });
46 ins.run(slug, publicKey, privateKey);
47 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
48}
49
50// ── content negotiation ───────────────────────────────────────────
51// True when the caller wants ActivityPub JSON rather than the HTML page.
52export function apWants(req) {
53 const a = String(req.headers.accept || '').toLowerCase();
54 return a.includes('application/activity+json') ||
55 (a.includes('application/ld+json') && a.includes('activitystreams'));
56}
57
58const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
59export function sendAP(res, obj) {
60 res.type(AP_CONTENT_TYPE);
61 res.set('Cache-Control', 'public, max-age=120');
62 res.send(JSON.stringify(obj));
63}
64
65// ── document builders ─────────────────────────────────────────────
66export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
67export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
68
69export function buildActor(base, site) {
70 const id = actorId(base, site.slug);
71 const keys = getOrCreateKeys(site.slug);
72 const actor = {
73 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
74 id,
75 type: 'Person',
76 preferredUsername: site.slug,
77 name: site.title || site.slug,
78 summary: site.tagline || site.description || '',
79 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
80 manuallyApprovesFollowers: false,
81 discoverable: true,
82 inbox: `${id}/inbox`,
83 outbox: `${id}/outbox`,
84 followers: `${id}/followers`,
85 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// ── outbound replies (Klonkt → fediverse) ─────────────────────────
658const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
659const 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(); };
660
661// Build one of OUR outbound reply Notes from an ap_outbox row.
662export function buildReplyNote(base, site, row) {
663 const me = actorId(base, site.slug);
664 return {
665 id: noteId(base, row.id),
666 type: 'Note',
667 attributedTo: me,
668 inReplyTo: row.in_reply_to || undefined,
669 content: row.content,
670 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
671 published: toISO(row.created_at),
672 to: row.to_actor ? [row.to_actor] : [PUBLIC],
673 cc: [PUBLIC, `${me}/followers`],
674 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
675 };
676}
677
678// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
679export function getOutboxNote(base, id) {
680 const row = iStmts().getO.get(id);
681 if (!row) return null;
682 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
683 if (!site) return null;
684 return buildReplyNote(base, site, row);
685}
686
687// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
688// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
689export async function deliverReply(site, { postId, postSlug, parent, text }) {
690 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
691 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
692 const me = actorId(base, site.slug);
693 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
694 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
695 const mention = parent.actor_uri
696 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
697 const content = `<p>${mention}${body}</p>`;
698 // Dedup: skip if the exact same reply was already sent (double-submit guard).
699 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
700 .get(site.slug, parent.object_uri || '', content);
701 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
702 const id = crypto.randomUUID();
703 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
704 const row = iStmts().getO.get(id);
705 const note = buildReplyNote(base, site, row);
706 const create = {
707 '@context': 'https://www.w3.org/ns/activitystreams',
708 id: note.id + '#create', type: 'Create', actor: me,
709 published: note.published, to: note.to, cc: note.cc, object: note,
710 };
711 const keys = getOrCreateKeys(site.slug);
712 const keyId = `${me}#main-key`;
713 const inboxes = new Set();
714 if (parent.actor_uri) {
715 const a = await fetchActor(parent.actor_uri).catch(() => null);
716 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
717 }
718 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
719 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
720 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
721 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
722 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
723 let delivered = 0;
724 for (const inbox of [...inboxes].filter(Boolean)) {
725 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
726 }
727 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
728 return { id, content, delivered };
729}
730
731// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
732// Returns a parent-shaped object usable by deliverReply(), or null.
733export async function resolveRemoteNote(url) {
734 if (!/^https?:\/\//i.test(String(url || ''))) return null;
735 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
736 if (!note || !note.id) return null;
737 const att = note.attributedTo;
738 const actorUri = typeof att === 'string' ? att : (att && att.id);
739 if (!actorUri) return null;
740 const actor = await fetchActor(actorUri).catch(() => null);
741 const ai = actorInfo(actor, actorUri);
742 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
743 // link our reply to that local post so it shows nested in the post thread.
744 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
745 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
746 // and collect every ancestor author's inbox, so each participant's server —
747 // including the original post's author — receives + threads our reply.
748 const threadInboxes = [];
749 const seenInbox = new Set();
750 let cursor = note.inReplyTo, guard = 0;
751 while (cursor && guard++ < 6) {
752 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
753 if (!url) break;
754 const pn = await fetchActor(url).catch(() => null);
755 if (!pn) break;
756 const pa = typeof pn.attributedTo === 'string' ? pn.attributedTo : (pn.attributedTo && pn.attributedTo.id);
757 if (pa && pa !== actorUri) {
758 const paDoc = await fetchActor(pa).catch(() => null);
759 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
760 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
761 }
762 cursor = pn.inReplyTo; // climb to the next ancestor
763 }
764 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
765 const images = (Array.isArray(note.attachment) ? note.attachment : [])
766 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
767 .map((a) => a.url);
768 return {
769 object_uri: note.id,
770 actor_uri: actorUri,
771 actor_url: ai.url,
772 actor_handle: ai.handle,
773 actor_name: ai.name,
774 actor_icon: ai.icon,
775 url: note.url || url,
776 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
777 images,
778 threadInboxes, // every ancestor author's inbox
779 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
780 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
781 };
782}
783
784// List a site's own outbound fediverse replies (for the manage/delete view).
785export function listOutbox(siteSlug) {
786 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);
787}
788
789// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
790export async function deliverOutboxDelete(site, outboxId) {
791 const row = iStmts().getO.get(outboxId);
792 if (!row || row.site_slug !== site.slug) return false;
793 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
794 if (base) {
795 const me = actorId(base, site.slug);
796 const nid = noteId(base, row.id);
797 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' } };
798 const keys = getOrCreateKeys(site.slug);
799 const inboxes = new Set();
800 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
801 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
802 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
803 }
804 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
805 return true;
806}
807
808// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
809// Resolve an @user@domain handle to its actor URL via WebFinger.
810export async function webfingerResolve(handle) {
811 const h = String(handle || '').trim().replace(/^@/, '');
812 const parts = h.split('@');
813 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
814 const acct = `${parts[0]}@${parts[1]}`;
815 try {
816 const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
817 { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
818 if (!r.ok) return null;
819 const jrd = await r.json();
820 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
821 return link ? link.href : null;
822 } catch { return null; }
823}
824
825let _insFw, _delFw, _listFw, _accFw, _oneFw;
826function fwStmts() {
827 if (!_insFw) {
828 _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)');
829 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
830 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
831 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
832 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
833 }
834 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw };
835}
836export function listFollowing(slug) { return fwStmts().list.all(slug); }
837
838let _insTl, _listTl, _delTl;
839function tlStmts() {
840 if (!_insTl) {
841 _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)');
842 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
843 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
844 }
845 return { ins: _insTl, list: _listTl, del: _delTl };
846}
847export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
848
849// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
850export async function followActor(site, handle) {
851 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
852 if (!base || !site || !site.slug) return { error: 'config' };
853 const actorUrl = await webfingerResolve(handle);
854 if (!actorUrl) return { error: 'not_found' };
855 const actor = await fetchActor(actorUrl).catch(() => null);
856 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
857 const ai = actorInfo(actor, actor.id);
858 const me = actorId(base, site.slug);
859 const keys = getOrCreateKeys(site.slug);
860 const followId = `${me}#follow-${Date.now()}`;
861 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending');
862 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
863 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
864 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
865 console.log('[AP] follow', site.slug, '→', actor.id);
866 return { ok: true, name: ai.name, handle: ai.handle };
867}
868
869export async function unfollowActor(site, actorUri) {
870 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
871 const me = actorId(base, site.slug);
872 const keys = getOrCreateKeys(site.slug);
873 const row = fwStmts().one.get(site.slug, actorUri);
874 if (row && row.inbox) {
875 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 } };
876 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
877 }
878 fwStmts().del.run(site.slug, actorUri);
879 return { ok: true };
880}
881
882// Send a Like or Announce (boost) on a remote note FROM this site.
883export async function sendInteraction(site, kind, targetNoteId, authorUri) {
884 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
885 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
886 const type = kind === 'boost' ? 'Announce' : 'Like';
887 const me = actorId(base, site.slug);
888 const keys = getOrCreateKeys(site.slug);
889 const act = {
890 '@context': 'https://www.w3.org/ns/activitystreams',
891 id: `${me}#${type.toLowerCase()}-${Date.now()}`,
892 type, actor: me, object: targetNoteId,
893 };
894 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
895 const inboxes = new Set();
896 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
897 // A boost is public → also deliver to our own followers so it shows for them.
898 if (type === 'Announce') { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
899 let delivered = 0;
900 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 */ } }
901 console.log('[AP]', type, site.slug, '→', targetNoteId, 'delivered', delivered);
902 return { ok: true, delivered };
903}
904
905// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
906export function getNotifications(slug, limit) {
907 const out = [];
908 try {
909 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)) {
910 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
911 }
912 } catch { /* ignore */ }
913 try {
914 const rows = db.prepare(`
915 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
916 p.slug AS post_slug, p.title AS post_title
917 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
918 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
919 ORDER BY i.created_at DESC LIMIT 80
920 `).all(slug);
921 for (const r of rows) out.push({
922 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
923 content: r.content, post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
924 });
925 } catch { /* ignore */ }
926 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
927 return out.slice(0, limit || 60);
928}
929
930// ── Blocking / defederation ───────────────────────────────────────
931let _insBl, _delBl, _listBl;
932function blStmts() {
933 if (!_insBl) {
934 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
935 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
936 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
937 }
938 return { ins: _insBl, del: _delBl, list: _listBl };
939}
940export function listBlocks(slug) { return blStmts().list.all(slug); }
941
942// True if an actor (or its whole domain) is blocked anywhere on this instance.
943export function isBlockedAny(actorUri) {
944 if (!actorUri) return false;
945 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
946 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
947 catch { return false; }
948}
949
950function purgeBlocked(kind, target) {
951 try {
952 if (kind === 'domain') {
953 const like = `%//${target}/%`;
954 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
955 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
956 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
957 } else {
958 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
959 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
960 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
961 }
962 } catch { /* best-effort */ }
963}
964
965// Block an actor (@handle or actor URL) or a whole domain; purges their content.
966export async function blockTarget(site, input) {
967 const raw = String(input || '').trim();
968 if (!site || !site.slug || !raw) return { error: 'empty' };
969 let kind, target, label;
970 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
971 else if (raw.includes('@')) {
972 const actorUrl = await webfingerResolve(raw);
973 if (!actorUrl) return { error: 'not_found' };
974 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
975 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
976 blStmts().ins.run(site.slug, target, kind, label);
977 purgeBlocked(kind, target);
978 console.log('[AP] block', site.slug, kind, target);
979 return { ok: true, label };
980}
981
982export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
983
984export default {
985 getOrCreateKeys, apWants, sendAP, actorId, noteId,
986 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
987 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate,
988 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
989 listOutbox, deliverOutboxDelete,
990 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
991 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
992 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
993 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
994};
Note: See TracBrowser for help on using the repository browser.