source: Klonkt/src/services/ActivityPubService.js@ bddbfe0

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

feat(fedi): edit your own outbound replies (Update(Note))

  • services/ActivityPubService.js — deliverOutboxUpdate rewrites the stored reply (mention re-added + #tags re-linked) and federates an Update(Note); listOutbox exposes the plain editable text (outboxEditableText) to prefill the box; added to the default export.
  • routes/posts.js — POST /fediverse/:id/edit (owner only).
  • views/pages/authorize-interaction.ejs — an Edit details/textarea per reply in the manage list; + CSS.
  • services/i18n.js — fedi.edit / fedi.save_edit (NL/EN/DE).
  • Property mode set to 100644
File size: 80.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 dns from 'dns';
20import net from 'net';
21import db from '../config/database.js';
22import HtmlSanitizerService from './HtmlSanitizerService.js';
23
24const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
25
26// Short random suffix so two activity ids minted in the same millisecond (e.g.
27// parallel saves) don't collide and get deduped by a receiver.
28const rid = () => crypto.randomBytes(4).toString('hex');
29
30// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
31// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
32const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
33
34// ── SSRF guard for outbound fetches ───────────────────────────────
35// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
36// every outbound fetch must refuse hosts that resolve to private/loopback ranges
37// (cloud metadata, internal services) — on the initial host AND each redirect hop.
38function isBlockedIp(ip) {
39 if (!ip) return true;
40 const v = net.isIP(ip);
41 if (v === 4) {
42 const o = ip.split('.').map(Number);
43 return o[0] === 127 || o[0] === 10 || o[0] === 0
44 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
45 || (o[0] === 192 && o[1] === 168)
46 || (o[0] === 169 && o[1] === 254)
47 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
48 }
49 if (v === 6) {
50 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
51 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
52 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
53 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
54 }
55 return true; // not an IP literal we recognise → refuse
56}
57async function assertPublicHost(hostname) {
58 if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
59 const addrs = await dns.promises.lookup(hostname, { all: true });
60 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
61}
62async function safeFetch(url, opts = {}, maxRedirects = 3) {
63 let target = url;
64 for (let hop = 0; ; hop++) {
65 const u = new URL(target); // throws on malformed → caller's catch
66 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
67 await assertPublicHost(u.hostname);
68 const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
69 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
70 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
71 return r;
72 }
73}
74const MAX_OUTBOX = 20;
75// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
76// (square) player card. Bump this whenever the twitter:player card dimensions change.
77const FEDI_CARD_VER = '2';
78
79// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
80// Prepared lazily (NOT at module load) — the ap_keys table is created in
81// initializeDatabase(), which runs after this module is imported.
82let _sel, _ins;
83function keyStmts() {
84 if (!_sel) {
85 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
86 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
87 }
88 return { sel: _sel, ins: _ins };
89}
90
91export function getOrCreateKeys(slug) {
92 const { sel, ins } = keyStmts();
93 const row = sel.get(slug);
94 if (row) return row;
95 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
96 modulusLength: 2048,
97 publicKeyEncoding: { type: 'spki', format: 'pem' },
98 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
99 });
100 ins.run(slug, publicKey, privateKey);
101 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
102}
103
104// ── content negotiation ───────────────────────────────────────────
105// True when the caller wants ActivityPub JSON rather than the HTML page.
106export function apWants(req) {
107 const a = String(req.headers.accept || '').toLowerCase();
108 return a.includes('application/activity+json') ||
109 (a.includes('application/ld+json') && a.includes('activitystreams'));
110}
111
112const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
113export function sendAP(res, obj) {
114 res.type(AP_CONTENT_TYPE);
115 res.set('Cache-Control', 'public, max-age=120');
116 res.send(JSON.stringify(obj));
117}
118
119// ── document builders ─────────────────────────────────────────────
120export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
121export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
122
123export function buildActor(base, site) {
124 const id = actorId(base, site.slug);
125 const keys = getOrCreateKeys(site.slug);
126 const actor = {
127 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
128 id,
129 type: 'Person',
130 preferredUsername: site.slug,
131 name: site.title || site.slug,
132 summary: site.tagline || site.description || '',
133 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
134 manuallyApprovesFollowers: false,
135 discoverable: true,
136 inbox: `${id}/inbox`,
137 outbox: `${id}/outbox`,
138 followers: `${id}/followers`,
139 featured: `${id}/featured`,
140 endpoints: { sharedInbox: `${base}/ap/inbox` },
141 publicKey: {
142 id: `${id}#main-key`,
143 owner: id,
144 publicKeyPem: keys.public_pem,
145 },
146 };
147 if (site.profile_photo) {
148 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
149 actor.icon = { type: 'Image', url: u };
150 }
151 return actor;
152}
153
154// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
155// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
156// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
157export function hasPlayableAudio(content, siteId) {
158 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
159 try {
160 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; }
161 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; }
162 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; }
163 } catch { /* non-fatal */ }
164 return false;
165}
166
167// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
168export function buildNote(base, site, post) {
169 const id = noteId(base, post.id);
170 const aId = actorId(base, site.slug);
171 const human = `${base}/${encodeURIComponent(post.slug)}`;
172 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
173 // first line) — the standard blog→fediverse convention. post.content is
174 // already sanitized HTML; the title is plain text, so escape it.
175 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
176 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
177
178 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
179 // the cover + any inline <img>, make absolute, then strip <img> from the content
180 // to avoid duplicate rendering on clients that DO keep them.
181 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
182 const mediaType = (u) => {
183 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
184 return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
185 };
186 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
187 const playable = hasPlayableAudio(post.content || '', site && site.id);
188 const urls = [];
189 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
190 // the player CARD (twitter:player) instead of the cover — media attachment and
191 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
192 // keeps its cover (no player card to show).
193 if (post.cover_image_url && !playable) urls.push(abs(post.cover_image_url));
194 let body = post.content || '';
195 if (!playable) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
196 body = body.replace(/<img\b[^>]*>/gi, '');
197 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
198 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
199 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
200 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
201 // a click-through to the protected player (discovery without leaking the file).
202 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
203 const audioLabels = [];
204 try {
205 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); }
206 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
207 } catch { /* non-fatal */ }
208 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
209 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
210 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
211 // of federating the raw shortcode text.
212 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
213 const u = esc(raw.trim().replace(/&amp;/g, '&'));
214 return `<p><a href="${u}">${u}</a></p>`;
215 });
216 if (hadAudio) {
217 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
218 // For playable posts, append a version param to the listen-link so Mastodon
219 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
220 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
221 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
222 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
223 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
224 }
225 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
226 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
227 // so convert newlines to <br> for the federated copy (content already made with
228 // shift+enter uses <br> and has no \n → this is a no-op there).
229 body = body.replace(/\r?\n/g, '<br>');
230 body = linkHashtags(base, body); // link inline #hashtags in the post body too
231 const seen = new Set();
232 const attachment = urls.filter(Boolean)
233 .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
234 .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
235
236 const note = {
237 id,
238 type: 'Note',
239 attributedTo: aId,
240 content: titleHtml + body,
241 url: human,
242 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
243 // fan_only = "fans only" → followers-only visibility (delivered to your followers
244 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
245 to: post.fan_only ? [`${aId}/followers`] : [PUBLIC],
246 cc: post.fan_only ? [] : [`${aId}/followers`],
247 tag: buildHashtagList(base, post.tags, body),
248 replies: `${id}/replies`,
249 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
250 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
251 sensitive: !!post.nsfw,
252 };
253 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
254 if (attachment.length) note.attachment = attachment;
255 // Playable-audio posts suppress the cover attachment (player card). Still expose
256 // the cover via AS2 `image` so card/grid consumers (the Klonkt Cirkel) can show
257 // it — Mastodon ignores a Note's `image`, so the player card is unaffected.
258 if (post.cover_image_url && playable) {
259 const cov = abs(post.cover_image_url);
260 if (cov) note.image = { type: 'Image', mediaType: mediaType(cov), url: cov };
261 }
262 return note;
263}
264
265// All reply note URIs on a local post (inbound fediverse replies + our own
266// outbound replies) — backs the Note's `replies` Collection so remote servers
267// can fetch the whole thread.
268export function getReplyUris(base, postId) {
269 const out = [];
270 try {
271 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);
272 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}`);
273 } catch { /* non-fatal */ }
274 return out;
275}
276
277// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
278export function markNotificationsSeen(slug) {
279 try {
280 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")
281 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
282 } catch { /* non-fatal */ }
283}
284export function countUnseenNotifications(slug) {
285 try {
286 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
287 const seen = row ? Date.parse(row.value) : 0;
288 let n = 0;
289 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
290 return n;
291 } catch { return 0; }
292}
293
294export function buildCreate(base, site, post) {
295 const note = buildNote(base, site, post);
296 return {
297 '@context': 'https://www.w3.org/ns/activitystreams',
298 id: note.id + '#create',
299 type: 'Create',
300 actor: actorId(base, site.slug),
301 published: note.published,
302 to: note.to,
303 cc: note.cc,
304 object: note,
305 };
306}
307
308export function buildOutbox(base, site, posts) {
309 const id = `${actorId(base, site.slug)}/outbox`;
310 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
311 return {
312 '@context': 'https://www.w3.org/ns/activitystreams',
313 id,
314 type: 'OrderedCollection',
315 totalItems: items.length,
316 orderedItems: items,
317 };
318}
319
320export function buildFollowers(base, site, count) {
321 const id = `${actorId(base, site.slug)}/followers`;
322 return {
323 '@context': 'https://www.w3.org/ns/activitystreams',
324 id,
325 type: 'OrderedCollection',
326 totalItems: count || 0,
327 orderedItems: [], // hidden for privacy; count only
328 };
329}
330
331// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
332// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
333// rank; embedded as full Notes so a remote server doesn't need extra fetches.
334export function buildFeatured(base, site, posts) {
335 const id = `${actorId(base, site.slug)}/featured`;
336 const items = (posts || []).map((p) => buildNote(base, site, p));
337 return {
338 '@context': 'https://www.w3.org/ns/activitystreams',
339 id,
340 type: 'OrderedCollection',
341 totalItems: items.length,
342 orderedItems: items,
343 };
344}
345
346// ── followers store (lazy stmts) ──────────────────────────────────
347let _insF, _delF, _listF, _cntF;
348function fStmts() {
349 if (!_insF) {
350 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
351 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
352 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
353 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
354 }
355 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
356}
357export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
358
359// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
360let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
361function iStmts() {
362 if (!_insI) {
363 _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)');
364 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
365 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
366 _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
367 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
368 _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)');
369 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
370 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
371 }
372 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
373}
374
375export function getInteractionById(id) { return iStmts().getI.get(id); }
376export function setInteractionBoosted(id, on) {
377 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
378}
379export function setInteractionLiked(id, on) {
380 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
381}
382// Your like/boost state on a REMOTE post (interact page toggles).
383export function setMyReaction(slug, uri, kind, on) {
384 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
385 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
386}
387export function getMyReactions(slug, uri) {
388 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
389 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
390}
391
392const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
393// Extract our local post id from a note URL, but only if it's ours (base match).
394function postIdFromNoteUrl(url, base) {
395 const s = String(url || '');
396 if (base && !s.startsWith(base)) return null;
397 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
398 return m ? decodeURIComponent(m[1]) : null;
399}
400function deriveHandle(actorUri) {
401 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
402}
403function actorInfo(doc, actorUri) {
404 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
405 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
406 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
407 return {
408 name: (doc && (doc.name || doc.preferredUsername)) || handle,
409 handle,
410 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
411 icon: safeUrl(icon) || null,
412 };
413}
414
415// Given an inReplyTo note URL, find which local post the thread belongs to + the
416// note being replied to (parent), so a reply-to-a-comment can be nested.
417function findThreadTarget(inReplyTo, base) {
418 if (!inReplyTo) return null;
419 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
420 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
421 if (seg) {
422 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 */ }
423 }
424 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 */ }
425 return null;
426}
427
428// Drop the leading @mention(s) a federated reply carries (the person being replied to),
429// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
430// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
431export function stripLeadingMentions(html) {
432 if (!html) return html;
433 let s = String(html);
434 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
435 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
436 return s;
437}
438
439// View-ready threaded view of a post's fediverse activity (inbound replies +
440// our outbound replies, nested), plus like/boost counts.
441export function getInteractions(postId, base, site) {
442 const s = iStmts();
443 const rows = s.list.all(postId);
444 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
445 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
446 // Our own (outbound) replies show the SITE identity for everyone (not "You").
447 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
448 const siteName = (site && (site.title || site.slug)) || '';
449 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
450 const siteUrl = baseClean ? `${baseClean}/` : '';
451 const siteIcon = (site && site.profile_photo) || null;
452
453 const nodes = [];
454 for (const r of rows) {
455 if (r.kind !== 'reply') continue;
456 nodes.push({
457 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
458 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
459 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
460 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
461 children: [],
462 });
463 }
464 for (const o of s.listO.all(postId)) {
465 nodes.push({
466 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
467 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
468 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
469 children: [],
470 });
471 }
472
473 const byId = new Map(nodes.map((n) => [n.noteId, n]));
474 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
475 const tops = [];
476 for (const n of nodes) {
477 if (isTop(n)) { tops.push(n); continue; }
478 let anc = n, guard = 0;
479 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
480 anc.children.push(n);
481 }
482 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
483 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
484
485 return {
486 thread: tops,
487 likeCount: rows.filter((r) => r.kind === 'like').length,
488 announceCount: rows.filter((r) => r.kind === 'announce').length,
489 total: nodes.length,
490 };
491}
492
493// ── HTTP Signatures + delivery ────────────────────────────────────
494const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
495
496// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
497export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
498 const body = JSON.stringify(bodyObj);
499 const u = new URL(inboxUrl);
500 const date = new Date().toUTCString();
501 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
502 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
503 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
504 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
505 const r = await safeFetch(inboxUrl, {
506 method: 'POST',
507 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
508 body,
509 });
510 return r.status;
511}
512
513export async function fetchActor(url) {
514 try {
515 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
516 if (!r.ok) return null;
517 const len = Number(r.headers.get('content-length') || 0);
518 if (len > 2_000_000) return null; // refuse oversized actor docs
519 return await r.json();
520 } catch { return null; }
521}
522
523// ── Delivery queue with retries ───────────────────────────────────
524// Outbound deliveries are tried immediately; on failure (down server, timeout,
525// non-2xx) they're queued and retried with backoff so a briefly-offline follower
526// doesn't silently miss the post. The signing key is NOT stored — the worker
527// re-derives it from the actor slug at send time.
528const DELIVERY_MAX_ATTEMPTS = 6;
529const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
530let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
531function deliveryStmts() {
532 if (!_insDeliv) {
533 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
534 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
535 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
536 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
537 }
538 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
539}
540export function enqueueDelivery(slug, inbox, activity) {
541 if (!slug || !inbox || !activity) return;
542 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
543}
544// Deliver now; queue for retry if it fails.
545export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
546 if (!inbox) return;
547 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
548 enqueueDelivery(slug, inbox, activity);
549}
550let _processingDeliv = false;
551export async function processDeliveryQueue() {
552 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
553 _processingDeliv = true;
554 try {
555 let rows;
556 try { rows = deliveryStmts().due.all(); } catch { return; }
557 if (!rows || !rows.length) return;
558 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
559 for (const row of rows) {
560 let ok = false;
561 try {
562 const keys = getOrCreateKeys(row.slug);
563 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
564 ok = st >= 200 && st < 300;
565 } catch { ok = false; }
566 if (ok) { deliveryStmts().del.run(row.id); continue; }
567 const attempts = row.attempts + 1;
568 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
569 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
570 // retry uses the 1-min tier instead of skipping it.
571 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
572 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
573 }
574 } finally { _processingDeliv = false; }
575}
576let _delivTimer = null;
577export function startDeliveryWorker() {
578 if (_delivTimer) return;
579 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
580 if (_delivTimer.unref) _delivTimer.unref();
581}
582
583// Best-effort verification of an incoming signed request. Returns the sender's
584// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
585export async function verifyRequest(req) {
586 const sigH = req.headers['signature'];
587 if (!sigH) return null;
588 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
589 if (!p.keyId || !p.signature) return null;
590 const actor = await fetchActor(p.keyId.split('#')[0]);
591 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
592 if (!pem) return null;
593 const hs = (p.headers || '(request-target) host date').split(/\s+/);
594 const line = hs.map((h) => h === '(request-target)'
595 ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
596 : `${h}: ${req.headers[h] || ''}`).join('\n');
597 let ok = false;
598 try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
599 if (ok && hs.includes('digest') && req.rawBody) {
600 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
601 if (req.headers['digest'] !== exp) ok = false;
602 }
603 return ok ? actor : null;
604}
605
606// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
607export async function handleInbox(req, slugParam) {
608 const act = req.body || {};
609 const type = act.type;
610 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
611 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
612 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
613 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
614 const verified = await verifyRequest(req).catch(() => null);
615
616 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
617 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
618 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
619 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
620 // Blocked actor/domain → silently drop (202, don't reveal the block).
621 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
622 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update'];
623 if (GATED.includes(type)) {
624 if (!verified || !claimedActor || verified.id !== claimedActor) {
625 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
626 return 401;
627 }
628 }
629
630 if (type === 'Follow') {
631 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
632 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
633 if (!who || !slug) return 400;
634 const remote = await fetchActor(who);
635 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
636 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
637 fStmts().ins.run(slug, who, remote.inbox, sharedInbox);
638 const me = actorId(base, slug);
639 const keys = getOrCreateKeys(slug);
640 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
641 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
642 // Auto-backfill: send our recent posts as Create so the instance has our history
643 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
644 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
645 // a follower of ours is wasted work (and won't re-populate the new follower's
646 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
647 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
648 const instanceFilled = sharedInbox &&
649 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
650 .get(slug, sharedInbox, who);
651 if (!instanceFilled) {
652 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
653 }
654 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
655 return 202;
656 }
657 if (type === 'Undo' && act.object) {
658 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
659 const ot = act.object.type;
660 if (ot === 'Follow') {
661 const obj = act.object.object;
662 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
663 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
664 return 202;
665 }
666 if (ot === 'Like' || ot === 'Announce') {
667 const tgt = act.object.object;
668 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
669 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
670 return 202;
671 }
672 return 202;
673 }
674
675 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
676 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
677 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
678 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
679
680 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
681 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
682 const o = act.object;
683 const tgt = findThreadTarget(o.inReplyTo, base);
684 if (tgt && actorUri && !isLocalActor) {
685 const ai = actorInfo(await resolveActor(actorUri), actorUri);
686 const html = HtmlSanitizerService.sanitize(o.content || '');
687 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);
688 console.log('[AP] reply', actorUri, '→', tgt.post_id);
689 return 202;
690 }
691 // Home timeline (client): a top-level post from an account we follow.
692 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
693 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
694 if (subs.length) {
695 const ai = actorInfo(await resolveActor(actorUri), actorUri);
696 const html = HtmlSanitizerService.sanitize(o.content || '');
697 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
698 // Fallback cover: a Note's `image` (set when the attachment was suppressed
699 // for a player-card post, e.g. hosted-audio posts).
700 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
701 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
702 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
703 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
704 }
705 const media = JSON.stringify(_atts);
706 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
707 // incoming posts to the fediverse — that flooded followers. Boosting to the
708 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
709 // the timeline).
710 for (const s of subs) {
711 tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
712 }
713 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
714 }
715 }
716 return 202;
717 }
718 if (type === 'Like' || type === 'Announce') {
719 const tgt = act.object;
720 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
721 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
722 const ai = actorInfo(await resolveActor(actorUri), actorUri);
723 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
724 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
725 }
726 return 202;
727 }
728 if (type === 'Delete') {
729 // A remote note was deleted upstream → drop it from replies AND the timeline.
730 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
731 // signature gate guarantees claimedActor == the verified signer here).
732 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
733 if (oid && claimedActor) {
734 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
735 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
736 }
737 return 202;
738 }
739 // Accept/Reject of a Follow WE sent (client side).
740 if (type === 'Accept' && act.object) {
741 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
742 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
743 console.log('[AP] follow accepted', actorUri);
744 return 202;
745 }
746 if (type === 'Reject' && act.object) {
747 const who = actorUri;
748 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
749 return 202;
750 }
751
752 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
753 return 202;
754}
755
756// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
757// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
758export async function deliverCreate(site, post) {
759 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
760 if (!base || !site || !site.slug) return;
761 const followers = fStmts().list.all(site.slug);
762 if (!followers.length) return;
763 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
764 const keys = getOrCreateKeys(site.slug);
765 const keyId = `${actorId(base, site.slug)}#main-key`;
766 const create = buildCreate(base, site, post);
767 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
768}
769
770// On a new Follow, send that follower our most recent posts as Create so their
771// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
772// so they sort into the follower's timeline at their original dates.
773async function backfillNewFollower(base, slug, inbox) {
774 if (!base || !slug || !inbox) return;
775 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
776 if (!site) return;
777 const recent = db.prepare(
778 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
779 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
780 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
781 ).all(site.id).reverse();
782 if (!recent.length) return;
783 const keys = getOrCreateKeys(slug);
784 const keyId = `${actorId(base, slug)}#main-key`;
785 for (const p of recent) {
786 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
787 await new Promise((r) => setTimeout(r, 150));
788 }
789 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
790}
791
792// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
793export async function deliverDelete(site, post) {
794 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
795 if (!base || !site || !site.slug || !post || !post.id) return;
796 const followers = fStmts().list.all(site.slug);
797 if (!followers.length) return;
798 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
799 const keys = getOrCreateKeys(site.slug);
800 const me = actorId(base, site.slug);
801 const nid = noteId(base, post.id);
802 const del = {
803 '@context': 'https://www.w3.org/ns/activitystreams',
804 id: `${nid}#delete-${Date.now()}-${rid()}`,
805 type: 'Delete',
806 actor: me,
807 to: [PUBLIC],
808 object: { id: nid, type: 'Tombstone' },
809 };
810 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
811}
812
813// Tell followers an already-published post changed (Update + edited Note) so
814// Mastodon refreshes the cached copy (e.g. after fixing content).
815export async function deliverUpdate(site, post) {
816 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
817 if (!base || !site || !site.slug || !post || !post.id) return;
818 const followers = fStmts().list.all(site.slug);
819 if (!followers.length) return;
820 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
821 const keys = getOrCreateKeys(site.slug);
822 const me = actorId(base, site.slug);
823 const note = buildNote(base, site, post);
824 note.updated = new Date().toISOString();
825 const update = {
826 '@context': 'https://www.w3.org/ns/activitystreams',
827 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
828 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
829 object: note,
830 };
831 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
832}
833
834// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
835// account AND re-fetches the featured (pinned) collection — there is no standard
836// "featured changed" activity, so this is how a pin/unpin propagates promptly.
837export async function deliverActorUpdate(site) {
838 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
839 if (!base || !site || !site.slug) return;
840 const followers = fStmts().list.all(site.slug);
841 if (!followers.length) return;
842 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
843 const keys = getOrCreateKeys(site.slug);
844 const me = actorId(base, site.slug);
845 const update = {
846 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
847 id: `${me}#update-${Date.now()}-${rid()}`,
848 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
849 object: buildActor(base, site),
850 };
851 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
852}
853
854// Reliably set the pinned order on followers' instances via Add/Remove activities
855// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
856// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
857// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
858// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
859// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
860export async function resyncFeaturedPins(site, alsoRemove = []) {
861 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
862 if (!base || !site || !site.slug) return;
863 const followers = fStmts().list.all(site.slug);
864 if (!followers.length) return;
865 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
866 const keys = getOrCreateKeys(site.slug);
867 const me = actorId(base, site.slug);
868 const keyId = `${me}#main-key`;
869 const featured = `${me}/featured`;
870 const AS = 'https://www.w3.org/ns/activitystreams';
871 const note = (id) => noteId(base, id);
872 const pinned = db.prepare(
873 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
874 AND pinned IS NOT NULL AND pinned > 0
875 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
876 ).all(site.id);
877 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
878 // 1. Remove every current pin so Mastodon can recreate them in order.
879 for (const id of removeIds) {
880 const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
881 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
882 }
883 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
884 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
885 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
886 for (const p of pinned) {
887 const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
888 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
889 await new Promise((r) => setTimeout(r, 2000));
890 }
891 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
892}
893
894// ── outbound replies (Klonkt → fediverse) ─────────────────────────
895const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
896const 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(); };
897
898// Build one of OUR outbound reply Notes from an ap_outbox row.
899// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
900function linkHashtags(base, html) {
901 return String(html || '').replace(/(^|[\s>])#([A-Za-z0-9_]+)/g, (m, pre, tag) =>
902 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
903}
904// Extract the AP Hashtag tag objects from already-linked reply content.
905function hashtagTags(base, content) {
906 const tags = [], seen = new Set();
907 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([A-Za-z0-9_]+)</gi;
908 let m;
909 while ((m = re.exec(content || ''))) {
910 const k = m[1].toLowerCase();
911 if (seen.has(k)) continue; seen.add(k);
912 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
913 }
914 return tags;
915}
916
917// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
918// Hashtag tag list (with hrefs to our /tag page).
919function buildHashtagList(base, tagsField, content) {
920 const out = [], seen = new Set();
921 for (const t of (Array.isArray(tagsField) ? tagsField : [])) {
922 const name = String(t).replace(/\s+/g, ''); if (!name) continue;
923 const k = name.toLowerCase(); if (seen.has(k)) continue; seen.add(k);
924 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + name });
925 }
926 for (const h of hashtagTags(base, content)) {
927 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
928 out.push(h);
929 }
930 return out;
931}
932
933export function buildReplyNote(base, site, row) {
934 const me = actorId(base, site.slug);
935 return {
936 id: noteId(base, row.id),
937 type: 'Note',
938 attributedTo: me,
939 inReplyTo: row.in_reply_to || undefined,
940 content: row.content,
941 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
942 published: toISO(row.created_at),
943 to: row.to_actor ? [row.to_actor] : [PUBLIC],
944 cc: [PUBLIC, `${me}/followers`],
945 tag: [
946 ...(row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : []),
947 ...hashtagTags(base, row.content),
948 ],
949 };
950}
951
952// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
953export function getOutboxNote(base, id) {
954 const row = iStmts().getO.get(id);
955 if (!row) return null;
956 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
957 if (!site) return null;
958 return buildReplyNote(base, site, row);
959}
960
961// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
962// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
963export async function deliverReply(site, { postId, postSlug, parent, text }) {
964 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
965 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
966 const me = actorId(base, site.slug);
967 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
968 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
969 const mention = parent.actor_uri
970 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
971 const content = `<p>${mention}${linkHashtags(base, body)}</p>`;
972 // Dedup: skip if the exact same reply was already sent (double-submit guard).
973 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
974 .get(site.slug, parent.object_uri || '', content);
975 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
976 const id = crypto.randomUUID();
977 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
978 const row = iStmts().getO.get(id);
979 const note = buildReplyNote(base, site, row);
980 const create = {
981 '@context': 'https://www.w3.org/ns/activitystreams',
982 id: note.id + '#create', type: 'Create', actor: me,
983 published: note.published, to: note.to, cc: note.cc, object: note,
984 };
985 const keys = getOrCreateKeys(site.slug);
986 const keyId = `${me}#main-key`;
987 const inboxes = new Set();
988 if (parent.actor_uri) {
989 const a = await fetchActor(parent.actor_uri).catch(() => null);
990 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
991 }
992 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
993 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
994 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
995 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
996 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
997 let delivered = 0;
998 for (const inbox of [...inboxes].filter(Boolean)) {
999 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1000 }
1001 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
1002 return { id, content, delivered };
1003}
1004
1005// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
1006// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
1007function actorUriOf(att) {
1008 if (!att) return null;
1009 if (typeof att === 'string') return att;
1010 if (Array.isArray(att)) {
1011 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
1012 if (person) return person.id;
1013 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
1014 return null;
1015 }
1016 return att.id || null;
1017}
1018
1019// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
1020// Returns a parent-shaped object usable by deliverReply(), or null.
1021export async function resolveRemoteNote(url) {
1022 if (!/^https?:\/\//i.test(String(url || ''))) return null;
1023 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
1024 if (!note || !note.id) return null;
1025 const att = note.attributedTo;
1026 const actorUri = actorUriOf(att);
1027 if (!actorUri) return null;
1028 const actor = await fetchActor(actorUri).catch(() => null);
1029 const ai = actorInfo(actor, actorUri);
1030 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
1031 // link our reply to that local post so it shows nested in the post thread.
1032 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
1033 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
1034 // and collect every ancestor author's inbox, so each participant's server —
1035 // including the original post's author — receives + threads our reply.
1036 const threadInboxes = [];
1037 const seenInbox = new Set();
1038 let cursor = note.inReplyTo, guard = 0;
1039 while (cursor && guard++ < 6) {
1040 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
1041 if (!url) break;
1042 const pn = await fetchActor(url).catch(() => null);
1043 if (!pn) break;
1044 const pa = actorUriOf(pn.attributedTo);
1045 if (pa && pa !== actorUri) {
1046 const paDoc = await fetchActor(pa).catch(() => null);
1047 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
1048 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
1049 }
1050 cursor = pn.inReplyTo; // climb to the next ancestor
1051 }
1052 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
1053 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
1054 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1055 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
1056 const images = (Array.isArray(note.attachment) ? note.attachment : [])
1057 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
1058 .map((a) => safeUrl(a.url)).filter(Boolean);
1059 return {
1060 object_uri: safeUrl(note.id) || note.id,
1061 actor_uri: actorUri,
1062 actor_url: ai.url,
1063 actor_handle: ai.handle,
1064 actor_name: ai.name,
1065 actor_icon: ai.icon,
1066 url: note.url || url,
1067 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
1068 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
1069 cw: note.summary || '',
1070 images,
1071 threadInboxes, // every ancestor author's inbox
1072 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
1073 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1074 };
1075}
1076
1077// List a site's own outbound fediverse replies (for the manage/delete view).
1078// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1079// so the manage view can prefill an edit box; the mention is re-added on save.
1080function outboxEditableText(content) {
1081 return String(content || '')
1082 .replace(/<br\s*\/?>/gi, '\n')
1083 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1084 .replace(/<[^>]+>/g, '')
1085 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1086 .trim();
1087}
1088export function listOutbox(siteSlug) {
1089 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1090 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1091}
1092
1093// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1094export async function deliverOutboxDelete(site, outboxId) {
1095 const row = iStmts().getO.get(outboxId);
1096 if (!row || row.site_slug !== site.slug) return false;
1097 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1098 if (base) {
1099 const me = actorId(base, site.slug);
1100 const nid = noteId(base, row.id);
1101 const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
1102 const keys = getOrCreateKeys(site.slug);
1103 const inboxes = new Set();
1104 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1105 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1106 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1107 }
1108 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1109 return true;
1110}
1111
1112// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1113// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1114export async function deliverOutboxUpdate(site, outboxId, newText) {
1115 const row = iStmts().getO.get(outboxId);
1116 if (!row || row.site_slug !== site.slug) return false;
1117 const text = String(newText || '').trim();
1118 if (!text) return false;
1119 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1120 if (!base) return false;
1121 const me = actorId(base, site.slug);
1122 const mention = row.to_actor
1123 ? `<a href="${escHtml(row.to_actor)}" class="u-url mention">${escHtml(row.to_handle || deriveHandle(row.to_actor))}</a> ` : '';
1124 const body = escHtml(text).replace(/\r?\n/g, '<br>');
1125 const content = `<p>${mention}${linkHashtags(base, body)}</p>`;
1126 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1127 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1128 note.updated = new Date().toISOString();
1129 const update = {
1130 '@context': 'https://www.w3.org/ns/activitystreams',
1131 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1132 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1133 };
1134 const keys = getOrCreateKeys(site.slug);
1135 const inboxes = new Set();
1136 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1137 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1138 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1139 let delivered = 0;
1140 for (const inbox of [...inboxes].filter(Boolean)) {
1141 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1142 }
1143 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1144 return { ok: true, content, delivered };
1145}
1146
1147// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1148// Resolve an @user@domain handle to its actor URL via WebFinger.
1149export async function webfingerResolve(handle) {
1150 const h = String(handle || '').trim().replace(/^@/, '');
1151 const parts = h.split('@');
1152 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1153 const acct = `${parts[0]}@${parts[1]}`;
1154 try {
1155 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1156 { headers: { Accept: 'application/jrd+json, application/json' } });
1157 if (!r.ok) return null;
1158 const jrd = await r.json();
1159 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1160 return safeUrl(link ? link.href : '') || null;
1161 } catch { return null; }
1162}
1163
1164let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1165function fwStmts() {
1166 if (!_insFw) {
1167 _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, auto_boost, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
1168 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1169 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1170 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1171 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1172 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1173 }
1174 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1175}
1176export function listFollowing(slug) { return fwStmts().list.all(slug); }
1177
1178// Toggle auto-boost ("feature") on an account we already follow.
1179export function setAutoBoost(slug, actorUri, on) {
1180 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1181 return { ok: true };
1182}
1183
1184let _insTl, _listTl, _delTl;
1185function tlStmts() {
1186 if (!_insTl) {
1187 _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, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
1188 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1189 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1190 }
1191 return { ins: _insTl, list: _listTl, del: _delTl };
1192}
1193export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1194
1195// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1196let _abCount, _cirkelPosts, _cirkelMembers;
1197export function autoBoostCount(slug) {
1198 try { if (!_abCount) _abCount = db.prepare('SELECT COUNT(*) AS n FROM ap_following WHERE slug = ? AND auto_boost = 1'); return _abCount.get(slug).n; } catch { return 0; }
1199}
1200export function getCirkelPosts(slug, limit) {
1201 try {
1202 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1203 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1204 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1205 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1206 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1207 FROM ap_timeline t
1208 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1209 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1210 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1211 LIMIT ?`);
1212 return _cirkelPosts.all(slug, limit || 60);
1213 } catch { return []; }
1214}
1215export function getCirkelMembers(slug) {
1216 try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
1217}
1218// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1219let _markBoost, _unmarkBoost, _boostedCount;
1220export function markBoosted(slug, noteId) {
1221 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1222}
1223export function unmarkBoosted(slug, noteId) {
1224 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1225}
1226let _markLike, _unmarkLike;
1227export function markLiked(slug, noteId) {
1228 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1229}
1230export function unmarkLiked(slug, noteId) {
1231 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1232}
1233export function getTimelineReaction(slug, noteId) {
1234 try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
1235}
1236// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1237// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1238// the Cirkel with a Boost badge, then flag it boosted.
1239export function upsertBoostedNote(slug, note) {
1240 if (!slug || !note || !note.object_uri) return;
1241 const id = note.object_uri;
1242 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1243 try {
1244 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1245 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1246 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1247 } catch { /* ignore */ }
1248 markBoosted(slug, id);
1249}
1250export function boostedCount(slug) {
1251 try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
1252}
1253
1254// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1255// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1256// followActor for bare-domain follows.
1257// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1258// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1259// never throws anything into the fediverse automatically" (would surprise-Follow
1260// for some operators at scale). The dead circle_links table stays as harmless dead
1261// data; an operator restores an old cirkel by re-following in /following (their click).
1262async function resolveApActor(siteUrl) {
1263 try {
1264 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1265 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1266 if (r.ok) return siteUrl;
1267 } catch { /* unreachable */ }
1268 return null;
1269}
1270
1271// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1272// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1273// note and refreshes content + media (recovers covers/edits that were delivered
1274// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1275// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1276const SELFHEAL_VERSION = 2;
1277async function fetchNoteAP(url) {
1278 try {
1279 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1280 if (r.status === 404 || r.status === 410) return 404;
1281 if (r.ok) return await r.json();
1282 } catch { /* unreachable */ }
1283 return null;
1284}
1285function mediaFromNote(note) {
1286 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1287 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1288 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1289 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1290 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1291 }
1292 return JSON.stringify(atts);
1293}
1294let _selfHealing = false;
1295export async function selfHealTimeline() {
1296 if (_selfHealing) return; _selfHealing = true;
1297 try {
1298 let cur = 0;
1299 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
1300 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1301 let rows = [];
1302 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
1303 let healed = 0;
1304 for (const r of rows) {
1305 try {
1306 const note = await fetchNoteAP(r.id);
1307 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1308 if (!note || typeof note !== 'object') continue;
1309 const html = HtmlSanitizerService.sanitize(note.content || '');
1310 const media = mediaFromNote(note);
1311 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1312 const cw = note.summary || null;
1313 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '')) {
1314 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, r.id);
1315 healed++;
1316 }
1317 } catch { /* per-note best-effort */ }
1318 }
1319 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1320 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1321 } catch { /* never block boot */ } finally { _selfHealing = false; }
1322}
1323
1324// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1325export async function followActor(site, handle, autoBoost = false) {
1326 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1327 if (!base || !site || !site.slug) return { error: 'config' };
1328 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1329 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1330 // resolves to its AP actor, so you can follow a site by just its domain.
1331 const s = String(handle || '').trim();
1332 let actorUrl;
1333 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1334 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1335 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1336 else actorUrl = null;
1337 if (!actorUrl) return { error: 'not_found' };
1338 const actor = await fetchActor(actorUrl).catch(() => null);
1339 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1340 const ai = actorInfo(actor, actor.id);
1341 const me = actorId(base, site.slug);
1342 const keys = getOrCreateKeys(site.slug);
1343 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1344 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1345 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
1346 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
1347 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
1348 console.log('[AP] follow', site.slug, '→', actor.id);
1349 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1350}
1351
1352// Resolve a profile URL or @handle to a followable remote actor (for the
1353// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1354// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1355export async function resolveRemoteActor(input) {
1356 const s = String(input || '').trim();
1357 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1358 if (!actorUrl) return null;
1359 const actor = await fetchActor(actorUrl).catch(() => null);
1360 if (!actor || !actor.id || !actor.inbox) return null;
1361 const ai = actorInfo(actor, actor.id);
1362 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1363}
1364
1365export async function unfollowActor(site, actorUri) {
1366 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1367 const me = actorId(base, site.slug);
1368 const keys = getOrCreateKeys(site.slug);
1369 const row = fwStmts().one.get(site.slug, actorUri);
1370 if (row && row.inbox) {
1371 const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
1372 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
1373 }
1374 fwStmts().del.run(site.slug, actorUri);
1375 return { ok: true };
1376}
1377
1378// Send a Like or Announce (boost) on a remote note FROM this site.
1379export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1380 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1381 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1382 const me = actorId(base, site.slug);
1383 const keys = getOrCreateKeys(site.slug);
1384 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1385 // reblog (matched on actor+object — no record of the original Announce needed).
1386 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1387 let act;
1388 if (kind === 'unboost' || kind === 'unlike') {
1389 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1390 // no record of the original activity needed — Mastodon honours both).
1391 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1392 act = {
1393 '@context': 'https://www.w3.org/ns/activitystreams',
1394 id: `${me}#undo-${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1395 object: { id: `${me}#${inner.toLowerCase()}-${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1396 };
1397 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1398 } else {
1399 const type = kind === 'boost' ? 'Announce' : 'Like';
1400 act = {
1401 '@context': 'https://www.w3.org/ns/activitystreams',
1402 id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
1403 type, actor: me, object: targetNoteId,
1404 };
1405 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1406 }
1407 const inboxes = new Set();
1408 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1409 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1410 let delivered = 0;
1411 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 */ } }
1412 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'delivered', delivered);
1413 return { ok: true, delivered };
1414}
1415
1416// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1417export function getNotifications(slug, limit) {
1418 const out = [];
1419 try {
1420 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)) {
1421 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1422 }
1423 } catch { /* ignore */ }
1424 try {
1425 const rows = db.prepare(`
1426 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1427 p.slug AS post_slug, p.title AS post_title
1428 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1429 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1430 ORDER BY i.created_at DESC LIMIT 80
1431 `).all(slug);
1432 for (const r of rows) out.push({
1433 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1434 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1435 });
1436 } catch { /* ignore */ }
1437 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1438 return out.slice(0, limit || 60);
1439}
1440
1441// ── Blocking / defederation ───────────────────────────────────────
1442let _insBl, _delBl, _listBl;
1443function blStmts() {
1444 if (!_insBl) {
1445 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1446 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1447 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1448 }
1449 return { ins: _insBl, del: _delBl, list: _listBl };
1450}
1451export function listBlocks(slug) { return blStmts().list.all(slug); }
1452
1453// True if an actor (or its whole domain) is blocked anywhere on this instance.
1454export function isBlockedAny(actorUri) {
1455 if (!actorUri) return false;
1456 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1457 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1458 catch { return false; }
1459}
1460
1461function purgeBlocked(kind, target) {
1462 try {
1463 if (kind === 'domain') {
1464 const like = `%//${target}/%`;
1465 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1466 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1467 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1468 } else {
1469 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1470 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1471 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1472 }
1473 } catch { /* best-effort */ }
1474}
1475
1476// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1477export async function blockTarget(site, input) {
1478 const raw = String(input || '').trim();
1479 if (!site || !site.slug || !raw) return { error: 'empty' };
1480 let kind, target, label;
1481 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1482 else if (raw.includes('@')) {
1483 const actorUrl = await webfingerResolve(raw);
1484 if (!actorUrl) return { error: 'not_found' };
1485 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1486 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1487 blStmts().ins.run(site.slug, target, kind, label);
1488 purgeBlocked(kind, target);
1489 console.log('[AP] block', site.slug, kind, target);
1490 return { ok: true, label };
1491}
1492
1493export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1494
1495export default {
1496 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1497 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1498 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1499 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1500 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
1501 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
1502 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
1503 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1504 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1505 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1506};
Note: See TracBrowser for help on using the repository browser.