source: Klonkt/src/services/ActivityPubService.js@ 9910ba1

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

fix(fedi): verify HTTP signatures behind a Host-rewriting proxy

verifyRequest reconstructed the signing string's host line from the raw Host header, which a
reverse proxy that doesn't preserve Host (Apache .htaccess [P] -> backend sees localhost:3000)
makes wrong -> every signed Follow/Like/etc. from another server was rejected as unsigned/invalid
on a proxied instance. Now it tries each candidate host (PUBLIC_BASE_URL host, X-Forwarded-Host,
raw Host) and accepts if the signature verifies against any (an attacker can't forge a match).
Also normalises a leading in the request-target.

  • Property mode set to 100644
File size: 87.2 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 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
232 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
233 // multi-word tags; skip any already present inline in the body.
234 {
235 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
236 const addSeen = new Set();
237 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
238 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
239 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
240 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
241 }
242 const seen = new Set();
243 const attachment = urls.filter(Boolean)
244 .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
245 .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
246
247 const note = {
248 id,
249 type: 'Note',
250 attributedTo: aId,
251 content: titleHtml + body,
252 url: human,
253 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
254 // fan_only = "fans only" → followers-only visibility (delivered to your followers
255 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
256 to: post.fan_only ? [`${aId}/followers`] : [PUBLIC],
257 cc: post.fan_only ? [] : [`${aId}/followers`],
258 tag: buildHashtagList(base, post.tags, body),
259 replies: `${id}/replies`,
260 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
261 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
262 sensitive: !!post.nsfw,
263 };
264 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
265 if (attachment.length) note.attachment = attachment;
266 // Playable-audio posts suppress the cover attachment (player card). Still expose
267 // the cover via AS2 `image` so card/grid consumers (the Klonkt Cirkel) can show
268 // it — Mastodon ignores a Note's `image`, so the player card is unaffected.
269 if (post.cover_image_url && playable) {
270 const cov = abs(post.cover_image_url);
271 if (cov) note.image = { type: 'Image', mediaType: mediaType(cov), url: cov };
272 }
273 return note;
274}
275
276// All reply note URIs on a local post (inbound fediverse replies + our own
277// outbound replies) — backs the Note's `replies` Collection so remote servers
278// can fetch the whole thread.
279export function getReplyUris(base, postId) {
280 const out = [];
281 try {
282 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);
283 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}`);
284 } catch { /* non-fatal */ }
285 return out;
286}
287
288// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
289export function markNotificationsSeen(slug) {
290 try {
291 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")
292 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
293 } catch { /* non-fatal */ }
294}
295export function countUnseenNotifications(slug) {
296 try {
297 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
298 const seen = row ? Date.parse(row.value) : 0;
299 let n = 0;
300 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
301 return n;
302 } catch { return 0; }
303}
304
305export function buildCreate(base, site, post) {
306 const note = buildNote(base, site, post);
307 return {
308 '@context': 'https://www.w3.org/ns/activitystreams',
309 id: note.id + '#create',
310 type: 'Create',
311 actor: actorId(base, site.slug),
312 published: note.published,
313 to: note.to,
314 cc: note.cc,
315 object: note,
316 };
317}
318
319export function buildOutbox(base, site, posts) {
320 const id = `${actorId(base, site.slug)}/outbox`;
321 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
322 return {
323 '@context': 'https://www.w3.org/ns/activitystreams',
324 id,
325 type: 'OrderedCollection',
326 totalItems: items.length,
327 orderedItems: items,
328 };
329}
330
331export function buildFollowers(base, site, count) {
332 const id = `${actorId(base, site.slug)}/followers`;
333 return {
334 '@context': 'https://www.w3.org/ns/activitystreams',
335 id,
336 type: 'OrderedCollection',
337 totalItems: count || 0,
338 orderedItems: [], // hidden for privacy; count only
339 };
340}
341
342// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
343// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
344// rank; embedded as full Notes so a remote server doesn't need extra fetches.
345export function buildFeatured(base, site, posts) {
346 const id = `${actorId(base, site.slug)}/featured`;
347 const items = (posts || []).map((p) => buildNote(base, site, p));
348 return {
349 '@context': 'https://www.w3.org/ns/activitystreams',
350 id,
351 type: 'OrderedCollection',
352 totalItems: items.length,
353 orderedItems: items,
354 };
355}
356
357// ── followers store (lazy stmts) ──────────────────────────────────
358let _insF, _delF, _listF, _cntF;
359function fStmts() {
360 if (!_insF) {
361 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
362 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
363 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
364 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
365 }
366 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
367}
368export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
369
370// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
371let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
372function iStmts() {
373 if (!_insI) {
374 _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)');
375 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
376 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
377 _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');
378 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
379 _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)');
380 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
381 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
382 }
383 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
384}
385
386export function getInteractionById(id) { return iStmts().getI.get(id); }
387export function setInteractionBoosted(id, on) {
388 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
389}
390export function setInteractionLiked(id, on) {
391 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
392}
393// Your like/boost state on a REMOTE post (interact page toggles).
394export function setMyReaction(slug, uri, kind, on) {
395 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
396 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
397}
398export function getMyReactions(slug, uri) {
399 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
400 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
401}
402
403const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
404// Extract our local post id from a note URL, but only if it's ours (base match).
405function postIdFromNoteUrl(url, base) {
406 const s = String(url || '');
407 if (base && !s.startsWith(base)) return null;
408 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
409 return m ? decodeURIComponent(m[1]) : null;
410}
411function deriveHandle(actorUri) {
412 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
413}
414function actorInfo(doc, actorUri) {
415 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
416 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
417 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
418 return {
419 name: (doc && (doc.name || doc.preferredUsername)) || handle,
420 handle,
421 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
422 icon: safeUrl(icon) || null,
423 };
424}
425
426// Given an inReplyTo note URL, find which local post the thread belongs to + the
427// note being replied to (parent), so a reply-to-a-comment can be nested.
428function findThreadTarget(inReplyTo, base) {
429 if (!inReplyTo) return null;
430 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
431 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
432 if (seg) {
433 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 */ }
434 }
435 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 */ }
436 return null;
437}
438
439// Drop the leading @mention(s) a federated reply carries (the person being replied to),
440// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
441// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
442export function stripLeadingMentions(html) {
443 if (!html) return html;
444 let s = String(html);
445 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
446 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
447 return s;
448}
449
450// View-ready threaded view of a post's fediverse activity (inbound replies +
451// our outbound replies, nested), plus like/boost counts.
452export function getInteractions(postId, base, site) {
453 const s = iStmts();
454 const rows = s.list.all(postId);
455 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
456 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
457 // Our own (outbound) replies show the SITE identity for everyone (not "You").
458 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
459 const siteName = (site && (site.title || site.slug)) || '';
460 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
461 const siteUrl = baseClean ? `${baseClean}/` : '';
462 const siteIcon = (site && site.profile_photo) || null;
463
464 const nodes = [];
465 for (const r of rows) {
466 if (r.kind !== 'reply') continue;
467 nodes.push({
468 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
469 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
470 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
471 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
472 children: [],
473 });
474 }
475 for (const o of s.listO.all(postId)) {
476 nodes.push({
477 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
478 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
479 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
480 children: [],
481 });
482 }
483
484 const byId = new Map(nodes.map((n) => [n.noteId, n]));
485 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
486 const tops = [];
487 for (const n of nodes) {
488 if (isTop(n)) { tops.push(n); continue; }
489 let anc = n, guard = 0;
490 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
491 anc.children.push(n);
492 }
493 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
494 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
495
496 return {
497 thread: tops,
498 likeCount: rows.filter((r) => r.kind === 'like').length,
499 announceCount: rows.filter((r) => r.kind === 'announce').length,
500 total: nodes.length,
501 };
502}
503
504// ── HTTP Signatures + delivery ────────────────────────────────────
505const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
506
507// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
508export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
509 const body = JSON.stringify(bodyObj);
510 const u = new URL(inboxUrl);
511 const date = new Date().toUTCString();
512 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
513 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
514 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
515 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
516 const r = await safeFetch(inboxUrl, {
517 method: 'POST',
518 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
519 body,
520 });
521 return r.status;
522}
523
524export async function fetchActor(url) {
525 try {
526 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
527 if (!r.ok) return null;
528 const len = Number(r.headers.get('content-length') || 0);
529 if (len > 2_000_000) return null; // refuse oversized actor docs
530 return await r.json();
531 } catch { return null; }
532}
533
534// ── Delivery queue with retries ───────────────────────────────────
535// Outbound deliveries are tried immediately; on failure (down server, timeout,
536// non-2xx) they're queued and retried with backoff so a briefly-offline follower
537// doesn't silently miss the post. The signing key is NOT stored — the worker
538// re-derives it from the actor slug at send time.
539const DELIVERY_MAX_ATTEMPTS = 6;
540const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
541let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
542function deliveryStmts() {
543 if (!_insDeliv) {
544 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
545 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
546 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
547 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
548 }
549 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
550}
551export function enqueueDelivery(slug, inbox, activity) {
552 if (!slug || !inbox || !activity) return;
553 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
554}
555// Deliver now; queue for retry if it fails.
556export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
557 if (!inbox) return;
558 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
559 enqueueDelivery(slug, inbox, activity);
560}
561let _processingDeliv = false;
562export async function processDeliveryQueue() {
563 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
564 _processingDeliv = true;
565 try {
566 let rows;
567 try { rows = deliveryStmts().due.all(); } catch { return; }
568 if (!rows || !rows.length) return;
569 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
570 for (const row of rows) {
571 let ok = false;
572 try {
573 const keys = getOrCreateKeys(row.slug);
574 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
575 ok = st >= 200 && st < 300;
576 } catch { ok = false; }
577 if (ok) { deliveryStmts().del.run(row.id); continue; }
578 const attempts = row.attempts + 1;
579 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
580 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
581 // retry uses the 1-min tier instead of skipping it.
582 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
583 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
584 }
585 } finally { _processingDeliv = false; }
586}
587let _delivTimer = null;
588export function startDeliveryWorker() {
589 if (_delivTimer) return;
590 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
591 if (_delivTimer.unref) _delivTimer.unref();
592}
593
594// Best-effort verification of an incoming signed request. Returns the sender's
595// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
596export async function verifyRequest(req) {
597 const sigH = req.headers['signature'];
598 if (!sigH) return null;
599 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
600 if (!p.keyId || !p.signature) return null;
601 const actor = await fetchActor(p.keyId.split('#')[0]);
602 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
603 if (!pem) return null;
604 const hs = (p.headers || '(request-target) host date').split(/\s+/);
605 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
606 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
607 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
608 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
609 // against any. An attacker can't forge a match (no private key), so this only rescues the
610 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
611 let _pubHost = null;
612 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
613 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
614 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
615 const _sig = Buffer.from(p.signature, 'base64');
616 let ok = false;
617 for (const _h of _hosts) {
618 const line = hs.map((x) => x === '(request-target)'
619 ? `(request-target): ${_target}`
620 : x === 'host' ? `host: ${_h}`
621 : `${x}: ${req.headers[x] || ''}`).join('\n');
622 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
623 }
624 if (ok && hs.includes('digest') && req.rawBody) {
625 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
626 if (req.headers['digest'] !== exp) ok = false;
627 }
628 return ok ? actor : null;
629}
630
631// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
632export async function handleInbox(req, slugParam) {
633 const act = req.body || {};
634 const type = act.type;
635 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
636 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
637 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
638 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
639 const verified = await verifyRequest(req).catch(() => null);
640
641 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
642 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
643 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
644 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
645 // Blocked actor/domain → silently drop (202, don't reveal the block).
646 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
647 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update'];
648 if (GATED.includes(type)) {
649 if (!verified || !claimedActor || verified.id !== claimedActor) {
650 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
651 return 401;
652 }
653 }
654
655 if (type === 'Follow') {
656 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
657 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
658 if (!who || !slug) return 400;
659 const remote = await fetchActor(who);
660 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
661 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
662 fStmts().ins.run(slug, who, remote.inbox, sharedInbox);
663 const me = actorId(base, slug);
664 const keys = getOrCreateKeys(slug);
665 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
666 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
667 // Auto-backfill: send our recent posts as Create so the instance has our history
668 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
669 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
670 // a follower of ours is wasted work (and won't re-populate the new follower's
671 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
672 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
673 const instanceFilled = sharedInbox &&
674 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
675 .get(slug, sharedInbox, who);
676 if (!instanceFilled) {
677 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
678 }
679 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
680 return 202;
681 }
682 if (type === 'Undo' && act.object) {
683 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
684 const ot = act.object.type;
685 if (ot === 'Follow') {
686 const obj = act.object.object;
687 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
688 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
689 return 202;
690 }
691 if (ot === 'Like' || ot === 'Announce') {
692 const tgt = act.object.object;
693 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
694 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
695 return 202;
696 }
697 return 202;
698 }
699
700 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
701 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
702 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
703 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
704
705 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
706 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
707 const o = act.object;
708 const tgt = findThreadTarget(o.inReplyTo, base);
709 if (tgt && actorUri && !isLocalActor) {
710 const ai = actorInfo(await resolveActor(actorUri), actorUri);
711 const html = HtmlSanitizerService.sanitize(o.content || '');
712 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);
713 console.log('[AP] reply', actorUri, '→', tgt.post_id);
714 return 202;
715 }
716 // Home timeline (client): a top-level post from an account we follow.
717 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
718 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
719 if (subs.length) {
720 const ai = actorInfo(await resolveActor(actorUri), actorUri);
721 const html = HtmlSanitizerService.sanitize(o.content || '');
722 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
723 // Fallback cover: a Note's `image` (set when the attachment was suppressed
724 // for a player-card post, e.g. hosted-audio posts).
725 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
726 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
727 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
728 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
729 }
730 const media = JSON.stringify(_atts);
731 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
732 // incoming posts to the fediverse — that flooded followers. Boosting to the
733 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
734 // the timeline).
735 for (const s of subs) {
736 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);
737 }
738 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
739 }
740 }
741 return 202;
742 }
743 if (type === 'Like' || type === 'Announce') {
744 const tgt = act.object;
745 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
746 const pid = postIdFromNoteUrl(objUrl, base);
747 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
748 const ai = actorInfo(await resolveActor(actorUri), actorUri);
749 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
750 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
751 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
752 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
753 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
754 // re-announcing an incoming Announce would cascade boosts across the network).
755 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
756 if (subs.length) {
757 const bn = await fetchNoteAP(objUrl);
758 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
759 const origUri = actorUriOf(bn.attributedTo);
760 const oai = actorInfo(await resolveActor(origUri), origUri);
761 const html = HtmlSanitizerService.sanitize(bn.content || '');
762 const media = mediaFromNote(bn);
763 const booster = actorInfo(await resolveActor(actorUri), actorUri);
764 for (const s of subs) {
765 // published = now → the boost shows as fresh activity at the top (Mastodon shows
766 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
767 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
768 let inserted = false;
769 try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, bn.summary || null); inserted = r.changes > 0; } catch { /* ignore */ }
770 if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, s.slug, bn.id); } catch { /* ignore */ } }
771 }
772 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
773 }
774 }
775 }
776 return 202;
777 }
778 if (type === 'Delete') {
779 // A remote note was deleted upstream → drop it from replies AND the timeline.
780 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
781 // signature gate guarantees claimedActor == the verified signer here).
782 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
783 if (oid && claimedActor) {
784 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
785 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
786 }
787 return 202;
788 }
789 // Accept/Reject of a Follow WE sent (client side).
790 if (type === 'Accept' && act.object) {
791 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
792 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
793 console.log('[AP] follow accepted', actorUri);
794 return 202;
795 }
796 if (type === 'Reject' && act.object) {
797 const who = actorUri;
798 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
799 return 202;
800 }
801
802 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
803 return 202;
804}
805
806// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
807// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
808export async function deliverCreate(site, post) {
809 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
810 if (!base || !site || !site.slug) return;
811 const followers = fStmts().list.all(site.slug);
812 if (!followers.length) return;
813 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
814 const keys = getOrCreateKeys(site.slug);
815 const keyId = `${actorId(base, site.slug)}#main-key`;
816 const create = buildCreate(base, site, post);
817 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
818}
819
820// On a new Follow, send that follower our most recent posts as Create so their
821// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
822// so they sort into the follower's timeline at their original dates.
823async function backfillNewFollower(base, slug, inbox) {
824 if (!base || !slug || !inbox) return;
825 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
826 if (!site) return;
827 const recent = db.prepare(
828 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
829 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
830 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
831 ).all(site.id).reverse();
832 if (!recent.length) return;
833 const keys = getOrCreateKeys(slug);
834 const keyId = `${actorId(base, slug)}#main-key`;
835 for (const p of recent) {
836 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
837 await new Promise((r) => setTimeout(r, 150));
838 }
839 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
840}
841
842// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
843export async function deliverDelete(site, post) {
844 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
845 if (!base || !site || !site.slug || !post || !post.id) return;
846 const followers = fStmts().list.all(site.slug);
847 if (!followers.length) return;
848 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
849 const keys = getOrCreateKeys(site.slug);
850 const me = actorId(base, site.slug);
851 const nid = noteId(base, post.id);
852 const del = {
853 '@context': 'https://www.w3.org/ns/activitystreams',
854 id: `${nid}#delete-${Date.now()}-${rid()}`,
855 type: 'Delete',
856 actor: me,
857 to: [PUBLIC],
858 object: { id: nid, type: 'Tombstone' },
859 };
860 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
861}
862
863// Tell followers an already-published post changed (Update + edited Note) so
864// Mastodon refreshes the cached copy (e.g. after fixing content).
865export async function deliverUpdate(site, post) {
866 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
867 if (!base || !site || !site.slug || !post || !post.id) return;
868 const followers = fStmts().list.all(site.slug);
869 if (!followers.length) return;
870 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
871 const keys = getOrCreateKeys(site.slug);
872 const me = actorId(base, site.slug);
873 const note = buildNote(base, site, post);
874 note.updated = new Date().toISOString();
875 const update = {
876 '@context': 'https://www.w3.org/ns/activitystreams',
877 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
878 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
879 object: note,
880 };
881 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
882}
883
884// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
885// account AND re-fetches the featured (pinned) collection — there is no standard
886// "featured changed" activity, so this is how a pin/unpin propagates promptly.
887export async function deliverActorUpdate(site) {
888 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
889 if (!base || !site || !site.slug) return;
890 const followers = fStmts().list.all(site.slug);
891 if (!followers.length) return;
892 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
893 const keys = getOrCreateKeys(site.slug);
894 const me = actorId(base, site.slug);
895 const update = {
896 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
897 id: `${me}#update-${Date.now()}-${rid()}`,
898 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
899 object: buildActor(base, site),
900 };
901 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
902}
903
904// Reliably set the pinned order on followers' instances via Add/Remove activities
905// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
906// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
907// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
908// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
909// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
910export async function resyncFeaturedPins(site, alsoRemove = []) {
911 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
912 if (!base || !site || !site.slug) return;
913 const followers = fStmts().list.all(site.slug);
914 if (!followers.length) return;
915 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
916 const keys = getOrCreateKeys(site.slug);
917 const me = actorId(base, site.slug);
918 const keyId = `${me}#main-key`;
919 const featured = `${me}/featured`;
920 const AS = 'https://www.w3.org/ns/activitystreams';
921 const note = (id) => noteId(base, id);
922 const pinned = db.prepare(
923 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
924 AND pinned IS NOT NULL AND pinned > 0
925 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
926 ).all(site.id);
927 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
928 // 1. Remove every current pin so Mastodon can recreate them in order.
929 for (const id of removeIds) {
930 const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
931 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
932 }
933 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
934 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
935 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
936 for (const p of pinned) {
937 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`] };
938 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
939 await new Promise((r) => setTimeout(r, 2000));
940 }
941 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
942}
943
944// ── outbound replies (Klonkt → fediverse) ─────────────────────────
945const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
946const 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(); };
947
948// Build one of OUR outbound reply Notes from an ap_outbox row.
949// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
950function linkHashtags(base, html) {
951 return String(html || '').replace(/(^|[\s>])#([A-Za-z0-9_]+)/g, (m, pre, tag) =>
952 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
953}
954// Extract the AP Hashtag tag objects from already-linked reply content.
955function hashtagTags(base, content) {
956 const tags = [], seen = new Set();
957 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([A-Za-z0-9_]+)</gi;
958 let m;
959 while ((m = re.exec(content || ''))) {
960 const k = m[1].toLowerCase();
961 if (seen.has(k)) continue; seen.add(k);
962 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
963 }
964 return tags;
965}
966
967// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
968function normalizeTags(t) {
969 if (Array.isArray(t)) return t;
970 if (typeof t === 'string') {
971 const s = t.trim(); if (!s) return [];
972 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
973 return s.split(',').map((x) => x.trim()).filter(Boolean);
974 }
975 return [];
976}
977// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
978// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
979// slug/href stays lowercase ("livemusic").
980function tagParts(raw) {
981 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^A-Za-z0-9]/g, '')).filter(Boolean);
982 if (!words.length) return null;
983 const slug = words.join('').toLowerCase();
984 if (!slug) return null;
985 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
986 return { label, slug };
987}
988// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
989// Hashtag tag list (with hrefs to our /tag page).
990function buildHashtagList(base, tagsField, content) {
991 const out = [], seen = new Set();
992 for (const t of normalizeTags(tagsField)) {
993 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
994 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
995 }
996 for (const h of hashtagTags(base, content)) {
997 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
998 out.push(h);
999 }
1000 return out;
1001}
1002
1003// Extract Mention tag objects from already-linked content (class="u-url mention").
1004function mentionTags(content) {
1005 const tags = [], seen = new Set();
1006 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
1007 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
1008 let m;
1009 while ((m = re.exec(content || ''))) {
1010 const href = m[1];
1011 if (seen.has(href)) continue; seen.add(href);
1012 tags.push({ type: 'Mention', href, name: '@' + m[2] });
1013 }
1014 return tags;
1015}
1016// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
1017// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
1018async function resolveMentionsInText(base, html) {
1019 const inboxes = [];
1020 const handles = new Set();
1021 const re = /(^|[\s>])@([A-Za-z0-9_.-]+@[A-Za-z0-9.-]+)/g;
1022 let m;
1023 while ((m = re.exec(html || ''))) handles.add(m[2]);
1024 let out = String(html || '');
1025 for (const h of handles) {
1026 let actorUri = null;
1027 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
1028 if (!actorUri) continue;
1029 const actor = await fetchActor(actorUri).catch(() => null);
1030 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
1031 if (inbox) inboxes.push(inbox);
1032 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
1033 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1034 out = out.replace(new RegExp('(^|[\\s>])@' + esc + '(?![A-Za-z0-9_.-])', 'g'),
1035 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
1036 }
1037 return { html: out, inboxes };
1038}
1039
1040export function buildReplyNote(base, site, row) {
1041 const me = actorId(base, site.slug);
1042 return {
1043 id: noteId(base, row.id),
1044 type: 'Note',
1045 attributedTo: me,
1046 inReplyTo: row.in_reply_to || undefined,
1047 content: row.content,
1048 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
1049 published: toISO(row.created_at),
1050 to: row.to_actor ? [row.to_actor] : [PUBLIC],
1051 cc: [PUBLIC, `${me}/followers`],
1052 tag: [
1053 ...mentionTags(row.content),
1054 ...hashtagTags(base, row.content),
1055 ],
1056 };
1057}
1058
1059// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
1060export function getOutboxNote(base, id) {
1061 const row = iStmts().getO.get(id);
1062 if (!row) return null;
1063 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
1064 if (!site) return null;
1065 return buildReplyNote(base, site, row);
1066}
1067
1068// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
1069// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
1070export async function deliverReply(site, { postId, postSlug, parent, text }) {
1071 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1072 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
1073 const me = actorId(base, site.slug);
1074 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
1075 const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
1076 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
1077 const mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
1078 const mention = parent.actor_uri
1079 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention" data-actor="${escHtml(parent.actor_uri)}">${escHtml(dispHandle)}</a> ` : '';
1080 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1081 // Dedup: skip if the exact same reply was already sent (double-submit guard).
1082 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
1083 .get(site.slug, parent.object_uri || '', content);
1084 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
1085 const id = crypto.randomUUID();
1086 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
1087 const row = iStmts().getO.get(id);
1088 const note = buildReplyNote(base, site, row);
1089 const create = {
1090 '@context': 'https://www.w3.org/ns/activitystreams',
1091 id: note.id + '#create', type: 'Create', actor: me,
1092 published: note.published, to: note.to, cc: note.cc, object: note,
1093 };
1094 const keys = getOrCreateKeys(site.slug);
1095 const keyId = `${me}#main-key`;
1096 const inboxes = new Set();
1097 if (parent.actor_uri) {
1098 const a = await fetchActor(parent.actor_uri).catch(() => null);
1099 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1100 }
1101 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
1102 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
1103 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1104 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
1105 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
1106 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
1107 let delivered = 0;
1108 for (const inbox of [...inboxes].filter(Boolean)) {
1109 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1110 }
1111 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
1112 return { id, content, delivered };
1113}
1114
1115// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
1116// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
1117function actorUriOf(att) {
1118 if (!att) return null;
1119 if (typeof att === 'string') return att;
1120 if (Array.isArray(att)) {
1121 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
1122 if (person) return person.id;
1123 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
1124 return null;
1125 }
1126 return att.id || null;
1127}
1128
1129// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
1130// Returns a parent-shaped object usable by deliverReply(), or null.
1131export async function resolveRemoteNote(url) {
1132 if (!/^https?:\/\//i.test(String(url || ''))) return null;
1133 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
1134 if (!note || !note.id) return null;
1135 const att = note.attributedTo;
1136 const actorUri = actorUriOf(att);
1137 if (!actorUri) return null;
1138 const actor = await fetchActor(actorUri).catch(() => null);
1139 const ai = actorInfo(actor, actorUri);
1140 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
1141 // link our reply to that local post so it shows nested in the post thread.
1142 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
1143 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
1144 // and collect every ancestor author's inbox, so each participant's server —
1145 // including the original post's author — receives + threads our reply.
1146 const threadInboxes = [];
1147 const seenInbox = new Set();
1148 let cursor = note.inReplyTo, guard = 0;
1149 while (cursor && guard++ < 6) {
1150 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
1151 if (!url) break;
1152 const pn = await fetchActor(url).catch(() => null);
1153 if (!pn) break;
1154 const pa = actorUriOf(pn.attributedTo);
1155 if (pa && pa !== actorUri) {
1156 const paDoc = await fetchActor(pa).catch(() => null);
1157 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
1158 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
1159 }
1160 cursor = pn.inReplyTo; // climb to the next ancestor
1161 }
1162 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
1163 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
1164 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1165 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
1166 const images = (Array.isArray(note.attachment) ? note.attachment : [])
1167 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
1168 .map((a) => safeUrl(a.url)).filter(Boolean);
1169 return {
1170 object_uri: safeUrl(note.id) || note.id,
1171 actor_uri: actorUri,
1172 actor_url: ai.url,
1173 actor_handle: ai.handle,
1174 actor_name: ai.name,
1175 actor_icon: ai.icon,
1176 url: note.url || url,
1177 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
1178 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
1179 cw: note.summary || '',
1180 images,
1181 threadInboxes, // every ancestor author's inbox
1182 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
1183 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1184 };
1185}
1186
1187// List a site's own outbound fediverse replies (for the manage/delete view).
1188// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1189// so the manage view can prefill an edit box; the mention is re-added on save.
1190function outboxEditableText(content) {
1191 return String(content || '')
1192 .replace(/<br\s*\/?>/gi, '\n')
1193 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1194 .replace(/<[^>]+>/g, '')
1195 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1196 .trim();
1197}
1198export function listOutbox(siteSlug) {
1199 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1200 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1201}
1202
1203// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1204export async function deliverOutboxDelete(site, outboxId) {
1205 const row = iStmts().getO.get(outboxId);
1206 if (!row || row.site_slug !== site.slug) return false;
1207 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1208 if (base) {
1209 const me = actorId(base, site.slug);
1210 const nid = noteId(base, row.id);
1211 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' } };
1212 const keys = getOrCreateKeys(site.slug);
1213 const inboxes = new Set();
1214 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1215 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1216 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1217 }
1218 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1219 return true;
1220}
1221
1222// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1223// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1224export async function deliverOutboxUpdate(site, outboxId, newText) {
1225 const row = iStmts().getO.get(outboxId);
1226 if (!row || row.site_slug !== site.slug) return false;
1227 const text = String(newText || '').trim();
1228 if (!text) return false;
1229 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1230 if (!base) return false;
1231 const me = actorId(base, site.slug);
1232 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
1233 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
1234 const _h = row.to_handle || deriveHandle(row.to_actor);
1235 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
1236 const mention = row.to_actor
1237 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
1238 const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
1239 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1240 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1241 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1242 note.updated = new Date().toISOString();
1243 const update = {
1244 '@context': 'https://www.w3.org/ns/activitystreams',
1245 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1246 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1247 };
1248 const keys = getOrCreateKeys(site.slug);
1249 const inboxes = new Set();
1250 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
1251 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1252 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
1253 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1254 let delivered = 0;
1255 for (const inbox of [...inboxes].filter(Boolean)) {
1256 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1257 }
1258 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1259 return { ok: true, content, delivered };
1260}
1261
1262// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1263// Resolve an @user@domain handle to its actor URL via WebFinger.
1264export async function webfingerResolve(handle) {
1265 const h = String(handle || '').trim().replace(/^@/, '');
1266 const parts = h.split('@');
1267 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1268 const acct = `${parts[0]}@${parts[1]}`;
1269 try {
1270 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1271 { headers: { Accept: 'application/jrd+json, application/json' } });
1272 if (!r.ok) return null;
1273 const jrd = await r.json();
1274 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1275 return safeUrl(link ? link.href : '') || null;
1276 } catch { return null; }
1277}
1278
1279let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1280function fwStmts() {
1281 if (!_insFw) {
1282 _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)');
1283 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1284 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1285 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1286 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1287 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1288 }
1289 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1290}
1291export function listFollowing(slug) { return fwStmts().list.all(slug); }
1292
1293// Toggle auto-boost ("feature") on an account we already follow.
1294export function setAutoBoost(slug, actorUri, on) {
1295 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1296 return { ok: true };
1297}
1298
1299let _insTl, _listTl, _delTl;
1300function tlStmts() {
1301 if (!_insTl) {
1302 _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)');
1303 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1304 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1305 }
1306 return { ins: _insTl, list: _listTl, del: _delTl };
1307}
1308export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1309
1310// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1311let _abCount, _cirkelPosts, _cirkelMembers;
1312export function autoBoostCount(slug) {
1313 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; }
1314}
1315export function getCirkelPosts(slug, limit) {
1316 try {
1317 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1318 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1319 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1320 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1321 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1322 FROM ap_timeline t
1323 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1324 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1325 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1326 LIMIT ?`);
1327 return _cirkelPosts.all(slug, limit || 60);
1328 } catch { return []; }
1329}
1330export function getCirkelMembers(slug) {
1331 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 []; }
1332}
1333// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1334let _markBoost, _unmarkBoost, _boostedCount;
1335export function markBoosted(slug, noteId) {
1336 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1337}
1338export function unmarkBoosted(slug, noteId) {
1339 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1340}
1341let _markLike, _unmarkLike;
1342export function markLiked(slug, noteId) {
1343 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1344}
1345export function unmarkLiked(slug, noteId) {
1346 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1347}
1348export function getTimelineReaction(slug, noteId) {
1349 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 }; }
1350}
1351// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1352// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1353// the Cirkel with a Boost badge, then flag it boosted.
1354export function upsertBoostedNote(slug, note) {
1355 if (!slug || !note || !note.object_uri) return;
1356 const id = note.object_uri;
1357 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1358 try {
1359 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1360 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1361 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1362 } catch { /* ignore */ }
1363 markBoosted(slug, id);
1364}
1365export function boostedCount(slug) {
1366 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; }
1367}
1368
1369// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1370// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1371// followActor for bare-domain follows.
1372// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1373// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1374// never throws anything into the fediverse automatically" (would surprise-Follow
1375// for some operators at scale). The dead circle_links table stays as harmless dead
1376// data; an operator restores an old cirkel by re-following in /following (their click).
1377async function resolveApActor(siteUrl) {
1378 try {
1379 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1380 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1381 if (r.ok) return siteUrl;
1382 } catch { /* unreachable */ }
1383 return null;
1384}
1385
1386// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1387// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1388// note and refreshes content + media (recovers covers/edits that were delivered
1389// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1390// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1391const SELFHEAL_VERSION = 2;
1392async function fetchNoteAP(url) {
1393 try {
1394 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1395 if (r.status === 404 || r.status === 410) return 404;
1396 if (r.ok) return await r.json();
1397 } catch { /* unreachable */ }
1398 return null;
1399}
1400function mediaFromNote(note) {
1401 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1402 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1403 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1404 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1405 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1406 }
1407 return JSON.stringify(atts);
1408}
1409let _selfHealing = false;
1410export async function selfHealTimeline() {
1411 if (_selfHealing) return; _selfHealing = true;
1412 try {
1413 let cur = 0;
1414 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; }
1415 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1416 let rows = [];
1417 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
1418 let healed = 0;
1419 for (const r of rows) {
1420 try {
1421 const note = await fetchNoteAP(r.id);
1422 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1423 if (!note || typeof note !== 'object') continue;
1424 const html = HtmlSanitizerService.sanitize(note.content || '');
1425 const media = mediaFromNote(note);
1426 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1427 const cw = note.summary || null;
1428 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '')) {
1429 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, r.id);
1430 healed++;
1431 }
1432 } catch { /* per-note best-effort */ }
1433 }
1434 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1435 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1436 } catch { /* never block boot */ } finally { _selfHealing = false; }
1437}
1438
1439// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1440export async function followActor(site, handle, autoBoost = false) {
1441 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1442 if (!base || !site || !site.slug) return { error: 'config' };
1443 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1444 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1445 // resolves to its AP actor, so you can follow a site by just its domain.
1446 const s = String(handle || '').trim();
1447 let actorUrl;
1448 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1449 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1450 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1451 else actorUrl = null;
1452 if (!actorUrl) return { error: 'not_found' };
1453 const actor = await fetchActor(actorUrl).catch(() => null);
1454 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1455 const ai = actorInfo(actor, actor.id);
1456 const me = actorId(base, site.slug);
1457 const keys = getOrCreateKeys(site.slug);
1458 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1459 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1460 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
1461 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
1462 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
1463 console.log('[AP] follow', site.slug, '→', actor.id);
1464 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1465}
1466
1467// Resolve a profile URL or @handle to a followable remote actor (for the
1468// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1469// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1470export async function resolveRemoteActor(input) {
1471 const s = String(input || '').trim();
1472 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1473 if (!actorUrl) return null;
1474 const actor = await fetchActor(actorUrl).catch(() => null);
1475 if (!actor || !actor.id || !actor.inbox) return null;
1476 const ai = actorInfo(actor, actor.id);
1477 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1478}
1479
1480export async function unfollowActor(site, actorUri) {
1481 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1482 const me = actorId(base, site.slug);
1483 const keys = getOrCreateKeys(site.slug);
1484 const row = fwStmts().one.get(site.slug, actorUri);
1485 if (row && row.inbox) {
1486 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 } };
1487 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
1488 }
1489 fwStmts().del.run(site.slug, actorUri);
1490 return { ok: true };
1491}
1492
1493// Send a Like or Announce (boost) on a remote note FROM this site.
1494export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1495 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1496 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1497 const me = actorId(base, site.slug);
1498 const keys = getOrCreateKeys(site.slug);
1499 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1500 // reblog (matched on actor+object — no record of the original Announce needed).
1501 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1502 let act;
1503 if (kind === 'unboost' || kind === 'unlike') {
1504 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1505 // no record of the original activity needed — Mastodon honours both).
1506 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1507 act = {
1508 '@context': 'https://www.w3.org/ns/activitystreams',
1509 id: `${me}#undo-${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1510 object: { id: `${me}#${inner.toLowerCase()}-${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1511 };
1512 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1513 } else {
1514 const type = kind === 'boost' ? 'Announce' : 'Like';
1515 act = {
1516 '@context': 'https://www.w3.org/ns/activitystreams',
1517 id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
1518 type, actor: me, object: targetNoteId,
1519 };
1520 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1521 }
1522 const inboxes = new Set();
1523 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1524 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1525 let delivered = 0;
1526 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 */ } }
1527 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'delivered', delivered);
1528 return { ok: true, delivered };
1529}
1530
1531// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1532export function getNotifications(slug, limit) {
1533 const out = [];
1534 try {
1535 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)) {
1536 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1537 }
1538 } catch { /* ignore */ }
1539 try {
1540 const rows = db.prepare(`
1541 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1542 p.slug AS post_slug, p.title AS post_title
1543 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1544 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1545 ORDER BY i.created_at DESC LIMIT 80
1546 `).all(slug);
1547 for (const r of rows) out.push({
1548 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1549 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1550 });
1551 } catch { /* ignore */ }
1552 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1553 return out.slice(0, limit || 60);
1554}
1555
1556// ── Blocking / defederation ───────────────────────────────────────
1557let _insBl, _delBl, _listBl;
1558function blStmts() {
1559 if (!_insBl) {
1560 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1561 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1562 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1563 }
1564 return { ins: _insBl, del: _delBl, list: _listBl };
1565}
1566export function listBlocks(slug) { return blStmts().list.all(slug); }
1567
1568// True if an actor (or its whole domain) is blocked anywhere on this instance.
1569export function isBlockedAny(actorUri) {
1570 if (!actorUri) return false;
1571 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1572 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1573 catch { return false; }
1574}
1575
1576function purgeBlocked(kind, target) {
1577 try {
1578 if (kind === 'domain') {
1579 const like = `%//${target}/%`;
1580 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1581 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1582 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1583 } else {
1584 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1585 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1586 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1587 }
1588 } catch { /* best-effort */ }
1589}
1590
1591// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1592export async function blockTarget(site, input) {
1593 const raw = String(input || '').trim();
1594 if (!site || !site.slug || !raw) return { error: 'empty' };
1595 let kind, target, label;
1596 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1597 else if (raw.includes('@')) {
1598 const actorUrl = await webfingerResolve(raw);
1599 if (!actorUrl) return { error: 'not_found' };
1600 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1601 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1602 blStmts().ins.run(site.slug, target, kind, label);
1603 purgeBlocked(kind, target);
1604 console.log('[AP] block', site.slug, kind, target);
1605 return { ok: true, label };
1606}
1607
1608export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1609
1610export default {
1611 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1612 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1613 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1614 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1615 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
1616 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
1617 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
1618 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1619 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1620 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1621};
Note: See TracBrowser for help on using the repository browser.