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

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

feat(music): per-track "share on the fediverse" — federate the file as a native AS2 Audio attachment

A per-track opt-in (default off) so an OPEN track's audio file is federated as a real AS2 Audio
attachment and served ungated → it plays inline in EVERY fediverse client, incl. the official
Mastodon apps (which only play native media, not external player cards). Gated tracks (default)
keep the file hidden + web-player-only. This is the spec-canonical way to federate audio; the
gated path stays the deliberate anti-steal choice.

  • src/config/database.js — audio_tracks.fedi_open column (default 0)
  • src/routes/audio.js — /audio/stream serves fedi_open tracks ungated so remote servers can fetch them
  • src/services/ActivityPubService.js (buildNote) — fedi_open tracks → AS2 Audio attachments (the file URL)
  • src/routes/admin-audio.js — POST /:id/fedi-open toggle (god-only) + fedi_open in the track query
  • src/views/pages/admin-audio.ejs — per-track share toggle next to the download toggle
  • src/services/i18n.js — aaud.fedi_on/off labels (nl/en/de)

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

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