source: Klonkt/src/services/ActivityPubService.js@ 55bba23

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

fediverse: federate pin order via Add/Remove activities (reliable, push-based)

The featured COLLECTION is pull-based and Mastodon caches it with sticky StatusPins, so
reordering never propagated. Mastodon federates pins via Add/Remove activities to the
featured collection (Add -> StatusPin.create, Remove -> destroy), processed immediately.
resyncFeaturedPins removes every pin then re-adds in rank-DESC order (rank 1 last =
newest = shown first) with gaps. /save fires it on any pin/unpin/reorder. Replaces the
unreliable deliverActorUpdate(featured-refetch) approach.

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

  • Property mode set to 100644
File size: 56.3 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import db from '../config/database.js';
20import HtmlSanitizerService from './HtmlSanitizerService.js';
21
22const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
23const MAX_OUTBOX = 20;
24// 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 // Auto-backfill: send the new follower our recent posts as Create so their
527 // timeline isn't empty (Mastodon doesn't fetch history on follow). Fire-and-forget.
528 backfillNewFollower(base, slug, remote.inbox).catch(() => { /* best-effort */ });
529 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
530 return 202;
531 }
532 if (type === 'Undo' && act.object) {
533 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
534 const ot = act.object.type;
535 if (ot === 'Follow') {
536 const obj = act.object.object;
537 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
538 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
539 return 202;
540 }
541 if (ot === 'Like' || ot === 'Announce') {
542 const tgt = act.object.object;
543 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
544 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
545 return 202;
546 }
547 return 202;
548 }
549
550 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
551 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
552 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
553 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
554
555 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
556 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
557 const o = act.object;
558 const tgt = findThreadTarget(o.inReplyTo, base);
559 if (tgt && actorUri && !isLocalActor) {
560 const ai = actorInfo(await resolveActor(actorUri), actorUri);
561 const html = HtmlSanitizerService.sanitize(o.content || '');
562 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);
563 console.log('[AP] reply', actorUri, '→', tgt.post_id);
564 return 202;
565 }
566 // Home timeline (client): a top-level post from an account we follow.
567 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
568 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
569 if (subs.length) {
570 const ai = actorInfo(await resolveActor(actorUri), actorUri);
571 const html = HtmlSanitizerService.sanitize(o.content || '');
572 const media = JSON.stringify((Array.isArray(o.attachment) ? o.attachment : []).filter((a) => a && a.url).map((a) => ({ url: a.url, type: a.mediaType || '' })));
573 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);
574 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
575 }
576 }
577 return 202;
578 }
579 if (type === 'Like' || type === 'Announce') {
580 const tgt = act.object;
581 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
582 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
583 const ai = actorInfo(await resolveActor(actorUri), actorUri);
584 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
585 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
586 }
587 return 202;
588 }
589 if (type === 'Delete') {
590 // A remote note was deleted upstream → drop it from replies AND the timeline.
591 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
592 if (oid) { iStmts().delReply.run(oid); try { tlStmts().del.run(oid); } catch { /* ignore */ } }
593 return 202;
594 }
595 // Accept/Reject of a Follow WE sent (client side).
596 if (type === 'Accept' && act.object) {
597 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
598 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
599 console.log('[AP] follow accepted', actorUri);
600 return 202;
601 }
602 if (type === 'Reject' && act.object) {
603 const who = actorUri;
604 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
605 return 202;
606 }
607
608 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
609 return 202;
610}
611
612// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
613// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
614export async function deliverCreate(site, post) {
615 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
616 if (!base || !site || !site.slug) return;
617 const followers = fStmts().list.all(site.slug);
618 if (!followers.length) return;
619 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
620 const keys = getOrCreateKeys(site.slug);
621 const keyId = `${actorId(base, site.slug)}#main-key`;
622 const create = buildCreate(base, site, post);
623 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
624}
625
626// On a new Follow, send that follower our most recent posts as Create so their
627// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
628// so they sort into the follower's timeline at their original dates.
629async function backfillNewFollower(base, slug, inbox) {
630 if (!base || !slug || !inbox) return;
631 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
632 if (!site) return;
633 const recent = db.prepare(
634 `SELECT id, slug, title, content, cover_image_url, published_at, created_at
635 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
636 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
637 ).all(site.id).reverse();
638 if (!recent.length) return;
639 const keys = getOrCreateKeys(slug);
640 const keyId = `${actorId(base, slug)}#main-key`;
641 for (const p of recent) {
642 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
643 await new Promise((r) => setTimeout(r, 150));
644 }
645 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
646}
647
648// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
649export async function deliverDelete(site, post) {
650 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
651 if (!base || !site || !site.slug || !post || !post.id) return;
652 const followers = fStmts().list.all(site.slug);
653 if (!followers.length) return;
654 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
655 const keys = getOrCreateKeys(site.slug);
656 const me = actorId(base, site.slug);
657 const nid = noteId(base, post.id);
658 const del = {
659 '@context': 'https://www.w3.org/ns/activitystreams',
660 id: `${nid}#delete-${Date.now()}`,
661 type: 'Delete',
662 actor: me,
663 to: [PUBLIC],
664 object: { id: nid, type: 'Tombstone' },
665 };
666 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
667}
668
669// Tell followers an already-published post changed (Update + edited Note) so
670// Mastodon refreshes the cached copy (e.g. after fixing content).
671export async function deliverUpdate(site, post) {
672 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
673 if (!base || !site || !site.slug || !post || !post.id) return;
674 const followers = fStmts().list.all(site.slug);
675 if (!followers.length) return;
676 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
677 const keys = getOrCreateKeys(site.slug);
678 const me = actorId(base, site.slug);
679 const note = buildNote(base, site, post);
680 note.updated = new Date().toISOString();
681 const update = {
682 '@context': 'https://www.w3.org/ns/activitystreams',
683 id: `${noteId(base, post.id)}#update-${Date.now()}`,
684 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
685 object: note,
686 };
687 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
688}
689
690// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
691// account AND re-fetches the featured (pinned) collection — there is no standard
692// "featured changed" activity, so this is how a pin/unpin propagates promptly.
693export async function deliverActorUpdate(site) {
694 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
695 if (!base || !site || !site.slug) return;
696 const followers = fStmts().list.all(site.slug);
697 if (!followers.length) return;
698 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
699 const keys = getOrCreateKeys(site.slug);
700 const me = actorId(base, site.slug);
701 const update = {
702 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
703 id: `${me}#update-${Date.now()}`,
704 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
705 object: buildActor(base, site),
706 };
707 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
708}
709
710// Reliably set the pinned order on followers' instances via Add/Remove activities
711// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
712// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
713// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
714// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
715// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
716export async function resyncFeaturedPins(site, alsoRemove = []) {
717 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
718 if (!base || !site || !site.slug) return;
719 const followers = fStmts().list.all(site.slug);
720 if (!followers.length) return;
721 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
722 const keys = getOrCreateKeys(site.slug);
723 const me = actorId(base, site.slug);
724 const keyId = `${me}#main-key`;
725 const featured = `${me}/featured`;
726 const AS = 'https://www.w3.org/ns/activitystreams';
727 const note = (id) => noteId(base, id);
728 const pinned = db.prepare(
729 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
730 AND pinned IS NOT NULL AND pinned > 0
731 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
732 ).all(site.id);
733 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
734 // 1. Remove every current pin so Mastodon can recreate them in order.
735 for (const id of removeIds) {
736 const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
737 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
738 }
739 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
740 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
741 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
742 for (const p of pinned) {
743 const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
744 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
745 await new Promise((r) => setTimeout(r, 2000));
746 }
747 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
748}
749
750// ── outbound replies (Klonkt → fediverse) ─────────────────────────
751const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
752const 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(); };
753
754// Build one of OUR outbound reply Notes from an ap_outbox row.
755export function buildReplyNote(base, site, row) {
756 const me = actorId(base, site.slug);
757 return {
758 id: noteId(base, row.id),
759 type: 'Note',
760 attributedTo: me,
761 inReplyTo: row.in_reply_to || undefined,
762 content: row.content,
763 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
764 published: toISO(row.created_at),
765 to: row.to_actor ? [row.to_actor] : [PUBLIC],
766 cc: [PUBLIC, `${me}/followers`],
767 tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
768 };
769}
770
771// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
772export function getOutboxNote(base, id) {
773 const row = iStmts().getO.get(id);
774 if (!row) return null;
775 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
776 if (!site) return null;
777 return buildReplyNote(base, site, row);
778}
779
780// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
781// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
782export async function deliverReply(site, { postId, postSlug, parent, text }) {
783 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
784 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
785 const me = actorId(base, site.slug);
786 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
787 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
788 const mention = parent.actor_uri
789 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
790 const content = `<p>${mention}${body}</p>`;
791 // Dedup: skip if the exact same reply was already sent (double-submit guard).
792 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
793 .get(site.slug, parent.object_uri || '', content);
794 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
795 const id = crypto.randomUUID();
796 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
797 const row = iStmts().getO.get(id);
798 const note = buildReplyNote(base, site, row);
799 const create = {
800 '@context': 'https://www.w3.org/ns/activitystreams',
801 id: note.id + '#create', type: 'Create', actor: me,
802 published: note.published, to: note.to, cc: note.cc, object: note,
803 };
804 const keys = getOrCreateKeys(site.slug);
805 const keyId = `${me}#main-key`;
806 const inboxes = new Set();
807 if (parent.actor_uri) {
808 const a = await fetchActor(parent.actor_uri).catch(() => null);
809 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
810 }
811 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
812 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
813 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
814 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
815 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
816 let delivered = 0;
817 for (const inbox of [...inboxes].filter(Boolean)) {
818 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
819 }
820 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
821 return { id, content, delivered };
822}
823
824// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
825// Returns a parent-shaped object usable by deliverReply(), or null.
826export async function resolveRemoteNote(url) {
827 if (!/^https?:\/\//i.test(String(url || ''))) return null;
828 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
829 if (!note || !note.id) return null;
830 const att = note.attributedTo;
831 const actorUri = typeof att === 'string' ? att : (att && att.id);
832 if (!actorUri) return null;
833 const actor = await fetchActor(actorUri).catch(() => null);
834 const ai = actorInfo(actor, actorUri);
835 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
836 // link our reply to that local post so it shows nested in the post thread.
837 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
838 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
839 // and collect every ancestor author's inbox, so each participant's server —
840 // including the original post's author — receives + threads our reply.
841 const threadInboxes = [];
842 const seenInbox = new Set();
843 let cursor = note.inReplyTo, guard = 0;
844 while (cursor && guard++ < 6) {
845 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
846 if (!url) break;
847 const pn = await fetchActor(url).catch(() => null);
848 if (!pn) break;
849 const pa = typeof pn.attributedTo === 'string' ? pn.attributedTo : (pn.attributedTo && pn.attributedTo.id);
850 if (pa && pa !== actorUri) {
851 const paDoc = await fetchActor(pa).catch(() => null);
852 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
853 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
854 }
855 cursor = pn.inReplyTo; // climb to the next ancestor
856 }
857 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
858 const images = (Array.isArray(note.attachment) ? note.attachment : [])
859 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
860 .map((a) => a.url);
861 return {
862 object_uri: note.id,
863 actor_uri: actorUri,
864 actor_url: ai.url,
865 actor_handle: ai.handle,
866 actor_name: ai.name,
867 actor_icon: ai.icon,
868 url: note.url || url,
869 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
870 images,
871 threadInboxes, // every ancestor author's inbox
872 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
873 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
874 };
875}
876
877// List a site's own outbound fediverse replies (for the manage/delete view).
878export function listOutbox(siteSlug) {
879 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);
880}
881
882// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
883export async function deliverOutboxDelete(site, outboxId) {
884 const row = iStmts().getO.get(outboxId);
885 if (!row || row.site_slug !== site.slug) return false;
886 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
887 if (base) {
888 const me = actorId(base, site.slug);
889 const nid = noteId(base, row.id);
890 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' } };
891 const keys = getOrCreateKeys(site.slug);
892 const inboxes = new Set();
893 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
894 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
895 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
896 }
897 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
898 return true;
899}
900
901// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
902// Resolve an @user@domain handle to its actor URL via WebFinger.
903export async function webfingerResolve(handle) {
904 const h = String(handle || '').trim().replace(/^@/, '');
905 const parts = h.split('@');
906 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
907 const acct = `${parts[0]}@${parts[1]}`;
908 try {
909 const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
910 { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
911 if (!r.ok) return null;
912 const jrd = await r.json();
913 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
914 return link ? link.href : null;
915 } catch { return null; }
916}
917
918let _insFw, _delFw, _listFw, _accFw, _oneFw;
919function fwStmts() {
920 if (!_insFw) {
921 _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)');
922 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
923 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
924 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
925 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
926 }
927 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw };
928}
929export function listFollowing(slug) { return fwStmts().list.all(slug); }
930
931let _insTl, _listTl, _delTl;
932function tlStmts() {
933 if (!_insTl) {
934 _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)');
935 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
936 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
937 }
938 return { ins: _insTl, list: _listTl, del: _delTl };
939}
940export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
941
942// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
943export async function followActor(site, handle) {
944 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
945 if (!base || !site || !site.slug) return { error: 'config' };
946 const actorUrl = await webfingerResolve(handle);
947 if (!actorUrl) return { error: 'not_found' };
948 const actor = await fetchActor(actorUrl).catch(() => null);
949 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
950 const ai = actorInfo(actor, actor.id);
951 const me = actorId(base, site.slug);
952 const keys = getOrCreateKeys(site.slug);
953 const followId = `${me}#follow-${Date.now()}`;
954 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending');
955 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
956 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
957 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
958 console.log('[AP] follow', site.slug, '→', actor.id);
959 return { ok: true, name: ai.name, handle: ai.handle };
960}
961
962export async function unfollowActor(site, actorUri) {
963 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
964 const me = actorId(base, site.slug);
965 const keys = getOrCreateKeys(site.slug);
966 const row = fwStmts().one.get(site.slug, actorUri);
967 if (row && row.inbox) {
968 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 } };
969 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
970 }
971 fwStmts().del.run(site.slug, actorUri);
972 return { ok: true };
973}
974
975// Send a Like or Announce (boost) on a remote note FROM this site.
976export async function sendInteraction(site, kind, targetNoteId, authorUri) {
977 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
978 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
979 const type = kind === 'boost' ? 'Announce' : 'Like';
980 const me = actorId(base, site.slug);
981 const keys = getOrCreateKeys(site.slug);
982 const act = {
983 '@context': 'https://www.w3.org/ns/activitystreams',
984 id: `${me}#${type.toLowerCase()}-${Date.now()}`,
985 type, actor: me, object: targetNoteId,
986 };
987 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
988 const inboxes = new Set();
989 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
990 // A boost is public → also deliver to our own followers so it shows for them.
991 if (type === 'Announce') { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
992 let delivered = 0;
993 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 */ } }
994 console.log('[AP]', type, site.slug, '→', targetNoteId, 'delivered', delivered);
995 return { ok: true, delivered };
996}
997
998// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
999export function getNotifications(slug, limit) {
1000 const out = [];
1001 try {
1002 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)) {
1003 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1004 }
1005 } catch { /* ignore */ }
1006 try {
1007 const rows = db.prepare(`
1008 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1009 p.slug AS post_slug, p.title AS post_title
1010 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1011 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1012 ORDER BY i.created_at DESC LIMIT 80
1013 `).all(slug);
1014 for (const r of rows) out.push({
1015 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1016 content: r.content, post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1017 });
1018 } catch { /* ignore */ }
1019 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1020 return out.slice(0, limit || 60);
1021}
1022
1023// ── Blocking / defederation ───────────────────────────────────────
1024let _insBl, _delBl, _listBl;
1025function blStmts() {
1026 if (!_insBl) {
1027 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1028 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1029 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1030 }
1031 return { ins: _insBl, del: _delBl, list: _listBl };
1032}
1033export function listBlocks(slug) { return blStmts().list.all(slug); }
1034
1035// True if an actor (or its whole domain) is blocked anywhere on this instance.
1036export function isBlockedAny(actorUri) {
1037 if (!actorUri) return false;
1038 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1039 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1040 catch { return false; }
1041}
1042
1043function purgeBlocked(kind, target) {
1044 try {
1045 if (kind === 'domain') {
1046 const like = `%//${target}/%`;
1047 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1048 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1049 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1050 } else {
1051 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1052 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1053 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1054 }
1055 } catch { /* best-effort */ }
1056}
1057
1058// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1059export async function blockTarget(site, input) {
1060 const raw = String(input || '').trim();
1061 if (!site || !site.slug || !raw) return { error: 'empty' };
1062 let kind, target, label;
1063 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1064 else if (raw.includes('@')) {
1065 const actorUrl = await webfingerResolve(raw);
1066 if (!actorUrl) return { error: 'not_found' };
1067 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1068 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1069 blStmts().ins.run(site.slug, target, kind, label);
1070 purgeBlocked(kind, target);
1071 console.log('[AP] block', site.slug, kind, target);
1072 return { ok: true, label };
1073}
1074
1075export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1076
1077export default {
1078 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1079 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1080 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1081 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1082 listOutbox, deliverOutboxDelete,
1083 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
1084 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1085 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1086 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1087};
Note: See TracBrowser for help on using the repository browser.