source: Klonkt/src/services/ActivityPubService.js@ 80797d7

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

feat(federation): add schema.org/PeerTube embedUrl to playable-audio notes (experiment)

Mirrors how PeerTube exposes an embed endpoint: a playable-audio Note now carries an
embedUrl pointing at the gated /embed player page (no audio file is exposed — anti-steal
intact). If a Mastodon client honours embedUrl on a Note it can show an inline player;
otherwise it degrades to the existing twitter:player card. To be confirmed in the official
Mastodon mobile app (where external player cards don't play inline).

  • src/services/ActivityPubService.js (buildNote) — note.embedUrl = <base>/embed?post=<slug> for playable-audio posts

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

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