source: Klonkt/src/services/ActivityPubService.js@ 667fb41

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

feat(polls): vote on any fediverse poll from the interact page

The interact page (paste any post URL) now detects a Question and shows a ballot
(radio/checkbox + Vote) so you can vote on any fediverse poll by URL — not only
polls from accounts you follow (which vote via /news). Casts the Mastodon-standard
ballot straight to the poll's author, no timeline cache needed.

  • src/services/ActivityPubService.js — voteOnRemotePoll(site, url, choices) fetches the Question fresh, validates the choice(s) and delivers the ballot to the author; resolveRemoteNote now returns the parsed poll so the view can render options.
  • src/routes/posts.js — POST /authorize_interaction/vote; GET passes voted and skips re-resolving the target on the confirmation view.
  • src/views/pages/authorize-interaction.ejs — ballot (open) / results (closed) above the like/boost/reply actions, a "vote sent" confirmation, and scoped .auth-poll styles.
  • src/services/i18n.js — poll.voted_title/_done (nl/en/de).

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

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