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

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

feat(fediverse): host your own polls (federate as AS2 Question)

A post can carry a poll that federates as an AS2 Question so remote (Mastodon)
followers vote from their own app; votes are tallied server-side and the fresh
counts are pushed back as Update(Question). Voting is fediverse-only; the site
shows live, read-only results. Complements the existing inbound poll support.

  • src/config/database.js — posts.poll_json (our poll definition) + poll_votes table (post_id, actor_uri, choice; UNIQUE) backing the tally + per-actor dedupe.
  • src/services/ActivityPubService.js — parseOwnPoll/pollTally/ownPollView helpers; buildNote emits a Question (oneOf/anyOf + replies.totalItems + endTime/closed + votersCount) for a poll post; handleInbox records a ballot (Note with name + inReplyTo our poll) before the reply path, deduped per actor; a debounced Update(Question) pushes fresh counts to followers; votersCount added to AP_CONTEXT.
  • src/services/Scheduler.js — closeExpiredPolls() marks a poll closed once its endTime passes and pushes the final tally; runs on the existing 60s tick.
  • src/routes/posts.js — parsePollForm() turns the editor fields into poll_json on create/save (a poll with votes is frozen), passes poll_json to the federation hooks, and hands the post page a render-ready ownPollView.
  • src/views/pages/post-edit.ejs — poll section (options, multiple-choice, duration); disabled once the poll has votes.
  • src/views/pages/post.ejs — display-only poll with result bars + voter/close meta.
  • src/services/i18n.js — poll.* + pedit.poll_* strings (nl/en/de).
  • test/polls.test.js — Question shape, tally, percentages, closed state, AS2 term.
  • CHANGELOG(.nl/.de).md — "Create your own polls" under Unreleased.

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

  • Property mode set to 100644
File size: 116.1 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 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1546 };
1547}
1548
1549// List a site's own outbound fediverse replies (for the manage/delete view).
1550// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1551// so the manage view can prefill an edit box; the mention is re-added on save.
1552function outboxEditableText(content) {
1553 return String(content || '')
1554 .replace(/<br\s*\/?>/gi, '\n')
1555 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1556 .replace(/<[^>]+>/g, '')
1557 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1558 .trim();
1559}
1560export function listOutbox(siteSlug) {
1561 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1562 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1563}
1564
1565// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1566export async function deliverOutboxDelete(site, outboxId) {
1567 const row = iStmts().getO.get(outboxId);
1568 if (!row || row.site_slug !== site.slug) return false;
1569 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1570 if (base) {
1571 const me = actorId(base, site.slug);
1572 const nid = noteId(base, row.id);
1573 const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
1574 const keys = getOrCreateKeys(site.slug);
1575 const inboxes = new Set();
1576 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1577 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1578 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1579 }
1580 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1581 return true;
1582}
1583
1584// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1585// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1586export async function deliverOutboxUpdate(site, outboxId, newText) {
1587 const row = iStmts().getO.get(outboxId);
1588 if (!row || row.site_slug !== site.slug) return false;
1589 const text = String(newText || '').trim();
1590 if (!text) return false;
1591 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1592 if (!base) return false;
1593 const me = actorId(base, site.slug);
1594 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
1595 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
1596 const _h = row.to_handle || deriveHandle(row.to_actor);
1597 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
1598 const mention = row.to_actor
1599 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
1600 const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
1601 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1602 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1603 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1604 note.updated = new Date().toISOString();
1605 const update = {
1606 '@context': AP_CONTEXT,
1607 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1608 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1609 };
1610 const keys = getOrCreateKeys(site.slug);
1611 const inboxes = new Set();
1612 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
1613 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1614 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
1615 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1616 let delivered = 0;
1617 for (const inbox of [...inboxes].filter(Boolean)) {
1618 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1619 }
1620 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1621 return { ok: true, content, delivered };
1622}
1623
1624// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1625// Resolve an @user@domain handle to its actor URL via WebFinger.
1626export async function webfingerResolve(handle) {
1627 const h = String(handle || '').trim().replace(/^@/, '');
1628 const parts = h.split('@');
1629 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1630 const acct = `${parts[0]}@${parts[1]}`;
1631 try {
1632 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1633 { headers: { Accept: 'application/jrd+json, application/json' } });
1634 if (!r.ok) return null;
1635 const jrd = await r.json();
1636 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1637 return safeUrl(link ? link.href : '') || null;
1638 } catch { return null; }
1639}
1640
1641let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1642function fwStmts() {
1643 if (!_insFw) {
1644 _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)');
1645 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1646 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1647 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1648 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1649 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1650 }
1651 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1652}
1653export function listFollowing(slug) { return fwStmts().list.all(slug); }
1654
1655// Toggle auto-boost ("feature") on an account we already follow.
1656export function setAutoBoost(slug, actorUri, on) {
1657 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1658 // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
1659 // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
1660 if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
1661 return { ok: true };
1662}
1663
1664let _insTl, _listTl, _delTl;
1665function tlStmts() {
1666 if (!_insTl) {
1667 _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)');
1668 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1669 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1670 }
1671 return { ins: _insTl, list: _listTl, del: _delTl };
1672}
1673export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1674
1675// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1676let _abCount, _cirkelPosts, _cirkelMembers;
1677export function autoBoostCount(slug) {
1678 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; }
1679}
1680export function getCirkelPosts(slug, limit) {
1681 try {
1682 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1683 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1684 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1685 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1686 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1687 FROM ap_timeline t
1688 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1689 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1690 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1691 LIMIT ?`);
1692 return _cirkelPosts.all(slug, limit || 60);
1693 } catch { return []; }
1694}
1695export function getCirkelMembers(slug) {
1696 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 []; }
1697}
1698// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1699let _markBoost, _unmarkBoost, _boostedCount;
1700export function markBoosted(slug, noteId) {
1701 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1702}
1703export function unmarkBoosted(slug, noteId) {
1704 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1705}
1706let _markLike, _unmarkLike;
1707export function markLiked(slug, noteId) {
1708 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1709}
1710export function unmarkLiked(slug, noteId) {
1711 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1712}
1713export function getTimelineReaction(slug, noteId) {
1714 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 }; }
1715}
1716// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1717// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1718// the Cirkel with a Boost badge, then flag it boosted.
1719export function upsertBoostedNote(slug, note) {
1720 if (!slug || !note || !note.object_uri) return;
1721 const id = note.object_uri;
1722 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1723 try {
1724 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1725 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1726 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1727 } catch { /* ignore */ }
1728 markBoosted(slug, id);
1729}
1730export function boostedCount(slug) {
1731 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; }
1732}
1733
1734// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1735// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1736// followActor for bare-domain follows.
1737// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1738// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1739// never throws anything into the fediverse automatically" (would surprise-Follow
1740// for some operators at scale). The dead circle_links table stays as harmless dead
1741// data; an operator restores an old cirkel by re-following in /following (their click).
1742async function resolveApActor(siteUrl) {
1743 try {
1744 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1745 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1746 if (r.ok) return siteUrl;
1747 } catch { /* unreachable */ }
1748 return null;
1749}
1750
1751// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1752// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1753// note and refreshes content + media (recovers covers/edits that were delivered
1754// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1755// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1756const SELFHEAL_VERSION = 5; // v5: re-fetch so embed/link-only posts pick up the note.image cover in feeds
1757async function fetchNoteAP(url) {
1758 try {
1759 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1760 if (r.status === 404 || r.status === 410) return 404;
1761 if (r.ok) return await r.json();
1762 } catch { /* unreachable */ }
1763 return null;
1764}
1765function mediaFromNote(note) {
1766 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1767 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1768 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1769 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1770 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1771 }
1772 return JSON.stringify(atts);
1773}
1774// A generic SSRF-safe AP GET (collections / pages).
1775async function apGetJson(url) {
1776 try {
1777 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
1778 if (!r.ok) return null;
1779 const len = Number(r.headers.get('content-length') || 0);
1780 if (len > 3_000_000) return null;
1781 return await r.json();
1782 } catch { return null; }
1783}
1784// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
1785// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
1786// history-from-before-you-followed or a delivery that was missed while you were down;
1787// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
1788export async function backfillFromOutbox(slug, actorUri, limit = 20) {
1789 try {
1790 if (!slug || !actorUri) return 0;
1791 const actor = await fetchActor(actorUri);
1792 if (!actor || !actor.outbox) return 0;
1793 let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
1794 let items = (page && (page.orderedItems || page.items)) || [];
1795 if (!items.length && page && page.first) {
1796 page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
1797 items = (page && (page.orderedItems || page.items)) || [];
1798 }
1799 if (!Array.isArray(items) || !items.length) return 0;
1800 const ai = actorInfo(actor, actorUri);
1801 let added = 0;
1802 for (const it of items.slice(0, limit)) {
1803 // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
1804 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
1805 if (!o || !o.id) continue;
1806 if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
1807 if (o.inReplyTo) continue; // top-level only
1808 const auth = actorUriOf(o.attributedTo);
1809 if (auth && auth !== actorUri) continue; // their OWN posts only
1810 const html = HtmlSanitizerService.sanitize(o.content || '');
1811 const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
1812 try {
1813 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);
1814 if (r && r.changes > 0) added++;
1815 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
1816 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 */ } }
1817 } catch { /* ignore */ }
1818 }
1819 if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
1820 return added;
1821 } catch { return 0; }
1822}
1823let _selfHealing = false;
1824export async function selfHealTimeline() {
1825 if (_selfHealing) return; _selfHealing = true;
1826 try {
1827 let cur = 0;
1828 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; }
1829 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1830 let rows = [];
1831 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 */ }
1832 let healed = 0;
1833 for (const r of rows) {
1834 try {
1835 const note = await fetchNoteAP(r.id);
1836 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1837 if (!note || typeof note !== 'object') continue;
1838 const html = HtmlSanitizerService.sanitize(note.content || '');
1839 const media = mediaFromNote(note);
1840 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1841 const cw = note.summary || null;
1842 const url = note.url || null; // re-sync the human url (catches a remote slug rename)
1843 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url)) {
1844 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);
1845 healed++;
1846 }
1847 } catch { /* per-note best-effort */ }
1848 }
1849 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1850 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1851 } catch { /* never block boot */ } finally { _selfHealing = false; }
1852}
1853
1854// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1855export async function followActor(site, handle, autoBoost = false) {
1856 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1857 if (!base || !site || !site.slug) return { error: 'config' };
1858 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1859 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1860 // resolves to its AP actor, so you can follow a site by just its domain.
1861 const s = String(handle || '').trim();
1862 let actorUrl;
1863 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1864 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1865 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1866 else actorUrl = null;
1867 if (!actorUrl) return { error: 'not_found' };
1868 const actor = await fetchActor(actorUrl).catch(() => null);
1869 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1870 const ai = actorInfo(actor, actor.id);
1871 const me = actorId(base, site.slug);
1872 const keys = getOrCreateKeys(site.slug);
1873 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1874 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1875 const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
1876 // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
1877 // timeout, transient 5xx) is retried with backoff instead of staying stuck on
1878 // 'pending' forever — the Accept can only come back once the Follow lands.
1879 await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
1880 console.log('[AP] follow', site.slug, '→', actor.id);
1881 // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
1882 if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
1883 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1884}
1885
1886// Resolve a profile URL or @handle to a followable remote actor (for the
1887// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1888// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1889export async function resolveRemoteActor(input) {
1890 const s = String(input || '').trim();
1891 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1892 if (!actorUrl) return null;
1893 const actor = await fetchActor(actorUrl).catch(() => null);
1894 if (!actor || !actor.id || !actor.inbox) return null;
1895 const ai = actorInfo(actor, actor.id);
1896 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1897}
1898
1899export async function unfollowActor(site, actorUri) {
1900 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1901 const me = actorId(base, site.slug);
1902 const keys = getOrCreateKeys(site.slug);
1903 const row = fwStmts().one.get(site.slug, actorUri);
1904 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
1905 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
1906 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
1907 // rather than send an unmatchable one. Deliver durably via the retry queue.
1908 if (row && row.inbox && row.follow_id) {
1909 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 } };
1910 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
1911 } else if (row && row.inbox) {
1912 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
1913 }
1914 fwStmts().del.run(site.slug, actorUri);
1915 return { ok: true };
1916}
1917
1918// Send a Like or Announce (boost) on a remote note FROM this site.
1919export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1920 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1921 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1922 const me = actorId(base, site.slug);
1923 const keys = getOrCreateKeys(site.slug);
1924 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1925 // reblog (matched on actor+object — no record of the original Announce needed).
1926 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1927 const followersCol = `${me}/followers`;
1928 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
1929 // attributes the boost to their post and notifies them — without this, a shared-inbox
1930 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
1931 // stamp keep us aligned with what Mastodon emits.
1932 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
1933 let act;
1934 if (kind === 'unboost' || kind === 'unlike') {
1935 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1936 // no record of the original activity needed — Mastodon honours both).
1937 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1938 act = {
1939 '@context': AP_CONTEXT,
1940 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1941 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1942 };
1943 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
1944 } else {
1945 const type = kind === 'boost' ? 'Announce' : 'Like';
1946 act = {
1947 '@context': AP_CONTEXT,
1948 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
1949 type, actor: me, object: targetNoteId,
1950 };
1951 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
1952 }
1953 const inboxes = new Set();
1954 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
1955 // routes the Announce/Like to the right post unambiguously.
1956 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
1957 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1958 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
1959 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
1960 // silently lose the boost — same durability a new post (deliverCreate) already gets.
1961 let queued = 0;
1962 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
1963 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
1964 return { ok: true, delivered: queued };
1965}
1966
1967// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1968export function getNotifications(slug, limit) {
1969 const out = [];
1970 try {
1971 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)) {
1972 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1973 }
1974 } catch { /* ignore */ }
1975 try {
1976 const rows = db.prepare(`
1977 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1978 p.slug AS post_slug, p.title AS post_title
1979 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1980 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1981 ORDER BY i.created_at DESC LIMIT 80
1982 `).all(slug);
1983 for (const r of rows) out.push({
1984 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1985 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1986 });
1987 } catch { /* ignore */ }
1988 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1989 return out.slice(0, limit || 60);
1990}
1991
1992// ── Blocking / defederation ───────────────────────────────────────
1993let _insBl, _delBl, _listBl;
1994function blStmts() {
1995 if (!_insBl) {
1996 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1997 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1998 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1999 }
2000 return { ins: _insBl, del: _delBl, list: _listBl };
2001}
2002export function listBlocks(slug) { return blStmts().list.all(slug); }
2003
2004// True if an actor (or its whole domain) is blocked anywhere on this instance.
2005// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
2006// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
2007// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
2008// author's Update(Question) refreshes the authoritative totals when it arrives.
2009export async function voteOnPoll(site, questionId, choices) {
2010 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2011 if (!base || !site || !site.slug || !questionId) return { error: 'config' };
2012 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 */ }
2013 if (!row || !row.poll_json) return { error: 'not_found' };
2014 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
2015 if (poll.closed) return { error: 'closed' };
2016 if (poll.voted) return { error: 'already' };
2017 const valid = new Set(poll.options.map((o) => o.name));
2018 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
2019 if (!picks.length) return { error: 'invalid' };
2020 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
2021 const me = actorId(base, site.slug);
2022 const keys = getOrCreateKeys(site.slug);
2023 const authorUri = row.author_uri || null;
2024 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
2025 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
2026 if (!inbox) return { error: 'unreachable' };
2027 for (const name of chosen) {
2028 const nid = `${me}/votes/${Date.now()}-${rid()}`;
2029 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
2030 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
2031 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
2032 }
2033 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
2034 poll.voted = poll.multiple ? chosen : chosen[0];
2035 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
2036 if (poll.voters != null) poll.voters += 1;
2037 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
2038 return { ok: true };
2039}
2040
2041export function isBlockedAny(actorUri) {
2042 if (!actorUri) return false;
2043 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
2044 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
2045 catch { return false; }
2046}
2047
2048function purgeBlocked(kind, target) {
2049 try {
2050 if (kind === 'domain') {
2051 // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
2052 // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
2053 const purge = (table, col) => {
2054 let rows = [];
2055 try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
2056 const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
2057 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 */ } } }
2058 };
2059 purge('ap_interactions', 'actor_uri');
2060 purge('ap_timeline', 'author_uri');
2061 purge('ap_followers', 'actor_uri');
2062 } else {
2063 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
2064 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
2065 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
2066 }
2067 } catch { /* best-effort */ }
2068}
2069
2070// Block an actor (@handle or actor URL) or a whole domain; purges their content.
2071export async function blockTarget(site, input) {
2072 const raw = String(input || '').trim();
2073 if (!site || !site.slug || !raw) return { error: 'empty' };
2074 let kind, target, label;
2075 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
2076 else if (raw.includes('@')) {
2077 const actorUrl = await webfingerResolve(raw);
2078 if (!actorUrl) return { error: 'not_found' };
2079 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
2080 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
2081 blStmts().ins.run(site.slug, target, kind, label);
2082 purgeBlocked(kind, target);
2083 console.log('[AP] block', site.slug, kind, target);
2084 return { ok: true, label };
2085}
2086
2087export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
2088
2089export default {
2090 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
2091 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
2092 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
2093 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
2094 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
2095 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll,
2096 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
2097 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
2098 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
2099 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
2100 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
2101};
Note: See TracBrowser for help on using the repository browser.