source: Klonkt/src/services/ActivityPubService.js@ 857a06f

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

feat(video-cover): federate an animated cover as a Video attachment (not the WebP)

buildNote now prefers post.cover_video_url -> the muted loop MP4 ships as an AS2 Video attachment
instead of the animated WebP (unreliable on Mastodon + its iOS apps; the MP4 plays everywhere).
mediaType learns mp4/webm/mov; cover_video_url threaded through every buildNote source
(create/save hooks, Scheduler, outbox + featured + backfill SELECTs).

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

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