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

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

fediverse: cache-buster on music listen-link to force a fresh SQUARE player card

Mastodon caches the player-card dimensions per URL and won't re-crawl a recent card,
so existing posts kept the old landscape ratio. Appending ?fc=<ver> to the listen-link
(playable posts only) makes Mastodon treat it as a new card URL and re-crawl it at
480x480. Link text stays clean; the page ignores the param. Bump FEDI_CARD_VER on
future dimension changes.

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

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