source: Klonkt/src/services/ActivityPubService.js@ 4b5223f

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

fediverse: federate external embeds as a bare URL link (Mastodon renders its own card)

A post's [[embed:url]] shortcode (YouTube/Spotify/SoundCloud) federated as raw shortcode
text. buildNote now emits the bare URL as a link so Mastodon generates its own preview/
player card.

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

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