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

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

feat(news): show boosts from followed accounts in the News feed

An Announce (boost) from an account you follow, of a REMOTE post, was dropped — only Creates
and boosts of YOUR own posts were handled. Now such a boost resolves the boosted note and
stores it in ap_timeline (marked with who boosted it) so it appears in News with a 'X boosted'
label. We only store it for display and NEVER auto-Announce it onward (anti-feedback-loop);
published = now so it surfaces as fresh activity.

  • config/database.js — ap_timeline.reblog_name/reblog_handle/reblog_icon
  • services/ActivityPubService.js — handleInbox Announce branch stores remote boosts from followed accounts
  • views/pages/news.ejs — 'boosted by' label; renamed the page-header div to tl-titlebar to avoid clashing with the card's tl-head class
  • services/i18n.js — tl.boosted (nl/en/de)
  • Property mode set to 100644
File size: 86.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 const line = hs.map((h) => h === '(request-target)'
606 ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
607 : `${h}: ${req.headers[h] || ''}`).join('\n');
608 let ok = false;
609 try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
610 if (ok && hs.includes('digest') && req.rawBody) {
611 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
612 if (req.headers['digest'] !== exp) ok = false;
613 }
614 return ok ? actor : null;
615}
616
617// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
618export async function handleInbox(req, slugParam) {
619 const act = req.body || {};
620 const type = act.type;
621 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
622 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
623 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
624 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
625 const verified = await verifyRequest(req).catch(() => null);
626
627 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
628 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
629 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
630 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
631 // Blocked actor/domain → silently drop (202, don't reveal the block).
632 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
633 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update'];
634 if (GATED.includes(type)) {
635 if (!verified || !claimedActor || verified.id !== claimedActor) {
636 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
637 return 401;
638 }
639 }
640
641 if (type === 'Follow') {
642 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
643 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
644 if (!who || !slug) return 400;
645 const remote = await fetchActor(who);
646 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
647 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
648 fStmts().ins.run(slug, who, remote.inbox, sharedInbox);
649 const me = actorId(base, slug);
650 const keys = getOrCreateKeys(slug);
651 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
652 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
653 // Auto-backfill: send our recent posts as Create so the instance has our history
654 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
655 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
656 // a follower of ours is wasted work (and won't re-populate the new follower's
657 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
658 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
659 const instanceFilled = sharedInbox &&
660 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
661 .get(slug, sharedInbox, who);
662 if (!instanceFilled) {
663 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
664 }
665 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
666 return 202;
667 }
668 if (type === 'Undo' && act.object) {
669 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
670 const ot = act.object.type;
671 if (ot === 'Follow') {
672 const obj = act.object.object;
673 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
674 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
675 return 202;
676 }
677 if (ot === 'Like' || ot === 'Announce') {
678 const tgt = act.object.object;
679 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
680 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
681 return 202;
682 }
683 return 202;
684 }
685
686 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
687 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
688 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
689 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
690
691 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
692 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
693 const o = act.object;
694 const tgt = findThreadTarget(o.inReplyTo, base);
695 if (tgt && actorUri && !isLocalActor) {
696 const ai = actorInfo(await resolveActor(actorUri), actorUri);
697 const html = HtmlSanitizerService.sanitize(o.content || '');
698 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);
699 console.log('[AP] reply', actorUri, '→', tgt.post_id);
700 return 202;
701 }
702 // Home timeline (client): a top-level post from an account we follow.
703 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
704 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
705 if (subs.length) {
706 const ai = actorInfo(await resolveActor(actorUri), actorUri);
707 const html = HtmlSanitizerService.sanitize(o.content || '');
708 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
709 // Fallback cover: a Note's `image` (set when the attachment was suppressed
710 // for a player-card post, e.g. hosted-audio posts).
711 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
712 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
713 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
714 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
715 }
716 const media = JSON.stringify(_atts);
717 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
718 // incoming posts to the fediverse — that flooded followers. Boosting to the
719 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
720 // the timeline).
721 for (const s of subs) {
722 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);
723 }
724 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
725 }
726 }
727 return 202;
728 }
729 if (type === 'Like' || type === 'Announce') {
730 const tgt = act.object;
731 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
732 const pid = postIdFromNoteUrl(objUrl, base);
733 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
734 const ai = actorInfo(await resolveActor(actorUri), actorUri);
735 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
736 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
737 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
738 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
739 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
740 // re-announcing an incoming Announce would cascade boosts across the network).
741 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
742 if (subs.length) {
743 const bn = await fetchNoteAP(objUrl);
744 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
745 const origUri = actorUriOf(bn.attributedTo);
746 const oai = actorInfo(await resolveActor(origUri), origUri);
747 const html = HtmlSanitizerService.sanitize(bn.content || '');
748 const media = mediaFromNote(bn);
749 const booster = actorInfo(await resolveActor(actorUri), actorUri);
750 for (const s of subs) {
751 // published = now → the boost shows as fresh activity at the top (Mastodon shows
752 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
753 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
754 let inserted = false;
755 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 */ }
756 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 */ } }
757 }
758 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
759 }
760 }
761 }
762 return 202;
763 }
764 if (type === 'Delete') {
765 // A remote note was deleted upstream → drop it from replies AND the timeline.
766 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
767 // signature gate guarantees claimedActor == the verified signer here).
768 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
769 if (oid && claimedActor) {
770 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
771 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
772 }
773 return 202;
774 }
775 // Accept/Reject of a Follow WE sent (client side).
776 if (type === 'Accept' && act.object) {
777 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
778 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
779 console.log('[AP] follow accepted', actorUri);
780 return 202;
781 }
782 if (type === 'Reject' && act.object) {
783 const who = actorUri;
784 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
785 return 202;
786 }
787
788 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
789 return 202;
790}
791
792// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
793// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
794export async function deliverCreate(site, post) {
795 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
796 if (!base || !site || !site.slug) return;
797 const followers = fStmts().list.all(site.slug);
798 if (!followers.length) return;
799 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
800 const keys = getOrCreateKeys(site.slug);
801 const keyId = `${actorId(base, site.slug)}#main-key`;
802 const create = buildCreate(base, site, post);
803 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
804}
805
806// On a new Follow, send that follower our most recent posts as Create so their
807// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
808// so they sort into the follower's timeline at their original dates.
809async function backfillNewFollower(base, slug, inbox) {
810 if (!base || !slug || !inbox) return;
811 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
812 if (!site) return;
813 const recent = db.prepare(
814 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
815 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
816 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
817 ).all(site.id).reverse();
818 if (!recent.length) return;
819 const keys = getOrCreateKeys(slug);
820 const keyId = `${actorId(base, slug)}#main-key`;
821 for (const p of recent) {
822 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
823 await new Promise((r) => setTimeout(r, 150));
824 }
825 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
826}
827
828// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
829export async function deliverDelete(site, post) {
830 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
831 if (!base || !site || !site.slug || !post || !post.id) return;
832 const followers = fStmts().list.all(site.slug);
833 if (!followers.length) return;
834 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
835 const keys = getOrCreateKeys(site.slug);
836 const me = actorId(base, site.slug);
837 const nid = noteId(base, post.id);
838 const del = {
839 '@context': 'https://www.w3.org/ns/activitystreams',
840 id: `${nid}#delete-${Date.now()}-${rid()}`,
841 type: 'Delete',
842 actor: me,
843 to: [PUBLIC],
844 object: { id: nid, type: 'Tombstone' },
845 };
846 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
847}
848
849// Tell followers an already-published post changed (Update + edited Note) so
850// Mastodon refreshes the cached copy (e.g. after fixing content).
851export async function deliverUpdate(site, post) {
852 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
853 if (!base || !site || !site.slug || !post || !post.id) return;
854 const followers = fStmts().list.all(site.slug);
855 if (!followers.length) return;
856 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
857 const keys = getOrCreateKeys(site.slug);
858 const me = actorId(base, site.slug);
859 const note = buildNote(base, site, post);
860 note.updated = new Date().toISOString();
861 const update = {
862 '@context': 'https://www.w3.org/ns/activitystreams',
863 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
864 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
865 object: note,
866 };
867 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
868}
869
870// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
871// account AND re-fetches the featured (pinned) collection — there is no standard
872// "featured changed" activity, so this is how a pin/unpin propagates promptly.
873export async function deliverActorUpdate(site) {
874 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
875 if (!base || !site || !site.slug) return;
876 const followers = fStmts().list.all(site.slug);
877 if (!followers.length) return;
878 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
879 const keys = getOrCreateKeys(site.slug);
880 const me = actorId(base, site.slug);
881 const update = {
882 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
883 id: `${me}#update-${Date.now()}-${rid()}`,
884 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
885 object: buildActor(base, site),
886 };
887 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
888}
889
890// Reliably set the pinned order on followers' instances via Add/Remove activities
891// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
892// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
893// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
894// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
895// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
896export async function resyncFeaturedPins(site, alsoRemove = []) {
897 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
898 if (!base || !site || !site.slug) return;
899 const followers = fStmts().list.all(site.slug);
900 if (!followers.length) return;
901 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
902 const keys = getOrCreateKeys(site.slug);
903 const me = actorId(base, site.slug);
904 const keyId = `${me}#main-key`;
905 const featured = `${me}/featured`;
906 const AS = 'https://www.w3.org/ns/activitystreams';
907 const note = (id) => noteId(base, id);
908 const pinned = db.prepare(
909 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
910 AND pinned IS NOT NULL AND pinned > 0
911 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
912 ).all(site.id);
913 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
914 // 1. Remove every current pin so Mastodon can recreate them in order.
915 for (const id of removeIds) {
916 const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
917 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
918 }
919 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
920 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
921 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
922 for (const p of pinned) {
923 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`] };
924 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
925 await new Promise((r) => setTimeout(r, 2000));
926 }
927 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
928}
929
930// ── outbound replies (Klonkt → fediverse) ─────────────────────────
931const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
932const 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(); };
933
934// Build one of OUR outbound reply Notes from an ap_outbox row.
935// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
936function linkHashtags(base, html) {
937 return String(html || '').replace(/(^|[\s>])#([A-Za-z0-9_]+)/g, (m, pre, tag) =>
938 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
939}
940// Extract the AP Hashtag tag objects from already-linked reply content.
941function hashtagTags(base, content) {
942 const tags = [], seen = new Set();
943 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([A-Za-z0-9_]+)</gi;
944 let m;
945 while ((m = re.exec(content || ''))) {
946 const k = m[1].toLowerCase();
947 if (seen.has(k)) continue; seen.add(k);
948 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
949 }
950 return tags;
951}
952
953// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
954function normalizeTags(t) {
955 if (Array.isArray(t)) return t;
956 if (typeof t === 'string') {
957 const s = t.trim(); if (!s) return [];
958 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
959 return s.split(',').map((x) => x.trim()).filter(Boolean);
960 }
961 return [];
962}
963// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
964// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
965// slug/href stays lowercase ("livemusic").
966function tagParts(raw) {
967 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^A-Za-z0-9]/g, '')).filter(Boolean);
968 if (!words.length) return null;
969 const slug = words.join('').toLowerCase();
970 if (!slug) return null;
971 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
972 return { label, slug };
973}
974// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
975// Hashtag tag list (with hrefs to our /tag page).
976function buildHashtagList(base, tagsField, content) {
977 const out = [], seen = new Set();
978 for (const t of normalizeTags(tagsField)) {
979 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
980 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
981 }
982 for (const h of hashtagTags(base, content)) {
983 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
984 out.push(h);
985 }
986 return out;
987}
988
989// Extract Mention tag objects from already-linked content (class="u-url mention").
990function mentionTags(content) {
991 const tags = [], seen = new Set();
992 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
993 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
994 let m;
995 while ((m = re.exec(content || ''))) {
996 const href = m[1];
997 if (seen.has(href)) continue; seen.add(href);
998 tags.push({ type: 'Mention', href, name: '@' + m[2] });
999 }
1000 return tags;
1001}
1002// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
1003// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
1004async function resolveMentionsInText(base, html) {
1005 const inboxes = [];
1006 const handles = new Set();
1007 const re = /(^|[\s>])@([A-Za-z0-9_.-]+@[A-Za-z0-9.-]+)/g;
1008 let m;
1009 while ((m = re.exec(html || ''))) handles.add(m[2]);
1010 let out = String(html || '');
1011 for (const h of handles) {
1012 let actorUri = null;
1013 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
1014 if (!actorUri) continue;
1015 const actor = await fetchActor(actorUri).catch(() => null);
1016 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
1017 if (inbox) inboxes.push(inbox);
1018 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
1019 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1020 out = out.replace(new RegExp('(^|[\\s>])@' + esc + '(?![A-Za-z0-9_.-])', 'g'),
1021 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
1022 }
1023 return { html: out, inboxes };
1024}
1025
1026export function buildReplyNote(base, site, row) {
1027 const me = actorId(base, site.slug);
1028 return {
1029 id: noteId(base, row.id),
1030 type: 'Note',
1031 attributedTo: me,
1032 inReplyTo: row.in_reply_to || undefined,
1033 content: row.content,
1034 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
1035 published: toISO(row.created_at),
1036 to: row.to_actor ? [row.to_actor] : [PUBLIC],
1037 cc: [PUBLIC, `${me}/followers`],
1038 tag: [
1039 ...mentionTags(row.content),
1040 ...hashtagTags(base, row.content),
1041 ],
1042 };
1043}
1044
1045// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
1046export function getOutboxNote(base, id) {
1047 const row = iStmts().getO.get(id);
1048 if (!row) return null;
1049 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
1050 if (!site) return null;
1051 return buildReplyNote(base, site, row);
1052}
1053
1054// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
1055// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
1056export async function deliverReply(site, { postId, postSlug, parent, text }) {
1057 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1058 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
1059 const me = actorId(base, site.slug);
1060 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
1061 const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
1062 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
1063 const mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
1064 const mention = parent.actor_uri
1065 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention" data-actor="${escHtml(parent.actor_uri)}">${escHtml(dispHandle)}</a> ` : '';
1066 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1067 // Dedup: skip if the exact same reply was already sent (double-submit guard).
1068 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
1069 .get(site.slug, parent.object_uri || '', content);
1070 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
1071 const id = crypto.randomUUID();
1072 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
1073 const row = iStmts().getO.get(id);
1074 const note = buildReplyNote(base, site, row);
1075 const create = {
1076 '@context': 'https://www.w3.org/ns/activitystreams',
1077 id: note.id + '#create', type: 'Create', actor: me,
1078 published: note.published, to: note.to, cc: note.cc, object: note,
1079 };
1080 const keys = getOrCreateKeys(site.slug);
1081 const keyId = `${me}#main-key`;
1082 const inboxes = new Set();
1083 if (parent.actor_uri) {
1084 const a = await fetchActor(parent.actor_uri).catch(() => null);
1085 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1086 }
1087 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
1088 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
1089 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1090 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
1091 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
1092 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
1093 let delivered = 0;
1094 for (const inbox of [...inboxes].filter(Boolean)) {
1095 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1096 }
1097 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
1098 return { id, content, delivered };
1099}
1100
1101// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
1102// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
1103function actorUriOf(att) {
1104 if (!att) return null;
1105 if (typeof att === 'string') return att;
1106 if (Array.isArray(att)) {
1107 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
1108 if (person) return person.id;
1109 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
1110 return null;
1111 }
1112 return att.id || null;
1113}
1114
1115// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
1116// Returns a parent-shaped object usable by deliverReply(), or null.
1117export async function resolveRemoteNote(url) {
1118 if (!/^https?:\/\//i.test(String(url || ''))) return null;
1119 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
1120 if (!note || !note.id) return null;
1121 const att = note.attributedTo;
1122 const actorUri = actorUriOf(att);
1123 if (!actorUri) return null;
1124 const actor = await fetchActor(actorUri).catch(() => null);
1125 const ai = actorInfo(actor, actorUri);
1126 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
1127 // link our reply to that local post so it shows nested in the post thread.
1128 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
1129 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
1130 // and collect every ancestor author's inbox, so each participant's server —
1131 // including the original post's author — receives + threads our reply.
1132 const threadInboxes = [];
1133 const seenInbox = new Set();
1134 let cursor = note.inReplyTo, guard = 0;
1135 while (cursor && guard++ < 6) {
1136 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
1137 if (!url) break;
1138 const pn = await fetchActor(url).catch(() => null);
1139 if (!pn) break;
1140 const pa = actorUriOf(pn.attributedTo);
1141 if (pa && pa !== actorUri) {
1142 const paDoc = await fetchActor(pa).catch(() => null);
1143 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
1144 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
1145 }
1146 cursor = pn.inReplyTo; // climb to the next ancestor
1147 }
1148 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
1149 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
1150 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1151 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
1152 const images = (Array.isArray(note.attachment) ? note.attachment : [])
1153 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
1154 .map((a) => safeUrl(a.url)).filter(Boolean);
1155 return {
1156 object_uri: safeUrl(note.id) || note.id,
1157 actor_uri: actorUri,
1158 actor_url: ai.url,
1159 actor_handle: ai.handle,
1160 actor_name: ai.name,
1161 actor_icon: ai.icon,
1162 url: note.url || url,
1163 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
1164 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
1165 cw: note.summary || '',
1166 images,
1167 threadInboxes, // every ancestor author's inbox
1168 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
1169 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1170 };
1171}
1172
1173// List a site's own outbound fediverse replies (for the manage/delete view).
1174// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1175// so the manage view can prefill an edit box; the mention is re-added on save.
1176function outboxEditableText(content) {
1177 return String(content || '')
1178 .replace(/<br\s*\/?>/gi, '\n')
1179 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1180 .replace(/<[^>]+>/g, '')
1181 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1182 .trim();
1183}
1184export function listOutbox(siteSlug) {
1185 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1186 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1187}
1188
1189// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1190export async function deliverOutboxDelete(site, outboxId) {
1191 const row = iStmts().getO.get(outboxId);
1192 if (!row || row.site_slug !== site.slug) return false;
1193 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1194 if (base) {
1195 const me = actorId(base, site.slug);
1196 const nid = noteId(base, row.id);
1197 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' } };
1198 const keys = getOrCreateKeys(site.slug);
1199 const inboxes = new Set();
1200 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1201 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1202 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1203 }
1204 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1205 return true;
1206}
1207
1208// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1209// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1210export async function deliverOutboxUpdate(site, outboxId, newText) {
1211 const row = iStmts().getO.get(outboxId);
1212 if (!row || row.site_slug !== site.slug) return false;
1213 const text = String(newText || '').trim();
1214 if (!text) return false;
1215 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1216 if (!base) return false;
1217 const me = actorId(base, site.slug);
1218 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
1219 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
1220 const _h = row.to_handle || deriveHandle(row.to_actor);
1221 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
1222 const mention = row.to_actor
1223 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
1224 const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
1225 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1226 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1227 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1228 note.updated = new Date().toISOString();
1229 const update = {
1230 '@context': 'https://www.w3.org/ns/activitystreams',
1231 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1232 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1233 };
1234 const keys = getOrCreateKeys(site.slug);
1235 const inboxes = new Set();
1236 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
1237 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1238 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
1239 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1240 let delivered = 0;
1241 for (const inbox of [...inboxes].filter(Boolean)) {
1242 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1243 }
1244 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1245 return { ok: true, content, delivered };
1246}
1247
1248// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1249// Resolve an @user@domain handle to its actor URL via WebFinger.
1250export async function webfingerResolve(handle) {
1251 const h = String(handle || '').trim().replace(/^@/, '');
1252 const parts = h.split('@');
1253 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1254 const acct = `${parts[0]}@${parts[1]}`;
1255 try {
1256 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1257 { headers: { Accept: 'application/jrd+json, application/json' } });
1258 if (!r.ok) return null;
1259 const jrd = await r.json();
1260 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1261 return safeUrl(link ? link.href : '') || null;
1262 } catch { return null; }
1263}
1264
1265let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1266function fwStmts() {
1267 if (!_insFw) {
1268 _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)');
1269 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1270 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1271 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1272 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1273 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1274 }
1275 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1276}
1277export function listFollowing(slug) { return fwStmts().list.all(slug); }
1278
1279// Toggle auto-boost ("feature") on an account we already follow.
1280export function setAutoBoost(slug, actorUri, on) {
1281 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1282 return { ok: true };
1283}
1284
1285let _insTl, _listTl, _delTl;
1286function tlStmts() {
1287 if (!_insTl) {
1288 _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)');
1289 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1290 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1291 }
1292 return { ins: _insTl, list: _listTl, del: _delTl };
1293}
1294export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1295
1296// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1297let _abCount, _cirkelPosts, _cirkelMembers;
1298export function autoBoostCount(slug) {
1299 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; }
1300}
1301export function getCirkelPosts(slug, limit) {
1302 try {
1303 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1304 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1305 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1306 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1307 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1308 FROM ap_timeline t
1309 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1310 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1311 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1312 LIMIT ?`);
1313 return _cirkelPosts.all(slug, limit || 60);
1314 } catch { return []; }
1315}
1316export function getCirkelMembers(slug) {
1317 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 []; }
1318}
1319// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1320let _markBoost, _unmarkBoost, _boostedCount;
1321export function markBoosted(slug, noteId) {
1322 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1323}
1324export function unmarkBoosted(slug, noteId) {
1325 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1326}
1327let _markLike, _unmarkLike;
1328export function markLiked(slug, noteId) {
1329 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1330}
1331export function unmarkLiked(slug, noteId) {
1332 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1333}
1334export function getTimelineReaction(slug, noteId) {
1335 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 }; }
1336}
1337// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1338// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1339// the Cirkel with a Boost badge, then flag it boosted.
1340export function upsertBoostedNote(slug, note) {
1341 if (!slug || !note || !note.object_uri) return;
1342 const id = note.object_uri;
1343 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1344 try {
1345 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1346 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1347 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1348 } catch { /* ignore */ }
1349 markBoosted(slug, id);
1350}
1351export function boostedCount(slug) {
1352 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; }
1353}
1354
1355// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1356// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1357// followActor for bare-domain follows.
1358// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1359// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1360// never throws anything into the fediverse automatically" (would surprise-Follow
1361// for some operators at scale). The dead circle_links table stays as harmless dead
1362// data; an operator restores an old cirkel by re-following in /following (their click).
1363async function resolveApActor(siteUrl) {
1364 try {
1365 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1366 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1367 if (r.ok) return siteUrl;
1368 } catch { /* unreachable */ }
1369 return null;
1370}
1371
1372// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1373// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1374// note and refreshes content + media (recovers covers/edits that were delivered
1375// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1376// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1377const SELFHEAL_VERSION = 2;
1378async function fetchNoteAP(url) {
1379 try {
1380 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1381 if (r.status === 404 || r.status === 410) return 404;
1382 if (r.ok) return await r.json();
1383 } catch { /* unreachable */ }
1384 return null;
1385}
1386function mediaFromNote(note) {
1387 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1388 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1389 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1390 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1391 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1392 }
1393 return JSON.stringify(atts);
1394}
1395let _selfHealing = false;
1396export async function selfHealTimeline() {
1397 if (_selfHealing) return; _selfHealing = true;
1398 try {
1399 let cur = 0;
1400 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; }
1401 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1402 let rows = [];
1403 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
1404 let healed = 0;
1405 for (const r of rows) {
1406 try {
1407 const note = await fetchNoteAP(r.id);
1408 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1409 if (!note || typeof note !== 'object') continue;
1410 const html = HtmlSanitizerService.sanitize(note.content || '');
1411 const media = mediaFromNote(note);
1412 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1413 const cw = note.summary || null;
1414 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '')) {
1415 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, r.id);
1416 healed++;
1417 }
1418 } catch { /* per-note best-effort */ }
1419 }
1420 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1421 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1422 } catch { /* never block boot */ } finally { _selfHealing = false; }
1423}
1424
1425// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1426export async function followActor(site, handle, autoBoost = false) {
1427 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1428 if (!base || !site || !site.slug) return { error: 'config' };
1429 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1430 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1431 // resolves to its AP actor, so you can follow a site by just its domain.
1432 const s = String(handle || '').trim();
1433 let actorUrl;
1434 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1435 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1436 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1437 else actorUrl = null;
1438 if (!actorUrl) return { error: 'not_found' };
1439 const actor = await fetchActor(actorUrl).catch(() => null);
1440 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1441 const ai = actorInfo(actor, actor.id);
1442 const me = actorId(base, site.slug);
1443 const keys = getOrCreateKeys(site.slug);
1444 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1445 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1446 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
1447 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
1448 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
1449 console.log('[AP] follow', site.slug, '→', actor.id);
1450 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1451}
1452
1453// Resolve a profile URL or @handle to a followable remote actor (for the
1454// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1455// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1456export async function resolveRemoteActor(input) {
1457 const s = String(input || '').trim();
1458 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1459 if (!actorUrl) return null;
1460 const actor = await fetchActor(actorUrl).catch(() => null);
1461 if (!actor || !actor.id || !actor.inbox) return null;
1462 const ai = actorInfo(actor, actor.id);
1463 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1464}
1465
1466export async function unfollowActor(site, actorUri) {
1467 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1468 const me = actorId(base, site.slug);
1469 const keys = getOrCreateKeys(site.slug);
1470 const row = fwStmts().one.get(site.slug, actorUri);
1471 if (row && row.inbox) {
1472 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 } };
1473 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
1474 }
1475 fwStmts().del.run(site.slug, actorUri);
1476 return { ok: true };
1477}
1478
1479// Send a Like or Announce (boost) on a remote note FROM this site.
1480export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1481 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1482 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1483 const me = actorId(base, site.slug);
1484 const keys = getOrCreateKeys(site.slug);
1485 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1486 // reblog (matched on actor+object — no record of the original Announce needed).
1487 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1488 let act;
1489 if (kind === 'unboost' || kind === 'unlike') {
1490 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1491 // no record of the original activity needed — Mastodon honours both).
1492 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1493 act = {
1494 '@context': 'https://www.w3.org/ns/activitystreams',
1495 id: `${me}#undo-${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1496 object: { id: `${me}#${inner.toLowerCase()}-${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1497 };
1498 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1499 } else {
1500 const type = kind === 'boost' ? 'Announce' : 'Like';
1501 act = {
1502 '@context': 'https://www.w3.org/ns/activitystreams',
1503 id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
1504 type, actor: me, object: targetNoteId,
1505 };
1506 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1507 }
1508 const inboxes = new Set();
1509 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1510 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1511 let delivered = 0;
1512 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 */ } }
1513 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'delivered', delivered);
1514 return { ok: true, delivered };
1515}
1516
1517// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1518export function getNotifications(slug, limit) {
1519 const out = [];
1520 try {
1521 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)) {
1522 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1523 }
1524 } catch { /* ignore */ }
1525 try {
1526 const rows = db.prepare(`
1527 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1528 p.slug AS post_slug, p.title AS post_title
1529 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1530 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1531 ORDER BY i.created_at DESC LIMIT 80
1532 `).all(slug);
1533 for (const r of rows) out.push({
1534 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1535 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1536 });
1537 } catch { /* ignore */ }
1538 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1539 return out.slice(0, limit || 60);
1540}
1541
1542// ── Blocking / defederation ───────────────────────────────────────
1543let _insBl, _delBl, _listBl;
1544function blStmts() {
1545 if (!_insBl) {
1546 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1547 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1548 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1549 }
1550 return { ins: _insBl, del: _delBl, list: _listBl };
1551}
1552export function listBlocks(slug) { return blStmts().list.all(slug); }
1553
1554// True if an actor (or its whole domain) is blocked anywhere on this instance.
1555export function isBlockedAny(actorUri) {
1556 if (!actorUri) return false;
1557 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1558 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1559 catch { return false; }
1560}
1561
1562function purgeBlocked(kind, target) {
1563 try {
1564 if (kind === 'domain') {
1565 const like = `%//${target}/%`;
1566 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1567 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1568 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1569 } else {
1570 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1571 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1572 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1573 }
1574 } catch { /* best-effort */ }
1575}
1576
1577// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1578export async function blockTarget(site, input) {
1579 const raw = String(input || '').trim();
1580 if (!site || !site.slug || !raw) return { error: 'empty' };
1581 let kind, target, label;
1582 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1583 else if (raw.includes('@')) {
1584 const actorUrl = await webfingerResolve(raw);
1585 if (!actorUrl) return { error: 'not_found' };
1586 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1587 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1588 blStmts().ins.run(site.slug, target, kind, label);
1589 purgeBlocked(kind, target);
1590 console.log('[AP] block', site.slug, kind, target);
1591 return { ok: true, label };
1592}
1593
1594export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1595
1596export default {
1597 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1598 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1599 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1600 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1601 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
1602 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
1603 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
1604 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1605 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1606 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1607};
Note: See TracBrowser for help on using the repository browser.